diff --git a/.github/rulesets/Default branch ruleset.json b/.github/rulesets/Default branch ruleset.json index 0fc2dd0..334d2ee 100644 --- a/.github/rulesets/Default branch ruleset.json +++ b/.github/rulesets/Default branch ruleset.json @@ -36,6 +36,7 @@ "parameters": { "required_approving_review_count": 0, "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, "require_last_push_approval": true, "required_review_thread_resolution": true, "automatic_copilot_code_review_enabled": false, @@ -50,7 +51,12 @@ "parameters": { "strict_required_status_checks_policy": true, "do_not_enforce_on_create": false, - "required_status_checks": [] + "required_status_checks": [ + { + "context": "Run Unit Tests", + "integration_id": 15368 + } + ] } }, { diff --git a/.github/rulesets/Tagging ruleset.json b/.github/rulesets/Tagging ruleset.json index f6b5576..bb4ac8a 100644 --- a/.github/rulesets/Tagging ruleset.json +++ b/.github/rulesets/Tagging ruleset.json @@ -36,7 +36,12 @@ "parameters": { "strict_required_status_checks_policy": true, "do_not_enforce_on_create": false, - "required_status_checks": [] + "required_status_checks": [ + { + "context": "Run Unit Tests", + "integration_id": 15368 + } + ] } } ], diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml new file mode 100644 index 0000000..0fa85c3 --- /dev/null +++ b/.github/workflows/tests-ci.yml @@ -0,0 +1,25 @@ +on: [ "push", "pull_request" ] + +name: Unit Tests + +jobs: + test: + name: Run Unit Tests + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@v4 + + - name: Use Node.js 20.x + uses: actions/setup-node@v3 + with: + node-version: 20.x + + - name: Install dependencies + run: | + npm install -g pnpm@latest + pnpm i + + - name: Run tests + run: | + pnpm test:ci:coverage \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6b6523d..48def25 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,6 +6,7 @@ "recommendations": [ "dbaeumer.vscode-eslint", "davidanson.vscode-markdownlint", + "Orta.vscode-jest", ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [] diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..520edcf --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "configurations": [ + { + "type": "node", + "name": "Debug Tests", + "request": "launch", + "args": [ + "test", + "--", + "--runInBand", + "--watchAll=false", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "runtimeExecutable": "pnpm" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index aa78f8d..d5e3cf7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,7 @@ "statusBar.border":"#484848", "statusBar.foreground": "#F0F0F0", "activityBar.activeBorder": "#484848", + "activityBarBadge.foreground":"#484848", + "activityBarBadge.background":"#F0F0F0", }, } \ No newline at end of file diff --git a/package.json b/package.json index 957ba3a..8cd6497 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "type": "git", "url": "git+https://github.com/alessiofrittoli/next-api.git" }, - "main": "./dist/index.cjs", + "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "files": [ @@ -31,24 +31,32 @@ ], "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/index.mjs", - "require": "./dist/index.cjs" + "require": "./dist/index.js" }, "./error": { "types": "./dist/error/index.d.ts", "import": "./dist/error/index.mjs", - "require": "./dist/error/index.cjs" + "require": "./dist/error/index.js" }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.mjs", - "require": "./dist/server/index.cjs" + "./request": { + "types": "./dist/request/index.d.ts", + "import": "./dist/request/index.mjs", + "require": "./dist/request/index.js" + }, + "./response": { + "types": "./dist/response/index.d.ts", + "import": "./dist/response/index.mjs", + "require": "./dist/response/index.js" + }, + "./route-wrappers": { + "types": "./dist/route-wrappers/index.d.ts", + "import": "./dist/route-wrappers/index.mjs", + "require": "./dist/route-wrappers/index.js" }, "./types": { "types": "./dist/types/index.d.ts" - }, - "./types/api": { - "types": "./dist/types/api.d.ts" } }, "sideEffects": false, @@ -74,7 +82,7 @@ "test:next-response": "pnpm test next-response.test.ts" }, "peerDependencies": { - "next": ">=14.0.0" + "next": ">=15.0.0" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -100,7 +108,7 @@ "@alessiofrittoli/exception": "^2.0.0", "@alessiofrittoli/http-server-status": "^1.0.0", "@alessiofrittoli/node-scripts": "^2.2.1", - "@alessiofrittoli/post-install-scripts": "^0.3.0", + "@alessiofrittoli/stream-reader": "^1.1.0", "@alessiofrittoli/type-utils": "^1.5.0", "@alessiofrittoli/url-utils": "^1.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6079a17..0766c64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,9 @@ importers: '@alessiofrittoli/node-scripts': specifier: ^2.2.1 version: 2.2.1 - '@alessiofrittoli/post-install-scripts': - specifier: ^0.3.0 - version: 0.3.0 + '@alessiofrittoli/stream-reader': + specifier: ^1.1.0 + version: 1.1.0 '@alessiofrittoli/type-utils': specifier: ^1.5.0 version: 1.5.0 @@ -96,8 +96,8 @@ packages: '@alessiofrittoli/node-scripts@2.2.1': resolution: {integrity: sha512-VP9pUyrPhudlv/1UOxb1BfisKOSAAl/0H8beM5Udn3ddMHPVbrP4ul1rLqDbHWsFzePmpOiOsvOpEAsj2YPnWA==} - '@alessiofrittoli/post-install-scripts@0.3.0': - resolution: {integrity: sha512-2NSRCoxYJLxod5sBIJxAE3YYJAe3Wu01beu1gkDHV5EZdnU+ekGq+NqJp1NUWeNZceny3FPGaPULWVrlHV5cJQ==} + '@alessiofrittoli/stream-reader@1.1.0': + resolution: {integrity: sha512-wTVNCAwVnBe/kUvmq+Dx1SH0cLyfhaymgMSyXDhX06uHbzkrMqPg2f9SEvIDCozN2nDzSMbaE4RPUzULFhOcoA==} '@alessiofrittoli/type-utils@1.5.0': resolution: {integrity: sha512-UCc2S8PCYaWS9uaKwF3k8MkU1vPqulnGk08NFDBi+dmjFQvyGCkG1mkSvfQjjXz0XOzh5zgn+mVqP+fZLF2CcQ==} @@ -2722,7 +2722,7 @@ snapshots: '@alessiofrittoli/node-scripts@2.2.1': {} - '@alessiofrittoli/post-install-scripts@0.3.0': {} + '@alessiofrittoli/stream-reader@1.1.0': {} '@alessiofrittoli/type-utils@1.5.0': dependencies: diff --git a/src/error/index.ts b/src/error/index.ts index bd8e083..cb3dc95 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -1,11 +1,9 @@ -import Exception from '@alessiofrittoli/exception/code' +import { ErrorCode as Exception } from '@alessiofrittoli/exception/code' export enum Next { // other custom error codes... } -const ErrorCode = { Exception, Next } -type ErrorCode = MergedEnumValue - -export default ErrorCode \ No newline at end of file +export const ErrorCode = { Exception, Next } +export type ErrorCode = MergedEnumValue \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 4090278..144ade8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ -export { default as NextResponse } from './server' -export { default as ErrorCode } from './error' +export * from '@/error' +export * from '@/request' +export * from '@/response' +export * from '@/route-wrappers' export type * from './types' \ No newline at end of file diff --git a/src/request/index.ts b/src/request/index.ts new file mode 100644 index 0000000..6aac98a --- /dev/null +++ b/src/request/index.ts @@ -0,0 +1,20 @@ +import type { NextRequest } from 'next/server' + +export * from './read' + + +/** + * Get request IP address. + * + * @param request The NextRequest Instance. + * @returns The Request IP address. + */ +export const getRequestIp = async ( request?: NextRequest ) => { + + const headers = request?.headers || await ( await import( 'next/headers' ) ).headers() + const forwarded = headers.get( 'X-Forwarded-For' )?.replace( /\s/g, '' ).split( ',' ).at( -1 ) + const realIp = headers.get( 'X-Real-Ip' )?.replace( /\s/g, '' ).split( ',' ).at( -1 ) + + return forwarded || realIp || null + +} \ No newline at end of file diff --git a/src/request/read.ts b/src/request/read.ts new file mode 100644 index 0000000..9ef0c96 --- /dev/null +++ b/src/request/read.ts @@ -0,0 +1,24 @@ +import type { Api } from '@/types/api' + +/** + * Read Incoming Request Json Body. + * + * @param body The Request|Body instance. + * @returns The Awaited result of {@link Body.json}, null if Body is empty, locked or invalid. + */ +export const readJsonBody = async < + T extends Api.Route.RequestBody = Api.Route.RequestBody +>( body: Api.Route.Request ) => { + + try { + + return await body.clone().json() + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch ( error ) { + + return null + + } + +} \ No newline at end of file diff --git a/src/server/index.ts b/src/response/index.ts similarity index 75% rename from src/server/index.ts rename to src/response/index.ts index f1615ea..8769f10 100644 --- a/src/server/index.ts +++ b/src/response/index.ts @@ -2,58 +2,28 @@ import { NextResponse as NextApiResponse } from 'next/server' import type { NextRequest } from 'next/server' import { removeTrailingSlash } from '@alessiofrittoli/url-utils/slash' -import Exception from '@alessiofrittoli/exception' +import { Exception } from '@alessiofrittoli/exception' import { ResponseStatus } from '@alessiofrittoli/http-server-status' import { message as responseMessage } from '@alessiofrittoli/http-server-status/message' +import { StreamReader } from '@alessiofrittoli/stream-reader' -import type Api from '../types/api' -import ErrorCode from '../error' - - -export interface NextResponseProps -{ - /** The Response BodyInit. */ - body?: BodyInit | null - /** ResponseInit */ - init?: ResponseInit - /** The NextRequest instance. This is used internally to retrieve Request Headers. */ - request?: NextRequest - /** An Object of {@link Api.CORSPolicy} defining custom policies for the API Response. If `true` CORS is enabled with the default configuration. */ - cors?: Api.CORSPolicy | true -} - - -export interface CorsHeadersOptions -{ - /** An object of {@link Api.CORSPolicy} defining custom policies for the API Response. */ - options?: Api.CORSPolicy - /** Custom Response Headers. */ - headers?: Headers | HeadersInit -} - -export type NextResponseIterator = ( - | Generator - | AsyncGenerator -) - -export type NextResponseStreamIterator = ( - | ReadableStream | NextResponseIterator -) +import { ErrorCode } from '@/error' +import type { CorsHeadersOptions, NextResponseProps, NextResponseStreamIterator } from './types' +import type { Api } from '@/types/api' +export * from './types' /** * This class extends the [`NextResponse` API](https://nextjs.org/docs/app/api-reference/functions/next-response) with additional convenience methods. * - * FIXME: add link to doc - * Read more: [Next API Docs: NextResponse](#todo-add-link) */ -class NextResponse extends NextApiResponse +export class NextResponse extends NextApiResponse { /** * The NextResponse CORS options. * */ - static CorsOptions?: Api.CORSPolicy | true + static CorsOptions?: Api.CORS.Policy | true /** @@ -116,30 +86,7 @@ class NextResponse extends NextApiResponse } - /** - * Convert an Iterator into a ReadableStream. - * - * @link https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#convert_async_iterator_to_stream - * - * @param iterator The Iterator to convert. - * @returns A new ReadableStream instance - */ - static iteratorToStream( iterator: NextResponseIterator ): ReadableStream - { - return ( - new ReadableStream( { - async pull( controller ) { - - const { value, done } = await iterator.next() - - if ( ! done ) return controller.enqueue( value ) - - return controller.close() - - }, - } ) - ) - } + static iteratorToStream = StreamReader.generatorToReadableStream /** @@ -148,8 +95,8 @@ class NextResponse extends NextApiResponse * @param iterator The Iterator to stream. * @returns A new Response with Iterator ReadableStream Body. */ - static stream( - iterator: NextResponseStreamIterator, + static stream( + iterator: NextResponseStreamIterator, init? : ResponseInit, ) { @@ -187,7 +134,7 @@ class NextResponse extends NextApiResponse */ static successJson( body: JsonBody, init?: ResponseInit, time?: number ) { - const data: Api.Response = { + const data: Api.Route.ResponseBody = { ms : time, message : body, } @@ -242,10 +189,10 @@ class NextResponse extends NextApiResponse * Enables Cross Origin Resource Sharing. * * @param request The incoming NextRequest instance. - * @param cors ( Optional ) An Object of {@link Api.CORSPolicy} defining custom policies for the API Response. + * @param cors ( Optional ) An Object of {@link Api.CORS.Policy} defining custom policies for the API Response. * @returns The NextResponse instance allowing chaining methods. */ - static cors( request?: NextRequest, cors?: Api.CORSPolicy ) + static cors( request?: NextRequest, cors?: Api.CORS.Policy ) { const origin = request?.headers.get( 'origin' ) @@ -336,8 +283,4 @@ class NextResponse extends NextApiResponse return corsHeaders } -} - - - -export default NextResponse \ No newline at end of file +} \ No newline at end of file diff --git a/src/response/types.ts b/src/response/types.ts new file mode 100644 index 0000000..08f1f6f --- /dev/null +++ b/src/response/types.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from 'next/server' +import type { StreamGenerator } from '@alessiofrittoli/stream-reader/types' +import type { Api } from '@/types/api' + +/** + * Interface representing the options for the NextResponse constructor. + * + */ +export interface NextResponseProps +{ + /** The Response BodyInit. */ + body?: BodyInit | null + /** ResponseInit */ + init?: ResponseInit + /** The NextRequest instance. This is used internally to retrieve Request Headers. */ + request?: NextRequest + /** An Object of {@link Api.CORS.Policy} defining custom policies for the API Response. If `true` CORS is enabled with the default configuration. */ + cors?: Api.CORS.Policy | true +} + + +/** + * Options for configuring CORS headers in an API response. + */ +export interface CorsHeadersOptions +{ + /** An object of {@link Api.CORS.Policy} defining custom policies for the API Response. */ + options?: Api.CORS.Policy + /** Custom Response Headers. */ + headers?: Headers | HeadersInit +} + + +/** + * Represents an iterator for a Next.js response stream. + * + * @template T - The type of data being streamed. Defaults to `unknown`. + * + * This type can be either a `ReadableStream` of type `T` or a `StreamGenerator` of type `T`. + */ +export type NextResponseStreamIterator = ( + | ReadableStream | StreamGenerator +) \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index a2e71f8..3349c3b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,32 +1,155 @@ -namespace Api +import type { NextRequest } from 'next/server' + + +/** + * Namespace containing types related to API. + */ +export namespace Api { /** The HTTP Request method. */ export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD' - /** The Response CORS Policy options. */ - export interface CORSPolicy + + /** + * Namespace containing types related to CORS. + */ + export namespace CORS { - /** The Request origin. */ - requestOrigin?: string | null - /** The Request allowed origin. */ - origin?: string | string[] | null - /** An array of allowed Request Methods. */ - methods?: Exclude[] - /** An array of allowed Request Headers. */ - headers?: string[] - /** An array of exposed Response Headers. */ - exposedHeaders?: string[] - /** Whether allow credentials or not. */ - credentials?: boolean + /** + * Represents the policy configuration for handling API requests. + */ + export interface Policy { + /** + * The Request origin. + * @remarks + * This specifies the origin of the request. It can be a string or null. + */ + requestOrigin?: string | null; + + /** + * The Request allowed origin. + * @remarks + * This specifies the allowed origin(s) for the request. It can be a string, an array of strings, or null. + */ + origin?: string | string[] | null; + + /** + * An array of allowed Request Methods. + * @remarks + * This specifies the HTTP methods that are allowed for the request, excluding 'OPTIONS'. + */ + methods?: Exclude[]; + + /** + * An array of allowed Request Headers. + * @remarks + * This specifies the headers that are allowed in the request. + */ + headers?: string[]; + + /** + * An array of exposed Response Headers. + * @remarks + * This specifies the headers that can be exposed in the response. + */ + exposedHeaders?: string[]; + + /** + * Whether to allow credentials or not. + * @remarks + * This specifies whether credentials (such as cookies or HTTP authentication) are allowed in the request. + */ + credentials?: boolean; + } } - - /** The JSON Response Type. */ - export interface Response + + /** + * Namespace containing types related to API routes. + */ + export namespace Route { - message : T - ms? : number + /** + * Represents the context for an API request. + * + * @template T - The type of the parameters. + */ + export interface Context + { + params: Promise + } + + + /** + * Represents a Next.js API request with an optional generic type for extending the request object. + * + * @template T - An optional type to extend the NextApiRequest object. Defaults to `unknown`. + */ + export type Request = NextRequest & T + + + /** + * Represents the body of a request. + * + */ + export interface RequestBody + { + /** + * The Google reCaptcha v3 token. + * reCaptcha is automatically verified in the API handler if the secret is defined in the ENV variables. + * The request will be then processed if the token is found in the request body, otherwise it will be rejected. + */ + gReCaptchaToken?: string + /** + * User Requested locale. + */ + locale?: string + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [ x: string ]: any + } + + + /** The JSON Response Type. */ + export interface ResponseBody + { + message : T + ms? : number + } + + + /** + * Represents the return type of an API handler function. + * It can either be a `Response` object or a `Promise` that resolves to a `Response` object. + */ + export type HandlerReturnType = globalThis.Response | Promise + + + /** + * Represents a handler function for processing API requests. + * + * @template T - The type of the request payload. Defaults to `unknown`. + * @param request - The request object containing the payload of type `T`. + * @returns A `Response` object or a `Promise` that resolves to a `Response` object. + */ + export type Handler = ( request: Api.Route.Request ) => HandlerReturnType + + + /** + * Represents a dynamic handler function for API routes. + * + * @template Body - The type of the request body. Defaults to `unknown`. + * @template RouteParams - The type of the route parameters. Defaults to `unknown`. + * + * @param request - The incoming request object containing the body of type `Body`. + * @param ctx - The context object containing route parameters of type `RouteParams`. + * + * @returns A `Response` object or a `Promise` that resolves to a `Response` object. + */ + export type DynamicHandler< + Body = unknown, + RouteParams = unknown, + > = ( request: Api.Route.Request, ctx: Api.Route.Context ) => HandlerReturnType } -} -export default Api \ No newline at end of file +} \ No newline at end of file diff --git a/src/types/globals.ts b/src/types/globals.ts index e78cebb..2b114da 100644 --- a/src/types/globals.ts +++ b/src/types/globals.ts @@ -10,4 +10,4 @@ declare global } } -export type DoNotUse = never \ No newline at end of file +export {} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 556ab9a..acb3d86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,2 @@ -export type { default as Api } from './api' +export type * from './api' export type * from './globals' \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 225b658..0da1705 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,30 +8,28 @@ "declaration": true, "isolatedModules": true, "outDir": "dist", - "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "target": "ES2020", + "lib": [ "dom", "dom.iterable", "esnext" ], + "allowJs": true, + "module": "ESNext", + "moduleResolution": "bundler", "skipLibCheck": true, "downlevelIteration": true, "noUncheckedIndexedAccess": true, "strictPropertyInitialization": false, "resolveJsonModule": true, - "typeRoots": [ - "src/types", - "node_modules/@types" - ], + "jsx": "preserve", + "incremental": true, + "tsBuildInfoFile": ".tsbuildinfo", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": [ "./src/*" ] } }, "include": [ "src", "__tests__", - "alessiofrittoli-env.d.ts" + "alessiofrittoli-env.d.ts", + "next-env.d.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": [ "node_modules", "dist" ] } \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index da9922e..8d0f7bf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,24 +1,17 @@ import { defineConfig } from 'tsup' export default defineConfig( { - entry : [ 'src/**/*.ts' ], + entry: [ + 'src/index.ts', 'src/error/index.ts', + 'src/request/index.ts', 'src/response/index.ts', + 'src/route-wrappers/index.ts', 'src/types/index.ts', + ], format : [ 'cjs', 'esm' ], dts : true, splitting : false, - shims : true, + shims : false, skipNodeModulesBundle: true, clean : true, treeshake : true, minify : true, - outExtension( ctx ) { - if ( ctx.format === 'esm' ) { - return { - dts : '.d.ts', - js : '.mjs', - } - } - return { - js: '.cjs' - } - }, } ) \ No newline at end of file