From ab3ea19a0f446d612eacc0ca7bdd0c98b6a869b8 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Sun, 5 Nov 2023 13:49:19 +0200 Subject: [PATCH] Implement RPC API handlers and client This introduces a new way of writing RPC styled API handlers and abstract away the use of status codes, HTTP responses etc. The input validation, type-cheking and OpenAPI spec generation are handled in the same way as with the REST endpoint handlers. Additionally, a new RPC client function is introduced that allows calling the procedures both on the server and in the client. --- apps/example/public/openapi.json | 134 +++++++ apps/example/src/app/api/routes/rpc/route.ts | 104 ++++++ apps/example/src/app/layout.tsx | 6 +- .../src/app/rpc-client-example/page.tsx | 13 + apps/example/src/pages/api/api-routes/rpc.ts | 11 + .../src/app-router/docs-route-handler.ts | 13 +- .../src/app-router/index.ts | 2 + .../src/app-router/route-handler.ts | 33 +- .../src/app-router/route-operation.ts | 234 +++++++++++- .../src/app-router/rpc-route-handler.ts | 146 ++++++++ .../src/app-router/typed-next-response.ts | 5 + packages/next-rest-framework/src/cli.ts | 13 +- .../next-rest-framework/src/client/index.ts | 1 + .../src/client/rpc-client.ts | 50 +++ packages/next-rest-framework/src/constants.ts | 7 +- packages/next-rest-framework/src/index.ts | 17 +- .../src/pages-router/api-route-handler.ts | 32 +- .../src/pages-router/api-route-operation.ts | 166 ++++++++- .../pages-router/docs-api-route-handler.ts | 15 +- .../src/pages-router/index.ts | 1 + .../src/pages-router/rpc-api-route-handler.ts | 121 ++++++ .../src/{utils => shared}/config.ts | 0 .../src/{utils => shared}/docs.ts | 0 .../src/{utils => shared}/index.ts | 1 + .../src/{utils => shared}/logging.ts | 0 .../src/{utils => shared}/open-api.ts | 282 +++++++++++--- .../src/shared/rpc-operation.ts | 107 ++++++ .../src/{utils => shared}/schemas.ts | 0 packages/next-rest-framework/src/types.ts | 140 +++++++ .../next-rest-framework/src/types/config.ts | 47 --- .../src/types/content-types.ts | 68 ---- .../next-rest-framework/src/types/index.ts | 5 - .../src/types/route-handlers.ts | 346 ------------------ .../src/types/typed-next-response.ts | 89 ----- .../src/types/utility-types.ts | 3 - .../tests/app-router/index.test.ts | 22 +- .../tests/app-router/paths.test.ts | 6 +- .../tests/app-router/rpc.test.ts | 158 ++++++++ .../tests/pages-router/index.test.ts | 21 +- .../tests/pages-router/paths.test.ts | 6 +- .../tests/pages-router/rpc.test.ts | 159 ++++++++ packages/next-rest-framework/tests/utils.ts | 59 ++- 42 files changed, 1949 insertions(+), 694 deletions(-) create mode 100644 apps/example/src/app/api/routes/rpc/route.ts create mode 100644 apps/example/src/app/rpc-client-example/page.tsx create mode 100644 apps/example/src/pages/api/api-routes/rpc.ts create mode 100644 packages/next-rest-framework/src/app-router/rpc-route-handler.ts create mode 100644 packages/next-rest-framework/src/app-router/typed-next-response.ts create mode 100644 packages/next-rest-framework/src/client/index.ts create mode 100644 packages/next-rest-framework/src/client/rpc-client.ts create mode 100644 packages/next-rest-framework/src/pages-router/rpc-api-route-handler.ts rename packages/next-rest-framework/src/{utils => shared}/config.ts (100%) rename packages/next-rest-framework/src/{utils => shared}/docs.ts (100%) rename packages/next-rest-framework/src/{utils => shared}/index.ts (80%) rename packages/next-rest-framework/src/{utils => shared}/logging.ts (100%) rename packages/next-rest-framework/src/{utils => shared}/open-api.ts (57%) create mode 100644 packages/next-rest-framework/src/shared/rpc-operation.ts rename packages/next-rest-framework/src/{utils => shared}/schemas.ts (100%) create mode 100644 packages/next-rest-framework/src/types.ts delete mode 100644 packages/next-rest-framework/src/types/config.ts delete mode 100644 packages/next-rest-framework/src/types/content-types.ts delete mode 100644 packages/next-rest-framework/src/types/index.ts delete mode 100644 packages/next-rest-framework/src/types/route-handlers.ts delete mode 100644 packages/next-rest-framework/src/types/typed-next-response.ts delete mode 100644 packages/next-rest-framework/src/types/utility-types.ts create mode 100644 packages/next-rest-framework/tests/app-router/rpc.test.ts create mode 100644 packages/next-rest-framework/tests/pages-router/rpc.test.ts diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index c30d60f..0e6b8c8 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -152,6 +152,81 @@ "tags": ["example-api", "todos", "pages-router"] } }, + "/api/routes/rpc": { + "post": { + "description": "RPC endpoint", + "tags": ["RPC"], + "operationId": "rpcCall", + "parameters": [ + { + "name": "X-RPC-Operation", + "in": "header", + "schema": { "type": "string" }, + "required": true, + "description": "The RPC operation to call." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "discriminator": { + "propertyName": "X-RPC-Operation", + "mapping": { + "getTodoById": "#/components/schemas/GetTodoByIdBody", + "createTodo": "#/components/schemas/CreateTodoBody", + "deleteTodo": "#/components/schemas/DeleteTodoBody" + } + }, + "oneOf": [ + "#/components/schemas/GetTodoByIdBody", + "#/components/schemas/CreateTodoBody", + "#/components/schemas/DeleteTodoBody" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "discriminator": { + "propertyName": "X-RPC-Operation", + "mapping": { + "getTodoById": "#/components/schemas/GetTodoByIdBody", + "createTodo": "#/components/schemas/CreateTodoBody", + "deleteTodo": "#/components/schemas/DeleteTodoBody" + } + }, + "oneOf": [ + "#/components/schemas/GetTodosResponse1", + "#/components/schemas/GetTodoByIdResponse1", + "#/components/schemas/GetTodoByIdResponse2", + "#/components/schemas/CreateTodoResponse1", + "#/components/schemas/DeleteTodoResponse1", + "#/components/schemas/DeleteTodoResponse2" + ] + } + } + } + }, + "default": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "message": { "type": "string" } } + } + } + } + } + } + } + }, "/api/routes/todos": { "get": { "responses": { @@ -298,5 +373,64 @@ "tags": ["example-api", "todos", "app-router"] } } + }, + "components": { + "schemas": { + "CreateTodoBody": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"], + "additionalProperties": false + }, + "CreateTodoResponse1": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"], + "additionalProperties": false + }, + "DeleteTodoBody": { "type": "string" }, + "DeleteTodoResponse1": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"], + "additionalProperties": false + }, + "DeleteTodoResponse2": { + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"], + "additionalProperties": false + }, + "GetTodoByIdBody": { "type": "string" }, + "GetTodoByIdResponse1": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"], + "additionalProperties": false + }, + "GetTodoByIdResponse2": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "completed": { "type": "boolean" } + }, + "required": ["id", "name", "completed"], + "additionalProperties": false + }, + "GetTodosResponse1": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" }, + "completed": { "type": "boolean" } + }, + "required": ["id", "name", "completed"], + "additionalProperties": false + } + } + } } } diff --git a/apps/example/src/app/api/routes/rpc/route.ts b/apps/example/src/app/api/routes/rpc/route.ts new file mode 100644 index 0000000..87dfc81 --- /dev/null +++ b/apps/example/src/app/api/routes/rpc/route.ts @@ -0,0 +1,104 @@ +import { rpcOperation, rpcRouteHandler } from 'next-rest-framework'; +import { z } from 'zod'; + +const TODOS = [ + { + id: 1, + name: 'TODO 1', + completed: false + } +]; + +// Example App Router RPC handler. +export const POST = rpcRouteHandler({ + getTodos: rpcOperation({ + // Optional OpenAPI operation documentation. + operationId: 'getTodos', + tags: ['example-api', 'todos', 'app-router', 'rpc'] + }) + // Output schema for strictly-typed responses and OpenAPI documentation. + .output([ + z.array( + z.object({ + id: z.number(), + name: z.string(), + completed: z.boolean() + }) + ) + ]) + .handler(() => { + // Type-checked response. + return TODOS; + }), + + getTodoById: rpcOperation({ + operationId: 'getTodoById', + tags: ['example-api', 'todos', 'app-router', 'rpc'] + }) + .input(z.string()) + .output([ + z.object({ + error: z.string() + }), + z.object({ + id: z.number(), + name: z.string(), + completed: z.boolean() + }) + ]) + .handler((id) => { + const todo = TODOS.find((t) => t.id === Number(id)); + + if (!todo) { + // Type-checked response. + return { error: 'TODO not found.' }; + } + + // Type-checked response. + return todo; + }), + + createTodo: rpcOperation({ + // Optional OpenAPI operation documentation. + operationId: 'createTodo', + tags: ['example-api', 'todos', 'app-router', 'rpc'] + }) + // Input schema for strictly-typed request, request validation and OpenAPI documentation. + .input( + z.object({ + name: z.string() + }) + ) + // Output schema for strictly-typed responses and OpenAPI documentation. + .output([z.object({ message: z.string() })]) + .handler(async ({ name }) => { + // Type-checked response. + return { message: `New TODO created: ${name}` }; + }), + + deleteTodo: rpcOperation({ + operationId: 'deleteTodo', + tags: ['example-api', 'todos', 'app-router', 'rpc'] + }) + .input(z.string()) + .output([ + z.object({ error: z.string() }), + z.object({ message: z.string() }) + ]) + .handler((id) => { + // Delete todo. + const todo = TODOS.find((t) => t.id === Number(id)); + + if (!todo) { + // Type-checked response. + return { + error: 'TODO not found.' + }; + } + + // Type-checked response. + return { message: 'TODO deleted.' }; + }) +}); + +export type AppRouterRpcClient = typeof POST.client; diff --git a/apps/example/src/app/layout.tsx b/apps/example/src/app/layout.tsx index 6b29d3a..e6e02cb 100644 --- a/apps/example/src/app/layout.tsx +++ b/apps/example/src/app/layout.tsx @@ -5,7 +5,11 @@ export const metadata = { description: 'Example application for Next REST Framework' }; -export default function Layout({ children }: { children: React.ReactNode }) { +export default async function Layout({ + children +}: { + children: React.ReactNode; +}) { const cookieStore = cookies(); const theme = cookieStore.get('theme')?.value ?? 'light'; diff --git a/apps/example/src/app/rpc-client-example/page.tsx b/apps/example/src/app/rpc-client-example/page.tsx new file mode 100644 index 0000000..3c72cf1 --- /dev/null +++ b/apps/example/src/app/rpc-client-example/page.tsx @@ -0,0 +1,13 @@ +import { rpcClient } from 'next-rest-framework/dist/client'; +import { type AppRouterRpcClient } from '../api/routes/rpc/route'; + +// Works both on server and client. +const client = rpcClient({ + url: 'http://localhost:3000/api/routes/rpc' +}); + +// Simple example - the client can be easily integrated with any data fetching framework, like React Query or RTKQ. +export default async function Page() { + const data = await client.getTodos(); + return <>{JSON.stringify(data)}; +} diff --git a/apps/example/src/pages/api/api-routes/rpc.ts b/apps/example/src/pages/api/api-routes/rpc.ts new file mode 100644 index 0000000..4e06c01 --- /dev/null +++ b/apps/example/src/pages/api/api-routes/rpc.ts @@ -0,0 +1,11 @@ +import { rpcApiRouteHandler } from 'next-rest-framework'; + +// Example Pages Router RPC handler. +const handler = rpcApiRouteHandler({ + // ... + // Exactly the same as the App Router example. +}); + +export default handler; + +export type RpcApiRouteClient = typeof handler.client; diff --git a/packages/next-rest-framework/src/app-router/docs-route-handler.ts b/packages/next-rest-framework/src/app-router/docs-route-handler.ts index e8fb451..3277818 100644 --- a/packages/next-rest-framework/src/app-router/docs-route-handler.ts +++ b/packages/next-rest-framework/src/app-router/docs-route-handler.ts @@ -2,13 +2,13 @@ import { type NextRequest, NextResponse } from 'next/server'; import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants'; import { type BaseQuery, type NextRestFrameworkConfig } from '../types'; import { - generatePathsFromDev, + fetchOasDataFromDev, getConfig, syncOpenApiSpec, logInitInfo, logNextRestFrameworkError, getHtmlForDocs -} from '../utils'; +} from '../shared'; export const docsRouteHandler = (_config?: NextRestFrameworkConfig) => { const config = getConfig(_config); @@ -38,8 +38,13 @@ export const docsRouteHandler = (_config?: NextRestFrameworkConfig) => { } if (config.autoGenerateOpenApiSpec) { - const paths = await generatePathsFromDev({ config, baseUrl, url }); - await syncOpenApiSpec({ config, paths }); + const nrfOasData = await fetchOasDataFromDev({ + config, + baseUrl, + url + }); + + await syncOpenApiSpec({ config, nrfOasData }); } } diff --git a/packages/next-rest-framework/src/app-router/index.ts b/packages/next-rest-framework/src/app-router/index.ts index 0a80099..174e7f5 100644 --- a/packages/next-rest-framework/src/app-router/index.ts +++ b/packages/next-rest-framework/src/app-router/index.ts @@ -1,3 +1,5 @@ export * from './docs-route-handler'; export * from './route-handler'; export * from './route-operation'; +export * from './rpc-route-handler'; +export { TypedNextResponse } from './typed-next-response'; diff --git a/packages/next-rest-framework/src/app-router/route-handler.ts b/packages/next-rest-framework/src/app-router/route-handler.ts index bffbe89..0a35b68 100644 --- a/packages/next-rest-framework/src/app-router/route-handler.ts +++ b/packages/next-rest-framework/src/app-router/route-handler.ts @@ -1,12 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants'; -import { type RouteParams, type BaseQuery } from '../types'; +import { type BaseQuery } from '../types'; import { getPathsFromMethodHandlers, isValidMethod, validateSchema, logNextRestFrameworkError -} from '../utils'; +} from '../shared'; + +import { type ValidMethod } from '../constants'; + +import { type OpenAPIV3_1 } from 'openapi-types'; + +import { type RouteOperationDefinition } from './route-operation'; + +export interface RouteParams { + openApiPath?: OpenAPIV3_1.PathItemObject; + [ValidMethod.GET]?: RouteOperationDefinition; + [ValidMethod.PUT]?: RouteOperationDefinition; + [ValidMethod.POST]?: RouteOperationDefinition; + [ValidMethod.DELETE]?: RouteOperationDefinition; + [ValidMethod.OPTIONS]?: RouteOperationDefinition; + [ValidMethod.HEAD]?: RouteOperationDefinition; + [ValidMethod.PATCH]?: RouteOperationDefinition; +} export const routeHandler = (methodHandlers: RouteParams) => { const handler = async (req: NextRequest, context: { params: BaseQuery }) => { @@ -37,12 +54,12 @@ export const routeHandler = (methodHandlers: RouteParams) => { const route = decodeURIComponent(pathname ?? ''); try { - const nextRestFrameworkPaths = getPathsFromMethodHandlers({ + const nrfOasData = getPathsFromMethodHandlers({ methodHandlers, route }); - return NextResponse.json({ nextRestFrameworkPaths }, { status: 200 }); + return NextResponse.json({ nrfOasData }, { status: 200 }); } catch (error) { throw Error(`OpenAPI spec generation failed for route: ${route} ${error}`); @@ -91,7 +108,7 @@ ${error}`); if (!valid) { return NextResponse.json( { - message: 'Invalid request body.', + message: DEFAULT_ERRORS.invalidRequestBody, errors }, { @@ -102,7 +119,7 @@ ${error}`); } catch (error) { return NextResponse.json( { - message: 'Missing request body.' + message: DEFAULT_ERRORS.missingRequestBody }, { status: 400 @@ -119,7 +136,7 @@ ${error}`); if (!valid) { return NextResponse.json( { - message: 'Invalid query parameters.', + message: DEFAULT_ERRORS.invalidQueryParameters, errors }, { @@ -131,7 +148,7 @@ ${error}`); } if (!handler) { - throw Error('Handler not found.'); + throw Error(DEFAULT_ERRORS.handlerNotFound); } const res = await handler(req, context); diff --git a/packages/next-rest-framework/src/app-router/route-operation.ts b/packages/next-rest-framework/src/app-router/route-operation.ts index fce44d8..006d126 100644 --- a/packages/next-rest-framework/src/app-router/route-operation.ts +++ b/packages/next-rest-framework/src/app-router/route-operation.ts @@ -1,10 +1,238 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ + +import { type OpenAPIV3_1 } from 'openapi-types'; import { - type RouteOperation, - type RouteOperationDefinition, + type BaseStatus, + type BaseQuery, type InputObject, type OutputObject, - type NextRouteHandler + type BaseContentType, + type Modify, + type AnyCase } from '../types'; +import { type NextRequest, type NextResponse } from 'next/server'; +import { type z } from 'zod'; +import { type ValidMethod } from '../constants'; +import { type I18NConfig } from 'next/dist/server/config-shared'; +import { type ResponseCookies } from 'next/dist/server/web/spec-extension/cookies'; +import { type NextURL } from 'next/dist/server/web/next-url'; + +type TypedHeaders = Modify< + Record, + { + [K in AnyCase<'Content-Type'>]?: ContentType; + } +>; + +interface TypedResponseInit< + Status extends BaseStatus, + ContentType extends BaseContentType +> extends globalThis.ResponseInit { + nextConfig?: { + basePath?: string; + i18n?: I18NConfig; + trailingSlash?: boolean; + }; + url?: string; + status?: Status; + headers?: TypedHeaders; +} + +interface ModifiedRequest { + headers?: Headers; +} + +interface TypedMiddlewareResponseInit + extends globalThis.ResponseInit { + request?: ModifiedRequest; + status?: Status; +} + +declare const INTERNALS: unique symbol; + +// A patched `NextResponse` that allows to strongly-typed status code and content-type. +export declare class TypedNextResponse< + Body, + Status extends BaseStatus, + ContentType extends BaseContentType +> extends Response { + [INTERNALS]: { + cookies: ResponseCookies; + url?: NextURL; + body?: Body; + status?: Status; + contentType?: ContentType; + }; + + constructor( + body?: BodyInit | null, + init?: TypedResponseInit + ); + + get cookies(): ResponseCookies; + + static json< + Body, + Status extends BaseStatus, + ContentType extends BaseContentType + >( + body: Body, + init?: TypedResponseInit + ): TypedNextResponse; + + static redirect< + Status extends BaseStatus, + ContentType extends BaseContentType + >( + url: string | NextURL | URL, + init?: number | TypedResponseInit + ): TypedNextResponse; + + static rewrite< + Status extends BaseStatus, + ContentType extends BaseContentType + >( + destination: string | NextURL | URL, + init?: TypedMiddlewareResponseInit + ): TypedNextResponse; + + static next( + init?: TypedMiddlewareResponseInit + ): TypedNextResponse; +} + +type TypedNextRequest = Modify< + NextRequest, + { + json: () => Promise; + method: ValidMethod; + nextUrl: Modify< + NextURL, + { + searchParams: Modify< + URLSearchParams, + { + get: (key: keyof Query) => string | null; + getAll: (key: keyof Query) => string[]; + } + >; + } + >; + } +>; + +type RouteHandler< + Body = unknown, + Query extends BaseQuery = BaseQuery, + ResponseBody = unknown, + Status extends BaseStatus = BaseStatus, + ContentType extends BaseContentType = BaseContentType, + Output extends ReadonlyArray< + OutputObject + > = ReadonlyArray>, + TypedResponse = + | TypedNextResponse< + z.infer, + Output[number]['status'], + Output[number]['contentType'] + > + | NextResponse> + | void +> = ( + req: TypedNextRequest, + context: { params: Record } +) => Promise | TypedResponse; + +type RouteOutput< + Middleware extends boolean = false, + Body = unknown, + Query extends BaseQuery = BaseQuery +> = < + ResponseBody, + Status extends BaseStatus, + ContentType extends BaseContentType, + Output extends ReadonlyArray> +>( + params?: Output +) => { + handler: ( + callback?: RouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > + ) => RouteOperationDefinition; +} & (Middleware extends true + ? { + middleware: ( + callback?: RouteHandler< + unknown, + BaseQuery, + ResponseBody, + Status, + ContentType, + Output + > + ) => { + handler: ( + callback?: RouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > + ) => RouteOperationDefinition; + }; + } + : Record); + +type RouteInput = < + Body, + Query extends BaseQuery +>( + params?: InputObject +) => { + output: RouteOutput; + handler: (callback?: RouteHandler) => RouteOperationDefinition; +} & (Middleware extends true + ? { + middleware: (callback?: RouteHandler) => { + output: RouteOutput; + handler: ( + callback?: RouteHandler + ) => RouteOperationDefinition; + }; + } + : Record); + +type NextRouteHandler = ( + req: NextRequest, + context: { params: BaseQuery } +) => Promise | NextResponse | Promise | void; + +export interface RouteOperationDefinition { + _config: { + openApiOperation?: OpenAPIV3_1.OperationObject; + input?: InputObject; + output?: readonly OutputObject[]; + middleware?: NextRouteHandler; + handler?: NextRouteHandler; + }; +} + +type RouteOperation = (openApiOperation?: OpenAPIV3_1.OperationObject) => { + input: RouteInput; + output: RouteOutput; + middleware: (middleware?: RouteHandler) => { + handler: (callback?: RouteHandler) => RouteOperationDefinition; + }; + handler: (callback?: RouteHandler) => RouteOperationDefinition; +}; export const routeOperation: RouteOperation = (openApiOperation) => { const createConfig = ( diff --git a/packages/next-rest-framework/src/app-router/rpc-route-handler.ts b/packages/next-rest-framework/src/app-router/rpc-route-handler.ts new file mode 100644 index 0000000..b896825 --- /dev/null +++ b/packages/next-rest-framework/src/app-router/rpc-route-handler.ts @@ -0,0 +1,146 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { + DEFAULT_ERRORS, + NEXT_REST_FRAMEWORK_USER_AGENT, + ValidMethod +} from '../constants'; +import { + validateSchema, + logNextRestFrameworkError, + getOasDataFromRpcOperations +} from '../shared'; +import { type Client } from '../client/rpc-client'; +import { type OperationDefinition } from '../shared/rpc-operation'; + +export const rpcRouteHandler = < + T extends Record> +>( + operations: T +) => { + const handler = async (req: NextRequest) => { + try { + const { method, headers, nextUrl } = req; + const { pathname } = nextUrl; + + if (method !== ValidMethod.POST) { + return NextResponse.json( + { message: DEFAULT_ERRORS.methodNotAllowed }, + { + status: 405, + headers: { + Allow: 'POST' + } + } + ); + } + + if ( + process.env.NODE_ENV !== 'production' && + headers.get('user-agent') === NEXT_REST_FRAMEWORK_USER_AGENT + ) { + const route = decodeURIComponent(pathname ?? ''); + + try { + const nrfOasData = getOasDataFromRpcOperations({ + operations, + route + }); + + return NextResponse.json({ nrfOasData }, { status: 200 }); + } catch (error) { + throw Error(`OpenAPI spec generation failed for route: ${route} +${error}`); + } + } + + const operation = + operations[ + (headers.get('x-rpc-operation') as keyof typeof operations) ?? '' + ]; + + if (!operation) { + return NextResponse.json( + { message: DEFAULT_ERRORS.operationNotAllowed }, + { + status: 400 + } + ); + } + + const { input, handler, middleware } = operation._meta; + + if (middleware) { + const res = await middleware(req.clone().body); + + if (res) { + return NextResponse.json(res, { status: 200 }); + } + } + + if (input) { + if (headers.get('content-type')?.split(';')[0] !== 'application/json') { + return NextResponse.json( + { message: DEFAULT_ERRORS.invalidMediaType }, + { status: 415 } + ); + } + + try { + const reqClone = req.clone(); + const body = await reqClone.json(); + + const { valid, errors } = await validateSchema({ + schema: input, + obj: body + }); + + if (!valid) { + return NextResponse.json( + { + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }, + { + status: 400 + } + ); + } + } catch (error) { + return NextResponse.json( + { + message: DEFAULT_ERRORS.missingRequestBody + }, + { + status: 400 + } + ); + } + } + + if (!handler) { + throw Error(DEFAULT_ERRORS.handlerNotFound); + } + + const res = await handler(req.clone().body); + return NextResponse.json(res, { status: 200 }); + } catch (error) { + logNextRestFrameworkError(error); + + return NextResponse.json( + { message: DEFAULT_ERRORS.unexpectedError }, + { status: 500 } + ); + } + }; + + handler.getPaths = (route: string) => + getOasDataFromRpcOperations({ + operations, + route + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + handler.client = {} as Client; + + return handler; +}; diff --git a/packages/next-rest-framework/src/app-router/typed-next-response.ts b/packages/next-rest-framework/src/app-router/typed-next-response.ts new file mode 100644 index 0000000..24f1a69 --- /dev/null +++ b/packages/next-rest-framework/src/app-router/typed-next-response.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; +import { type TypedNextResponse as TypedNextResponseType } from './route-operation'; + +// @ts-expect-error - Keep the original NextResponse functionality with custom types. +export const TypedNextResponse: typeof TypedNextResponseType = NextResponse; diff --git a/packages/next-rest-framework/src/cli.ts b/packages/next-rest-framework/src/cli.ts index 433395b..b558a40 100644 --- a/packages/next-rest-framework/src/cli.ts +++ b/packages/next-rest-framework/src/cli.ts @@ -8,15 +8,16 @@ import chalk from 'chalk'; import { type NextRestFrameworkConfig } from './types'; import { type OpenAPIV3_1 } from 'openapi-types'; import { + type NrfOasData, getApiRouteName, getNestedFiles, getRouteName, - getSortedPaths, isValidMethod, isWildcardMatch, logIgnoredPaths, + sortObjectByKeys, syncOpenApiSpec -} from './utils'; +} from './shared'; import { existsSync, readFileSync } from 'fs'; import { isEqualWith, merge } from 'lodash'; import { OPEN_API_VERSION } from './constants'; @@ -28,7 +29,7 @@ const generatePathsFromBuild = async ({ }: { config: Required; distDir: string; -}): Promise => { +}): Promise => { const ignoredPaths: string[] = []; // Check if the route is allowed or denied by the user. @@ -140,7 +141,7 @@ const generatePathsFromBuild = async ({ logIgnoredPaths(ignoredPaths); } - return getSortedPaths(paths); + return { paths: sortObjectByKeys(paths) }; }; const findConfig = async ({ @@ -257,8 +258,8 @@ const syncOpenApiSpecFromBuild = async ({ } console.log(chalk.yellowBright('Next REST Framework config found!')); - const paths = await generatePathsFromBuild({ config, distDir }); - await syncOpenApiSpec({ config, paths }); + const nrfOasData = await generatePathsFromBuild({ config, distDir }); + await syncOpenApiSpec({ config, nrfOasData }); }; // Sync the `openapi.json` file from generated paths from the build output. diff --git a/packages/next-rest-framework/src/client/index.ts b/packages/next-rest-framework/src/client/index.ts new file mode 100644 index 0000000..deb93d9 --- /dev/null +++ b/packages/next-rest-framework/src/client/index.ts @@ -0,0 +1 @@ +export * from './rpc-client'; diff --git a/packages/next-rest-framework/src/client/rpc-client.ts b/packages/next-rest-framework/src/client/rpc-client.ts new file mode 100644 index 0000000..a623311 --- /dev/null +++ b/packages/next-rest-framework/src/client/rpc-client.ts @@ -0,0 +1,50 @@ +import { type ZodSchema, type z } from 'zod'; +import { type OperationDefinition } from '../shared'; + +export type Client>> = { + [key in keyof T]: T[key]; +}; + +const fetcher = async ( + body: z.infer>, + options: { url: string; operationId: string } +) => { + const opts = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-RPC-Operation': options.operationId + } + }; + + const res = await fetch( + options.url, + body ? { ...opts, body: JSON.stringify(body) } : opts + ); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message); + } + + return await res.json(); +}; + +export const rpcClient = < + T extends Record> +>({ + url +}: { + url: string; +}) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return new Proxy({} as Client, { + get: (_, prop) => { + if (typeof prop === 'string') { + return async (body?: z.infer>) => { + return await fetcher(body, { url, operationId: prop }); + }; + } + } + }); +}; diff --git a/packages/next-rest-framework/src/constants.ts b/packages/next-rest-framework/src/constants.ts index f869633..45c660a 100644 --- a/packages/next-rest-framework/src/constants.ts +++ b/packages/next-rest-framework/src/constants.ts @@ -7,7 +7,12 @@ export const DEFAULT_ERRORS = { unexpectedError: 'An unknown error occurred, trying again might help.', methodNotAllowed: 'Method not allowed.', notFound: 'Not found.', - invalidMediaType: 'Invalid media type.' + invalidMediaType: 'Invalid media type.', + operationNotAllowed: 'Operation not allowed.', + invalidRequestBody: 'Invalid request body.', + missingRequestBody: 'Missing request body.', + invalidQueryParameters: 'Invalid query parameters.', + handlerNotFound: 'Handler not found.' }; export const OPEN_API_VERSION = '3.0.1'; diff --git a/packages/next-rest-framework/src/index.ts b/packages/next-rest-framework/src/index.ts index 8121c73..7659e57 100644 --- a/packages/next-rest-framework/src/index.ts +++ b/packages/next-rest-framework/src/index.ts @@ -1,11 +1,14 @@ -import { NextResponse } from 'next/server'; -import { type TypedNextResponse as TypedNextResponseType } from './types'; export { docsApiRouteHandler, apiRouteHandler, - apiRouteOperation + apiRouteOperation, + rpcApiRouteHandler } from './pages-router'; -export { docsRouteHandler, routeHandler, routeOperation } from './app-router'; - -// @ts-expect-error - Keep the original NextResponse functionality with custom types. -export const TypedNextResponse: typeof TypedNextResponseType = NextResponse; +export { + docsRouteHandler, + routeHandler, + routeOperation, + rpcRouteHandler, + TypedNextResponse +} from './app-router'; +export { rpcOperation } from './shared'; diff --git a/packages/next-rest-framework/src/pages-router/api-route-handler.ts b/packages/next-rest-framework/src/pages-router/api-route-handler.ts index 1e5b6a2..8b78399 100644 --- a/packages/next-rest-framework/src/pages-router/api-route-handler.ts +++ b/packages/next-rest-framework/src/pages-router/api-route-handler.ts @@ -1,12 +1,28 @@ -import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants'; -import { type ApiRouteParams } from '../types'; +import { + DEFAULT_ERRORS, + NEXT_REST_FRAMEWORK_USER_AGENT, + type ValidMethod +} from '../constants'; import { getPathsFromMethodHandlers, isValidMethod, validateSchema, logNextRestFrameworkError -} from '../utils'; +} from '../shared'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; +import { type OpenAPIV3_1 } from 'openapi-types'; +import { type ApiRouteOperationDefinition } from './api-route-operation'; + +export interface ApiRouteParams { + openApiPath?: OpenAPIV3_1.PathItemObject; + [ValidMethod.GET]?: ApiRouteOperationDefinition; + [ValidMethod.PUT]?: ApiRouteOperationDefinition; + [ValidMethod.POST]?: ApiRouteOperationDefinition; + [ValidMethod.DELETE]?: ApiRouteOperationDefinition; + [ValidMethod.OPTIONS]?: ApiRouteOperationDefinition; + [ValidMethod.HEAD]?: ApiRouteOperationDefinition; + [ValidMethod.PATCH]?: ApiRouteOperationDefinition; +} export const apiRouteHandler = (methodHandlers: ApiRouteParams) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -30,12 +46,12 @@ export const apiRouteHandler = (methodHandlers: ApiRouteParams) => { const route = decodeURIComponent(pathname ?? ''); try { - const nextRestFrameworkPaths = getPathsFromMethodHandlers({ + const nrfOasData = getPathsFromMethodHandlers({ methodHandlers, route }); - res.status(200).json({ nextRestFrameworkPaths }); + res.status(200).json({ nrfOasData }); return; } catch (error) { throw Error(`OpenAPI spec generation failed for route: ${route} @@ -79,7 +95,7 @@ ${error}`); if (!valid) { res.status(400).json({ - message: 'Invalid request body.', + message: DEFAULT_ERRORS.invalidRequestBody, errors }); @@ -95,7 +111,7 @@ ${error}`); if (!valid) { res.status(400).json({ - message: 'Invalid query parameters.', + message: DEFAULT_ERRORS.invalidQueryParameters, errors }); @@ -105,7 +121,7 @@ ${error}`); } if (!handler) { - throw Error('Handler not found.'); + throw Error(DEFAULT_ERRORS.handlerNotFound); } await handler(req, res); diff --git a/packages/next-rest-framework/src/pages-router/api-route-operation.ts b/packages/next-rest-framework/src/pages-router/api-route-operation.ts index e282c00..25f0c13 100644 --- a/packages/next-rest-framework/src/pages-router/api-route-operation.ts +++ b/packages/next-rest-framework/src/pages-router/api-route-operation.ts @@ -1,10 +1,170 @@ +import { type ValidMethod } from '../constants'; import { - type ApiRouteOperation, type InputObject, type OutputObject, - type ApiRouteOperationDefinition + type Modify, + type AnyCase, + type BaseQuery, + type BaseStatus, + type BaseContentType } from '../types'; -import { type NextApiHandler } from 'next/types'; +import { + type NextApiRequest, + type NextApiHandler, + type NextApiResponse +} from 'next/types'; +import { type OpenAPIV3_1 } from 'openapi-types'; +import { type z } from 'zod'; + +type TypedNextApiRequest = Modify< + NextApiRequest, + { + body: Body; + query: Query; + method: ValidMethod; + } +>; + +type TypedNextApiResponse = Modify< + NextApiResponse, + { + status: (status: Status) => TypedNextApiResponse; + redirect: ( + status: Status, + url: string + ) => TypedNextApiResponse; + + setDraftMode: (options: { + enable: boolean; + }) => TypedNextApiResponse; + + setPreviewData: ( + data: object | string, + options?: { + maxAge?: number; + path?: string; + } + ) => TypedNextApiResponse; + + clearPreviewData: (options?: { + path?: string; + }) => TypedNextApiResponse; + + setHeader: < + K extends AnyCase<'Content-Type'> | string, + V extends number | string | readonly string[] + >( + name: K, + value: K extends AnyCase<'Content-Type'> ? ContentType : V + ) => void; + } +>; + +type ApiRouteHandler< + Body = unknown, + Query extends BaseQuery = BaseQuery, + ResponseBody = unknown, + Status extends BaseStatus = BaseStatus, + ContentType extends BaseContentType = BaseContentType, + Output extends ReadonlyArray< + OutputObject + > = ReadonlyArray> +> = ( + req: TypedNextApiRequest, + res: TypedNextApiResponse< + z.infer, + Output[number]['status'], + Output[number]['contentType'] + > +) => Promise | void; + +type ApiRouteOutput< + Middleware extends boolean = false, + Body = unknown, + Query extends BaseQuery = BaseQuery +> = < + ResponseBody, + Status extends BaseStatus, + ContentType extends BaseContentType, + Output extends ReadonlyArray> +>( + params?: Output +) => { + handler: ( + callback?: ApiRouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > + ) => ApiRouteOperationDefinition; +} & (Middleware extends true + ? { + middleware: ( + callback?: ApiRouteHandler< + unknown, + BaseQuery, + ResponseBody, + Status, + ContentType, + Output + > + ) => { + handler: ( + callback?: ApiRouteHandler< + Body, + Query, + ResponseBody, + Status, + ContentType, + Output + > + ) => ApiRouteOperationDefinition; + }; + } + : Record); + +type ApiRouteInput = < + Body, + Query extends BaseQuery +>( + params?: InputObject +) => { + output: ApiRouteOutput; + handler: ( + callback?: ApiRouteHandler + ) => ApiRouteOperationDefinition; +} & (Middleware extends true + ? { + middleware: (callback?: ApiRouteHandler) => { + output: ApiRouteOutput; + handler: ( + callback?: ApiRouteHandler + ) => ApiRouteOperationDefinition; + }; + } + : Record); + +export interface ApiRouteOperationDefinition { + _config: { + openApiOperation?: OpenAPIV3_1.OperationObject; + input?: InputObject; + output?: readonly OutputObject[]; + middleware?: NextApiHandler; + handler?: NextApiHandler; + }; +} + +type ApiRouteOperation = (openApiOperation?: OpenAPIV3_1.OperationObject) => { + input: ApiRouteInput; + output: ApiRouteOutput; + middleware: (middleware?: ApiRouteHandler) => { + handler: (callback?: ApiRouteHandler) => ApiRouteOperationDefinition; + }; + handler: (callback?: ApiRouteHandler) => ApiRouteOperationDefinition; +}; export const apiRouteOperation: ApiRouteOperation = (openApiOperation) => { const createConfig = ( diff --git a/packages/next-rest-framework/src/pages-router/docs-api-route-handler.ts b/packages/next-rest-framework/src/pages-router/docs-api-route-handler.ts index c2aedbd..e7ac90b 100644 --- a/packages/next-rest-framework/src/pages-router/docs-api-route-handler.ts +++ b/packages/next-rest-framework/src/pages-router/docs-api-route-handler.ts @@ -1,9 +1,9 @@ import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT } from '../constants'; import { type NextRestFrameworkConfig } from '../types'; -import { generatePathsFromDev, getConfig, syncOpenApiSpec } from '../utils'; +import { fetchOasDataFromDev, getConfig, syncOpenApiSpec } from '../shared'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; -import { getHtmlForDocs } from '../utils/docs'; -import { logInitInfo, logNextRestFrameworkError } from '../utils/logging'; +import { getHtmlForDocs } from '../shared/docs'; +import { logInitInfo, logNextRestFrameworkError } from '../shared/logging'; export const docsApiRouteHandler = (_config?: NextRestFrameworkConfig) => { const config = getConfig(_config); @@ -31,8 +31,13 @@ export const docsApiRouteHandler = (_config?: NextRestFrameworkConfig) => { } if (config.autoGenerateOpenApiSpec) { - const paths = await generatePathsFromDev({ config, baseUrl, url }); - await syncOpenApiSpec({ config, paths }); + const nrfOasData = await fetchOasDataFromDev({ + config, + baseUrl, + url + }); + + await syncOpenApiSpec({ config, nrfOasData }); } } diff --git a/packages/next-rest-framework/src/pages-router/index.ts b/packages/next-rest-framework/src/pages-router/index.ts index 0855da0..64b070e 100644 --- a/packages/next-rest-framework/src/pages-router/index.ts +++ b/packages/next-rest-framework/src/pages-router/index.ts @@ -1,3 +1,4 @@ export * from './api-route-handler'; export * from './api-route-operation'; export * from './docs-api-route-handler'; +export * from './rpc-api-route-handler'; diff --git a/packages/next-rest-framework/src/pages-router/rpc-api-route-handler.ts b/packages/next-rest-framework/src/pages-router/rpc-api-route-handler.ts new file mode 100644 index 0000000..36f31ca --- /dev/null +++ b/packages/next-rest-framework/src/pages-router/rpc-api-route-handler.ts @@ -0,0 +1,121 @@ +import { + DEFAULT_ERRORS, + NEXT_REST_FRAMEWORK_USER_AGENT, + ValidMethod +} from '../constants'; +import { + validateSchema, + logNextRestFrameworkError, + type OperationDefinition, + getOasDataFromRpcOperations +} from '../shared'; +import { type Client } from '../client/rpc-client'; +import { type NextApiRequest, type NextApiResponse } from 'next/types'; + +export const rpcApiRouteHandler = < + T extends Record> +>( + operations: T +) => { + const handler = async (req: NextApiRequest, res: NextApiResponse) => { + try { + const { method, body, headers, url: pathname } = req; + + if (method !== ValidMethod.POST) { + res.setHeader('Allow', 'POST'); + res.status(405).json({ message: DEFAULT_ERRORS.methodNotAllowed }); + return; + } + + if ( + process.env.NODE_ENV !== 'production' && + headers['user-agent'] === NEXT_REST_FRAMEWORK_USER_AGENT + ) { + const route = decodeURIComponent(pathname ?? ''); + + try { + const nrfOasData = getOasDataFromRpcOperations({ + operations, + route + }); + + res.status(200).json({ nrfOasData }); + return; + } catch (error) { + throw Error(`OpenAPI spec generation failed for route: ${route} +${error}`); + } + } + + const operation = + operations[ + (headers['x-rpc-operation'] as keyof typeof operations) ?? '' + ]; + + if (!operation) { + res.status(400).json({ message: DEFAULT_ERRORS.operationNotAllowed }); + return; + } + + const { input, handler, middleware } = operation._meta; + + if (middleware) { + const _res = await middleware(body); + + if (_res) { + res.status(200).json(_res); + return; + } + } + + if (input) { + if (headers['content-type']?.split(';')[0] !== 'application/json') { + res.status(415).json({ message: DEFAULT_ERRORS.invalidMediaType }); + } + + try { + const { valid, errors } = await validateSchema({ + schema: input, + obj: body + }); + + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); + + return; + } + } catch (error) { + res.status(400).json({ + message: DEFAULT_ERRORS.missingRequestBody + }); + + return; + } + } + + if (!handler) { + throw Error(DEFAULT_ERRORS.handlerNotFound); + } + + const _res = await handler(body); + res.status(200).json(_res); + } catch (error) { + logNextRestFrameworkError(error); + res.status(500).json({ message: DEFAULT_ERRORS.unexpectedError }); + } + }; + + handler.getPaths = (route: string) => + getOasDataFromRpcOperations({ + operations, + route + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + handler.client = {} as Client; + + return handler; +}; diff --git a/packages/next-rest-framework/src/utils/config.ts b/packages/next-rest-framework/src/shared/config.ts similarity index 100% rename from packages/next-rest-framework/src/utils/config.ts rename to packages/next-rest-framework/src/shared/config.ts diff --git a/packages/next-rest-framework/src/utils/docs.ts b/packages/next-rest-framework/src/shared/docs.ts similarity index 100% rename from packages/next-rest-framework/src/utils/docs.ts rename to packages/next-rest-framework/src/shared/docs.ts diff --git a/packages/next-rest-framework/src/utils/index.ts b/packages/next-rest-framework/src/shared/index.ts similarity index 80% rename from packages/next-rest-framework/src/utils/index.ts rename to packages/next-rest-framework/src/shared/index.ts index 3c7fc30..f1fba01 100644 --- a/packages/next-rest-framework/src/utils/index.ts +++ b/packages/next-rest-framework/src/shared/index.ts @@ -2,4 +2,5 @@ export * from './config'; export * from './docs'; export * from './logging'; export * from './open-api'; +export * from './rpc-operation'; export * from './schemas'; diff --git a/packages/next-rest-framework/src/utils/logging.ts b/packages/next-rest-framework/src/shared/logging.ts similarity index 100% rename from packages/next-rest-framework/src/utils/logging.ts rename to packages/next-rest-framework/src/shared/logging.ts diff --git a/packages/next-rest-framework/src/utils/open-api.ts b/packages/next-rest-framework/src/shared/open-api.ts similarity index 57% rename from packages/next-rest-framework/src/utils/open-api.ts rename to packages/next-rest-framework/src/shared/open-api.ts index 03cd34e..cfc6069 100644 --- a/packages/next-rest-framework/src/utils/open-api.ts +++ b/packages/next-rest-framework/src/shared/open-api.ts @@ -1,10 +1,6 @@ import { join } from 'path'; -import { - type ApiRouteParams, - type RouteParams, - type NextRestFrameworkConfig -} from '../types'; -import { type OpenAPIV3_1 } from 'openapi-types'; +import { type NextRestFrameworkConfig } from '../types'; +import { type OpenAPIV3, type OpenAPIV3_1 } from 'openapi-types'; import { DEFAULT_ERRORS, NEXT_REST_FRAMEWORK_USER_AGENT, @@ -16,6 +12,9 @@ import { getJsonSchema, getSchemaKeys } from './schemas'; import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'; import chalk from 'chalk'; import * as prettier from 'prettier'; +import { type ApiRouteParams } from '../pages-router'; +import { type RouteParams } from '../app-router'; +import { type OperationDefinition } from './rpc-operation'; // Traverse the base path and find all nested files. export const getNestedFiles = (basePath: string, dir: string): string[] => { @@ -75,19 +74,23 @@ export const logIgnoredPaths = (paths: string[]) => { ); }; -export const getSortedPaths = (paths: OpenAPIV3_1.PathsObject) => { - const sortedPathKeys = Object.keys(paths).sort(); - const sortedPaths: typeof paths = {}; - - for (const key of sortedPathKeys) { - sortedPaths[key] = paths[key]; - } +export const sortObjectByKeys = >( + obj: T +): T => { + const sortedEntries = Object.entries(obj).sort((a, b) => + a[0].localeCompare(b[0]) + ); - return sortedPaths; + return Object.fromEntries(sortedEntries) as T; }; -// Generate the OpenAPI paths from the Next.js routes and API routes when running the dev server. -export const generatePathsFromDev = async ({ +export interface NrfOasData { + paths?: OpenAPIV3_1.PathsObject; + schemas?: Record; +} + +// Fetch OAS information from the Next.js routes and API routes when running the dev server. +export const fetchOasDataFromDev = async ({ config, baseUrl, url @@ -95,7 +98,7 @@ export const generatePathsFromDev = async ({ config: Required; baseUrl: string; url: string; -}): Promise => { +}): Promise => { // Disable TLS certificate validation in development mode to enable local development using HTTPS. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; @@ -192,52 +195,66 @@ export const generatePathsFromDev = async ({ } let paths: OpenAPIV3_1.PathsObject = {}; + let schemas: Record = {}; // Call the API routes to get the OpenAPI paths. await Promise.all( [...routes, ...apiRoutes].map(async (route) => { const url = `${baseUrl}${route}`; - const controller = new AbortController(); - - const abortRequest = setTimeout(() => { - controller.abort(); - }, 5000); - - try { - const res = await fetch(url, { - headers: { - 'User-Agent': NEXT_REST_FRAMEWORK_USER_AGENT, - 'Content-Type': 'application/json', - 'x-forwarded-proto': baseUrl.split('://')[0], - host: baseUrl.split('://')[1] - }, - signal: controller.signal - }); - clearTimeout(abortRequest); + const fetchWithMethod = async (method: string) => { + const controller = new AbortController(); + + const abortRequest = setTimeout(() => { + controller.abort(); + }, 5000); + + try { + const res = await fetch(url, { + headers: { + 'User-Agent': NEXT_REST_FRAMEWORK_USER_AGENT, + 'Content-Type': 'application/json', + 'x-forwarded-proto': baseUrl.split('://')[0], + host: baseUrl.split('://')[1] + }, + signal: controller.signal, + method + }); - const data: { - nextRestFrameworkPaths: Record; - } = await res.json(); + clearTimeout(abortRequest); - const isPathItemObject = ( - obj: unknown - ): obj is OpenAPIV3_1.PathItemObject => { - return ( - !!obj && typeof obj === 'object' && 'nextRestFrameworkPaths' in obj - ); - }; + const data: { + nrfOasData?: Partial; + } = await res.json(); + + if (res.status === 200 && data.nrfOasData) { + paths = { ...paths, ...data.nrfOasData.paths }; + schemas = { ...schemas, ...data.nrfOasData.schemas }; + } - if (res.status === 200 && isPathItemObject(data)) { - paths = { ...paths, ...data.nextRestFrameworkPaths }; + return true; + } catch { + // A user defined API route returned an error. + } + + return false; + }; + + // The API routes can export any methods - test them all until a successful response is returned. + for (const method of Object.keys(ValidMethod)) { + const shouldBreak = await fetchWithMethod(method); + + if (shouldBreak) { + break; } - } catch { - // A user defined API route returned an error. } }) ); - return getSortedPaths(paths); + return { + paths: Object.keys(paths).length ? sortObjectByKeys(paths) : undefined, + schemas: Object.keys(schemas).length ? sortObjectByKeys(schemas) : undefined + }; }; export const generateOpenApiSpec = async ({ @@ -271,10 +288,10 @@ export const generateOpenApiSpec = async ({ export const syncOpenApiSpec = async ({ config, - paths + nrfOasData: { paths, schemas } }: { config: Required; - paths: OpenAPIV3_1.PathsObject; + nrfOasData: NrfOasData; }) => { const path = join(process.cwd(), 'public', config.openApiJsonPath); @@ -283,7 +300,8 @@ export const syncOpenApiSpec = async ({ openapi: OPEN_API_VERSION }, config.openApiObject, - { paths } + paths && { paths }, + schemas && { components: { schemas } } ); try { @@ -335,7 +353,7 @@ export const getPathsFromMethodHandlers = ({ }: { methodHandlers: RouteParams | ApiRouteParams; route: string; -}) => { +}): NrfOasData => { const { openApiPath } = methodHandlers; const paths: OpenAPIV3_1.PathsObject = {}; @@ -423,5 +441,161 @@ export const getPathsFromMethodHandlers = ({ }; }); - return paths; + return { paths }; +}; + +export const getOasDataFromRpcOperations = ({ + operations, + route +}: { + operations: Record; + route: string; +}): NrfOasData => { + const requestBodySchemas: Record< + string, + { key: string; ref: string; schema: OpenAPIV3_1.SchemaObject } + > = {}; + + const responseBodySchemas: Record< + string, + Array<{ key: string; ref: string; schema: OpenAPIV3_1.SchemaObject }> + > = {}; + + const capitalize = (str: string) => str[0].toUpperCase() + str.slice(1); + + Object.entries(operations).forEach( + ([ + operation, + { + _meta: { input, output } + } + ]) => { + if (input) { + const key = `${capitalize(operation)}Body`; + + requestBodySchemas[operation] = { + key, + ref: `#/components/schemas/${key}`, + schema: getJsonSchema({ schema: input }) + }; + } + + if (output) { + responseBodySchemas[operation] = output.reduce< + Array<{ key: string; ref: string; schema: OpenAPIV3_1.SchemaObject }> + >((acc, curr, i) => { + const key = `${capitalize(operation)}Response${i + 1}`; + + return [ + ...acc, + { + key, + ref: `#/components/schemas/${key}`, + schema: getJsonSchema({ schema: curr }) + } + ]; + }, []); + } + } + ); + + const requestBodySchemaRefMapping = Object.entries(requestBodySchemas).reduce< + Record + >((acc, [key, val]) => { + acc[key] = val.ref; + return acc; + }, {}); + + const responseBodySchemaRefMapping = Object.entries( + requestBodySchemas + ).reduce>((acc, [key, val]) => { + acc[key] = val.ref; + return acc; + }, {}); + + const paths: OpenAPIV3_1.PathsObject = { + [route]: { + post: { + description: 'RPC endpoint', + tags: ['RPC'], + operationId: 'rpcCall', + parameters: [ + { + name: 'X-RPC-Operation', + in: 'header', + schema: { + type: 'string' + }, + required: true, + description: 'The RPC operation to call.' + } + ], + requestBody: { + content: { + 'application/json': { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + schema: { + discriminator: { + propertyName: 'X-RPC-Operation', + mapping: requestBodySchemaRefMapping + }, + oneOf: Object.values(requestBodySchemas).map(({ ref }) => ref) + } as OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject + } + } + }, + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + schema: { + discriminator: { + propertyName: 'X-RPC-Operation', + mapping: responseBodySchemaRefMapping + }, + oneOf: Object.values(responseBodySchemas).flatMap((val) => [ + ...val.map(({ ref }) => ref) + ]) + } as OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject + } + } + }, + default: defaultResponse as ( + | OpenAPIV3_1.ReferenceObject + | OpenAPIV3_1.ResponseObject + ) & + (OpenAPIV3.ReferenceObject | OpenAPIV3.ResponseObject) + } + } + } + }; + + const requestBodySchemaMapping = Object.values(requestBodySchemas).reduce< + Record + >((acc, { key, schema }) => { + acc[key] = schema; + return acc; + }, {}); + + const responseBodySchemaMapping = Object.values(responseBodySchemas) + .flatMap((val) => val) + .reduce>( + (acc, { key, schema }) => { + acc[key] = schema; + return acc; + }, + {} + ); + + const schemas: Record = { + ...requestBodySchemaMapping, + ...responseBodySchemaMapping + }; + + return { + paths, + schemas + }; }; diff --git a/packages/next-rest-framework/src/shared/rpc-operation.ts b/packages/next-rest-framework/src/shared/rpc-operation.ts new file mode 100644 index 0000000..dbd52e7 --- /dev/null +++ b/packages/next-rest-framework/src/shared/rpc-operation.ts @@ -0,0 +1,107 @@ +import { type z, type ZodSchema } from 'zod'; +import { type OpenAPIV3_1 } from 'openapi-types'; + +type OperationHandler< + Input = unknown, + Output extends readonly ZodSchema[] = readonly ZodSchema[] +> = ( + params: z.infer> +) => Promise> | z.infer; + +interface OperationDefinitionMeta { + openApiOperation?: OpenAPIV3_1.OperationObject; + input?: ZodSchema; + output?: Output; + middleware?: OperationHandler; + handler?: OperationHandler; +} + +export type OperationDefinition< + Input = unknown, + Output extends readonly ZodSchema[] = readonly ZodSchema[], + HasInput extends boolean = true +> = (HasInput extends true + ? (body: z.infer>) => Promise> + : () => Promise>) & { + _meta: OperationDefinitionMeta; +}; + +export const rpcOperation = ( + openApiOperation?: OpenAPIV3_1.OperationObject +) => { + function createOperation( + input: ZodSchema, + output: Output | undefined, + middleware: OperationHandler | undefined, + handler: OperationHandler | undefined + ): OperationDefinition; + + function createOperation( + input: undefined, + output: Output | undefined, + middleware: OperationHandler | undefined, + handler: OperationHandler | undefined + ): OperationDefinition; + + function createOperation( + input: ZodSchema | undefined, + output: Output | undefined, + middleware: OperationHandler | undefined, + handler: OperationHandler | undefined + ): OperationDefinition { + const meta = { + openApiOperation, + input, + output, + middleware, + handler + }; + + if (input === undefined) { + const operation = async () => {}; + operation._meta = meta; + return operation as OperationDefinition; + } else { + const operation = async (_body: z.infer>) => {}; + operation._meta = meta; + return operation as OperationDefinition; + } + } + + return { + input: (input: ZodSchema) => ({ + output: (output: Output) => ({ + middleware: (middleware: OperationHandler) => ({ + handler: (handler: OperationHandler) => + createOperation(input, output, middleware, handler) + }), + handler: (handler: OperationHandler) => + createOperation(input, output, undefined, handler) + }), + middleware: (middleware: OperationHandler) => ({ + output: (output: Output) => ({ + handler: (handler: OperationHandler) => + createOperation(input, output, middleware, handler) + }), + handler: (handler: OperationHandler) => + createOperation(input, undefined, middleware, handler) + }), + handler: (handler: OperationHandler) => + createOperation(input, undefined, undefined, handler) + }), + output: (output: Output) => ({ + middleware: (middleware: OperationHandler) => ({ + handler: (handler: OperationHandler) => + createOperation(undefined, output, middleware, handler) + }), + handler: (handler: OperationHandler) => + createOperation(undefined, output, undefined, handler) + }), + middleware: (middleware: OperationHandler) => ({ + handler: (handler: OperationHandler) => + createOperation(undefined, undefined, middleware, handler) + }), + handler: (handler: OperationHandler) => + createOperation(undefined, undefined, undefined, handler) + }; +}; diff --git a/packages/next-rest-framework/src/utils/schemas.ts b/packages/next-rest-framework/src/shared/schemas.ts similarity index 100% rename from packages/next-rest-framework/src/utils/schemas.ts rename to packages/next-rest-framework/src/shared/schemas.ts diff --git a/packages/next-rest-framework/src/types.ts b/packages/next-rest-framework/src/types.ts new file mode 100644 index 0000000..c3d1332 --- /dev/null +++ b/packages/next-rest-framework/src/types.ts @@ -0,0 +1,140 @@ +import { type OpenAPIV3_1 } from 'openapi-types'; +import { type ZodSchema } from 'zod'; + +export type DocsProvider = 'redoc' | 'swagger-ui'; + +export interface NextRestFrameworkConfig { + /*! + * Array of paths that are denied by Next REST Framework and not included in the OpenAPI spec. + * Supports wildcards using asterisk `*` and double asterisk `**` for recursive matching. + * Example: `['/api/disallowed-path', '/api/disallowed-path-2/*', '/api/disallowed-path-3/**']` + * Defaults to no paths being disallowed. + */ + deniedPaths?: string[]; + /*! + * Array of paths that are allowed by Next REST Framework and included in the OpenAPI spec. + * Supports wildcards using asterisk `*` and double asterisk `**` for recursive matching. + * Example: `['/api/allowed-path', '/api/allowed-path-2/*', '/api/allowed-path-3/**']` + * Defaults to all paths being allowed. + */ + allowedPaths?: string[]; + /*! An OpenAPI Object that can be used to override and extend the auto-generated specification: https://swagger.io/specification/#openapi-object */ + openApiObject?: Modify< + Omit, + { + info: Partial; + } + >; + /*! Path that will be used for fetching the OpenAPI spec - defaults to `/openapi.json`. This path also determines the path where this file will be generated inside the `public` folder. */ + openApiJsonPath?: string; + /*! Setting this to `false` will not automatically update the generated OpenAPI spec when calling the Next REST Framework endpoint. Defaults to `true`. */ + autoGenerateOpenApiSpec?: boolean; + /*! Customization options for the generated docs. */ + docsConfig?: { + /*! Determines whether to render the docs using Redoc (`redoc`) or SwaggerUI `swagger-ui`. Defaults to `redoc`. */ + provider?: DocsProvider; + /*! Custom title, used for the visible title and HTML title. */ + title?: string; + /*! Custom description, used for the visible description and HTML meta description. */ + description?: string; + /*! Custom HTML meta favicon URL. */ + faviconUrl?: string; + /*! A URL for a custom logo. */ + logoUrl?: string; + }; + /*! Setting this to `true` will suppress all informational logs from Next REST Framework. Defaults to `false`. */ + suppressInfo?: boolean; +} + +export type BaseStatus = number; +export type BaseContentType = AnyContentTypeWithAutocompleteForMostCommonOnes; +export type BaseQuery = Record; + +export interface InputObject { + contentType?: BaseContentType; + body?: ZodSchema; + query?: ZodSchema; +} + +export interface OutputObject< + Body = unknown, + Status extends BaseStatus = BaseStatus, + ContentType extends BaseContentType = BaseContentType +> { + schema: ZodSchema; + status: Status; + contentType: ContentType; +} + +export type Modify = Omit & R; + +export type AnyCase = T | Uppercase | Lowercase; + +// Ref: https://twitter.com/diegohaz/status/1524257274012876801 +export type StringWithAutocomplete = T | (string & Record); + +// Content types ref: https://stackoverflow.com/a/48704300 +export type AnyContentTypeWithAutocompleteForMostCommonOnes = + StringWithAutocomplete< + | 'application/java-archive' + | 'application/EDI-X12' + | 'application/EDIFACT' + | 'application/javascript' + | 'application/octet-stream' + | 'application/ogg' + | 'application/pdf' + | 'application/xhtml+xml' + | 'application/x-shockwave-flash' + | 'application/json' + | 'application/ld+json' + | 'application/xml' + | 'application/zip' + | 'application/x-www-form-urlencoded' + /********************/ + | 'audio/mpeg' + | 'audio/x-ms-wma' + | 'audio/vnd.rn-realaudio' + | 'audio/x-wav' + /********************/ + | 'image/gif' + | 'image/jpeg' + | 'image/png' + | 'image/tiff' + | 'image/vnd.microsoft.icon' + | 'image/x-icon' + | 'image/vnd.djvu' + | 'image/svg+xml' + /********************/ + | 'multipart/mixed' + | 'multipart/alternative' + | 'multipart/related' + | 'multipart/form-data' + /********************/ + | 'text/css' + | 'text/csv' + | 'text/html' + | 'text/javascript' + | 'text/plain' + | 'text/xml' + /********************/ + | 'video/mpeg' + | 'video/mp4' + | 'video/quicktime' + | 'video/x-ms-wmv' + | 'video/x-msvideo' + | 'video/x-flv' + | 'video/webm' + /********************/ + | 'application/vnd.android.package-archive' + | 'application/vnd.oasis.opendocument.text' + | 'application/vnd.oasis.opendocument.spreadsheet' + | 'application/vnd.oasis.opendocument.presentation' + | 'application/vnd.oasis.opendocument.graphics' + | 'application/vnd.ms-excel' + | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + | 'application/vnd.ms-powerpoint' + | 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + | 'application/msword' + | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + | 'application/vnd.mozilla.xul+xml' + >; diff --git a/packages/next-rest-framework/src/types/config.ts b/packages/next-rest-framework/src/types/config.ts deleted file mode 100644 index 371b612..0000000 --- a/packages/next-rest-framework/src/types/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type OpenAPIV3_1 } from 'openapi-types'; -import { type Modify } from './utility-types'; - -export type DocsProvider = 'redoc' | 'swagger-ui'; - -export interface NextRestFrameworkConfig { - /*! - * Array of paths that are denied by Next REST Framework and not included in the OpenAPI spec. - * Supports wildcards using asterisk `*` and double asterisk `**` for recursive matching. - * Example: `['/api/disallowed-path', '/api/disallowed-path-2/*', '/api/disallowed-path-3/**']` - * Defaults to no paths being disallowed. - */ - deniedPaths?: string[]; - /*! - * Array of paths that are allowed by Next REST Framework and included in the OpenAPI spec. - * Supports wildcards using asterisk `*` and double asterisk `**` for recursive matching. - * Example: `['/api/allowed-path', '/api/allowed-path-2/*', '/api/allowed-path-3/**']` - * Defaults to all paths being allowed. - */ - allowedPaths?: string[]; - /*! An OpenAPI Object that can be used to override and extend the auto-generated specification: https://swagger.io/specification/#openapi-object */ - openApiObject?: Modify< - Omit, - { - info: Partial; - } - >; - /*! Path that will be used for fetching the OpenAPI spec - defaults to `/openapi.json`. This path also determines the path where this file will be generated inside the `public` folder. */ - openApiJsonPath?: string; - /*! Setting this to `false` will not automatically update the generated OpenAPI spec when calling the Next REST Framework endpoint. Defaults to `true`. */ - autoGenerateOpenApiSpec?: boolean; - /*! Customization options for the generated docs. */ - docsConfig?: { - /*! Determines whether to render the docs using Redoc (`redoc`) or SwaggerUI `swagger-ui`. Defaults to `redoc`. */ - provider?: DocsProvider; - /*! Custom title, used for the visible title and HTML title. */ - title?: string; - /*! Custom description, used for the visible description and HTML meta description. */ - description?: string; - /*! Custom HTML meta favicon URL. */ - faviconUrl?: string; - /*! A URL for a custom logo. */ - logoUrl?: string; - }; - /*! Setting this to `true` will suppress all informational logs from Next REST Framework. Defaults to `false`. */ - suppressInfo?: boolean; -} diff --git a/packages/next-rest-framework/src/types/content-types.ts b/packages/next-rest-framework/src/types/content-types.ts deleted file mode 100644 index c59c75c..0000000 --- a/packages/next-rest-framework/src/types/content-types.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Ref: https://twitter.com/diegohaz/status/1524257274012876801 -export type StringWithAutocomplete = T | (string & Record); - -// Content types ref: https://stackoverflow.com/a/48704300 -export type AnyContentTypeWithAutocompleteForMostCommonOnes = - StringWithAutocomplete< - | 'application/java-archive' - | 'application/EDI-X12' - | 'application/EDIFACT' - | 'application/javascript' - | 'application/octet-stream' - | 'application/ogg' - | 'application/pdf' - | 'application/xhtml+xml' - | 'application/x-shockwave-flash' - | 'application/json' - | 'application/ld+json' - | 'application/xml' - | 'application/zip' - | 'application/x-www-form-urlencoded' - /********************/ - | 'audio/mpeg' - | 'audio/x-ms-wma' - | 'audio/vnd.rn-realaudio' - | 'audio/x-wav' - /********************/ - | 'image/gif' - | 'image/jpeg' - | 'image/png' - | 'image/tiff' - | 'image/vnd.microsoft.icon' - | 'image/x-icon' - | 'image/vnd.djvu' - | 'image/svg+xml' - /********************/ - | 'multipart/mixed' - | 'multipart/alternative' - | 'multipart/related' - | 'multipart/form-data' - /********************/ - | 'text/css' - | 'text/csv' - | 'text/html' - | 'text/javascript' - | 'text/plain' - | 'text/xml' - /********************/ - | 'video/mpeg' - | 'video/mp4' - | 'video/quicktime' - | 'video/x-ms-wmv' - | 'video/x-msvideo' - | 'video/x-flv' - | 'video/webm' - /********************/ - | 'application/vnd.android.package-archive' - | 'application/vnd.oasis.opendocument.text' - | 'application/vnd.oasis.opendocument.spreadsheet' - | 'application/vnd.oasis.opendocument.presentation' - | 'application/vnd.oasis.opendocument.graphics' - | 'application/vnd.ms-excel' - | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - | 'application/vnd.ms-powerpoint' - | 'application/vnd.openxmlformats-officedocument.presentationml.presentation' - | 'application/msword' - | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - | 'application/vnd.mozilla.xul+xml' - >; diff --git a/packages/next-rest-framework/src/types/index.ts b/packages/next-rest-framework/src/types/index.ts deleted file mode 100644 index 68667f0..0000000 --- a/packages/next-rest-framework/src/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './config'; -export * from './content-types'; -export * from './route-handlers'; -export * from './typed-next-response'; -export * from './utility-types'; diff --git a/packages/next-rest-framework/src/types/route-handlers.ts b/packages/next-rest-framework/src/types/route-handlers.ts deleted file mode 100644 index 5b68ea7..0000000 --- a/packages/next-rest-framework/src/types/route-handlers.ts +++ /dev/null @@ -1,346 +0,0 @@ -/* eslint-disable @typescript-eslint/no-invalid-void-type */ - -import { type NextRequest, type NextResponse } from 'next/server'; -import { - type NextApiResponse, - type NextApiRequest, - type NextApiHandler -} from 'next/types'; - -import { type ValidMethod } from '../constants'; -import { type AnyCase, type Modify } from './utility-types'; -import { type NextURL } from 'next/dist/server/web/next-url'; - -import { type OpenAPIV3_1 } from 'openapi-types'; -import { type AnyContentTypeWithAutocompleteForMostCommonOnes } from './content-types'; -import { type ZodSchema, type z } from 'zod'; -import { type TypedNextResponse } from './typed-next-response'; - -export type BaseStatus = number; -export type BaseContentType = AnyContentTypeWithAutocompleteForMostCommonOnes; -export type BaseQuery = Record; - -export interface InputObject { - contentType?: BaseContentType; - body?: ZodSchema; - query?: ZodSchema; -} - -export interface OutputObject< - Body = unknown, - Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType -> { - schema: ZodSchema; - status: Status; - contentType: ContentType; -} - -type TypedNextRequest = Modify< - NextRequest, - { - json: () => Promise; - method: ValidMethod; - nextUrl: Modify< - NextURL, - { - searchParams: Modify< - URLSearchParams, - { - get: (key: keyof Query) => string | null; - getAll: (key: keyof Query) => string[]; - } - >; - } - >; - } ->; - -type RouteHandler< - Body = unknown, - Query extends BaseQuery = BaseQuery, - ResponseBody = unknown, - Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType, - Output extends ReadonlyArray< - OutputObject - > = ReadonlyArray>, - TypedResponse = - | TypedNextResponse< - z.infer, - Output[number]['status'], - Output[number]['contentType'] - > - | NextResponse> - | void -> = ( - req: TypedNextRequest, - context: { params: Record } -) => Promise | TypedResponse; - -type RouteOutput< - Middleware extends boolean = false, - Body = unknown, - Query extends BaseQuery = BaseQuery -> = < - ResponseBody, - Status extends BaseStatus, - ContentType extends BaseContentType, - Output extends ReadonlyArray> ->( - params?: Output -) => { - handler: ( - callback?: RouteHandler< - Body, - Query, - ResponseBody, - Status, - ContentType, - Output - > - ) => RouteOperationDefinition; -} & (Middleware extends true - ? { - middleware: ( - callback?: RouteHandler< - unknown, - BaseQuery, - ResponseBody, - Status, - ContentType, - Output - > - ) => { - handler: ( - callback?: RouteHandler< - Body, - Query, - ResponseBody, - Status, - ContentType, - Output - > - ) => RouteOperationDefinition; - }; - } - : Record); - -type RouteInput = < - Body, - Query extends BaseQuery ->( - params?: InputObject -) => { - output: RouteOutput; - handler: (callback?: RouteHandler) => RouteOperationDefinition; -} & (Middleware extends true - ? { - middleware: (callback?: RouteHandler) => { - output: RouteOutput; - handler: ( - callback?: RouteHandler - ) => RouteOperationDefinition; - }; - } - : Record); - -export type RouteOperation = ( - openApiOperation?: OpenAPIV3_1.OperationObject -) => { - input: RouteInput; - output: RouteOutput; - middleware: (middleware?: RouteHandler) => { - handler: (callback?: RouteHandler) => RouteOperationDefinition; - }; - handler: (callback?: RouteHandler) => RouteOperationDefinition; -}; - -export type NextRouteHandler = ( - req: NextRequest, - context: { params: BaseQuery } -) => Promise | NextResponse | Promise | void; - -export interface RouteOperationDefinition { - _config: { - openApiOperation?: OpenAPIV3_1.OperationObject; - input?: InputObject; - output?: readonly OutputObject[]; - middleware?: NextRouteHandler; - handler?: NextRouteHandler; - }; -} - -export interface RouteParams { - openApiPath?: OpenAPIV3_1.PathItemObject; - [ValidMethod.GET]?: RouteOperationDefinition; - [ValidMethod.PUT]?: RouteOperationDefinition; - [ValidMethod.POST]?: RouteOperationDefinition; - [ValidMethod.DELETE]?: RouteOperationDefinition; - [ValidMethod.OPTIONS]?: RouteOperationDefinition; - [ValidMethod.HEAD]?: RouteOperationDefinition; - [ValidMethod.PATCH]?: RouteOperationDefinition; -} - -type TypedNextApiRequest = Modify< - NextApiRequest, - { - body: Body; - query: Query; - method: ValidMethod; - } ->; - -type TypedNextApiResponse = Modify< - NextApiResponse, - { - status: (status: Status) => TypedNextApiResponse; - redirect: ( - status: Status, - url: string - ) => TypedNextApiResponse; - - setDraftMode: (options: { - enable: boolean; - }) => TypedNextApiResponse; - - setPreviewData: ( - data: object | string, - options?: { - maxAge?: number; - path?: string; - } - ) => TypedNextApiResponse; - - clearPreviewData: (options?: { - path?: string; - }) => TypedNextApiResponse; - - setHeader: < - K extends AnyCase<'Content-Type'> | string, - V extends number | string | readonly string[] - >( - name: K, - value: K extends AnyCase<'Content-Type'> ? ContentType : V - ) => void; - } ->; - -type ApiRouteHandler< - Body = unknown, - Query extends BaseQuery = BaseQuery, - ResponseBody = unknown, - Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType, - Output extends ReadonlyArray< - OutputObject - > = ReadonlyArray> -> = ( - req: TypedNextApiRequest, - res: TypedNextApiResponse< - z.infer, - Output[number]['status'], - Output[number]['contentType'] - > -) => Promise | void; - -type ApiRouteOutput< - Middleware extends boolean = false, - Body = unknown, - Query extends BaseQuery = BaseQuery -> = < - ResponseBody, - Status extends BaseStatus, - ContentType extends BaseContentType, - Output extends ReadonlyArray> ->( - params?: Output -) => { - handler: ( - callback?: ApiRouteHandler< - Body, - Query, - ResponseBody, - Status, - ContentType, - Output - > - ) => ApiRouteOperationDefinition; -} & (Middleware extends true - ? { - middleware: ( - callback?: ApiRouteHandler< - unknown, - BaseQuery, - ResponseBody, - Status, - ContentType, - Output - > - ) => { - handler: ( - callback?: ApiRouteHandler< - Body, - Query, - ResponseBody, - Status, - ContentType, - Output - > - ) => ApiRouteOperationDefinition; - }; - } - : Record); - -type ApiRouteInput = < - Body, - Query extends BaseQuery ->( - params?: InputObject -) => { - output: ApiRouteOutput; - handler: ( - callback?: ApiRouteHandler - ) => ApiRouteOperationDefinition; -} & (Middleware extends true - ? { - middleware: (callback?: ApiRouteHandler) => { - output: ApiRouteOutput; - handler: ( - callback?: ApiRouteHandler - ) => ApiRouteOperationDefinition; - }; - } - : Record); - -export type ApiRouteOperation = ( - openApiOperation?: OpenAPIV3_1.OperationObject -) => { - input: ApiRouteInput; - output: ApiRouteOutput; - middleware: (middleware?: ApiRouteHandler) => { - handler: (callback?: ApiRouteHandler) => ApiRouteOperationDefinition; - }; - handler: (callback?: ApiRouteHandler) => ApiRouteOperationDefinition; -}; - -export interface ApiRouteOperationDefinition { - _config: { - openApiOperation?: OpenAPIV3_1.OperationObject; - input?: InputObject; - output?: readonly OutputObject[]; - middleware?: NextApiHandler; - handler?: NextApiHandler; - }; -} - -export interface ApiRouteParams { - openApiPath?: OpenAPIV3_1.PathItemObject; - [ValidMethod.GET]?: ApiRouteOperationDefinition; - [ValidMethod.PUT]?: ApiRouteOperationDefinition; - [ValidMethod.POST]?: ApiRouteOperationDefinition; - [ValidMethod.DELETE]?: ApiRouteOperationDefinition; - [ValidMethod.OPTIONS]?: ApiRouteOperationDefinition; - [ValidMethod.HEAD]?: ApiRouteOperationDefinition; - [ValidMethod.PATCH]?: ApiRouteOperationDefinition; -} diff --git a/packages/next-rest-framework/src/types/typed-next-response.ts b/packages/next-rest-framework/src/types/typed-next-response.ts deleted file mode 100644 index f801101..0000000 --- a/packages/next-rest-framework/src/types/typed-next-response.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { type I18NConfig } from 'next/dist/server/config-shared'; -import { type ResponseCookies } from 'next/dist/server/web/spec-extension/cookies'; -import { type BaseContentType, type BaseStatus } from './route-handlers'; -import { type NextURL } from 'next/dist/server/web/next-url'; -import { type Modify, type AnyCase } from './utility-types'; - -type TypedHeaders = Modify< - Record, - { - [K in AnyCase<'Content-Type'>]?: ContentType; - } ->; - -interface TypedResponseInit< - Status extends BaseStatus, - ContentType extends BaseContentType -> extends globalThis.ResponseInit { - nextConfig?: { - basePath?: string; - i18n?: I18NConfig; - trailingSlash?: boolean; - }; - url?: string; - status?: Status; - headers?: TypedHeaders; -} - -interface ModifiedRequest { - headers?: Headers; -} - -interface TypedMiddlewareResponseInit - extends globalThis.ResponseInit { - request?: ModifiedRequest; - status?: Status; -} - -declare const INTERNALS: unique symbol; - -// A patched `NextResponse` that allows to strongly-typed status code and content-type. -export declare class TypedNextResponse< - Body, - Status extends BaseStatus, - ContentType extends BaseContentType -> extends Response { - [INTERNALS]: { - cookies: ResponseCookies; - url?: NextURL; - body?: Body; - status?: Status; - contentType?: ContentType; - }; - - constructor( - body?: BodyInit | null, - init?: TypedResponseInit - ); - - get cookies(): ResponseCookies; - - static json< - Body, - Status extends BaseStatus, - ContentType extends BaseContentType - >( - body: Body, - init?: TypedResponseInit - ): TypedNextResponse; - - static redirect< - Status extends BaseStatus, - ContentType extends BaseContentType - >( - url: string | NextURL | URL, - init?: number | TypedResponseInit - ): TypedNextResponse; - - static rewrite< - Status extends BaseStatus, - ContentType extends BaseContentType - >( - destination: string | NextURL | URL, - init?: TypedMiddlewareResponseInit - ): TypedNextResponse; - - static next( - init?: TypedMiddlewareResponseInit - ): TypedNextResponse; -} diff --git a/packages/next-rest-framework/src/types/utility-types.ts b/packages/next-rest-framework/src/types/utility-types.ts deleted file mode 100644 index e361b1e..0000000 --- a/packages/next-rest-framework/src/types/utility-types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type Modify = Omit & R; - -export type AnyCase = T | Uppercase | Lowercase; diff --git a/packages/next-rest-framework/tests/app-router/index.test.ts b/packages/next-rest-framework/tests/app-router/index.test.ts index cb184cf..75ebc31 100644 --- a/packages/next-rest-framework/tests/app-router/index.test.ts +++ b/packages/next-rest-framework/tests/app-router/index.test.ts @@ -3,7 +3,7 @@ import { getConfig, validateSchema, getHtmlForDocs -} from '../../src/utils'; +} from '../../src/shared'; import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import chalk from 'chalk'; import { createMockRouteRequest, resetCustomGlobals } from '../utils'; @@ -285,7 +285,7 @@ it('returns error for invalid request body', async () => { const { errors } = await validateSchema({ schema, obj: body }); expect(json).toEqual({ - message: 'Invalid request body.', + message: DEFAULT_ERRORS.invalidRequestBody, errors }); }); @@ -322,7 +322,7 @@ it('returns error for invalid query parameters', async () => { const { errors } = await validateSchema({ schema, obj: query }); expect(json).toEqual({ - message: 'Invalid query parameters.', + message: DEFAULT_ERRORS.invalidQueryParameters, errors }); }); @@ -458,34 +458,28 @@ it('executes middleware before validating input', async () => { .handler(() => {}) })(req, context); + expect(console.log).toHaveBeenCalledWith('foo'); + const json = await res?.json(); expect(res?.status).toEqual(400); const { errors } = await validateSchema({ schema, obj: body }); expect(json).toEqual({ - message: 'Invalid request body.', + message: DEFAULT_ERRORS.invalidRequestBody, errors }); - - expect(console.log).toHaveBeenCalledWith('foo'); }); it('does not execute handler if middleware returns a response', async () => { const { req, context } = createMockRouteRequest({ - method: ValidMethod.POST, - body: { - foo: 'bar' - }, - headers: { - 'content-type': 'application/json' - } + method: ValidMethod.GET }); console.log = jest.fn(); const res = await routeHandler({ - POST: routeOperation() + GET: routeOperation() .middleware(() => { return NextResponse.json({ foo: 'bar' }, { status: 200 }); }) diff --git a/packages/next-rest-framework/tests/app-router/paths.test.ts b/packages/next-rest-framework/tests/app-router/paths.test.ts index 5da80b5..b3e15da 100644 --- a/packages/next-rest-framework/tests/app-router/paths.test.ts +++ b/packages/next-rest-framework/tests/app-router/paths.test.ts @@ -11,7 +11,7 @@ import { import { z } from 'zod'; import { NextResponse } from 'next/server'; import chalk from 'chalk'; -import * as openApiUtils from '../../src/utils/open-api'; +import * as openApiUtils from '../../src/shared/open-api'; import { docsRouteHandler, routeHandler, routeOperation } from '../../src'; const createDirent = (name: string) => ({ @@ -433,10 +433,10 @@ it.each([ it('handles error if the OpenAPI spec generation fails', async () => { console.error = jest.fn(); - jest.mock('../../src/utils/open-api', () => { + jest.mock('../../src/shared/open-api', () => { return { __esModule: true, - ...jest.requireActual('../src/utils/open-api') + ...jest.requireActual('../../src/shared/open-api') }; }); diff --git a/packages/next-rest-framework/tests/app-router/rpc.test.ts b/packages/next-rest-framework/tests/app-router/rpc.test.ts new file mode 100644 index 0000000..0be5bc4 --- /dev/null +++ b/packages/next-rest-framework/tests/app-router/rpc.test.ts @@ -0,0 +1,158 @@ +import { validateSchema } from '../../src/shared'; +import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; +import { createMockRpcRouteRequest } from '../utils'; +import { z } from 'zod'; +import { rpcOperation, rpcRouteHandler } from '../../src'; + +it.each(Object.values(ValidMethod))( + 'only works with HTTP method POST: %p', + async (method) => { + const { req } = createMockRpcRouteRequest({ + method + }); + + const data = ['All good!']; + + const operation = rpcOperation() + .output([z.array(z.string())]) + .handler(() => data); + + const res = await rpcRouteHandler({ + test: operation + })(req); + + if (method === ValidMethod.POST) { + const json = await res?.json(); + expect(res?.status).toEqual(200); + expect(json).toEqual(data); + } else { + expect(res?.status).toEqual(405); + expect(res?.headers.get('Allow')).toEqual('POST'); + } + } +); + +it('returns error for missing operation', async () => { + const { req } = createMockRpcRouteRequest({ operation: 'does-not-exist' }); + + const res = await rpcRouteHandler({ + // @ts-expect-error: Intentionally invalid. + test: rpcOperation().handler() + })(req); + + const json = await res?.json(); + expect(res?.status).toEqual(400); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.operationNotAllowed + }); +}); + +it('returns error for invalid request body', async () => { + const body = { + foo: 'bar' + }; + + const { req } = createMockRpcRouteRequest({ + body + }); + + const schema = z.object({ + foo: z.number() + }); + + const res = await rpcRouteHandler({ + test: rpcOperation() + .input(schema) + .handler(() => {}) + })(req); + + const json = await res?.json(); + expect(res?.status).toEqual(400); + + const { errors } = await validateSchema({ schema, obj: body }); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); +}); + +it('returns a default error response', async () => { + const { req } = createMockRpcRouteRequest(); + + console.error = jest.fn(); + + const res = await rpcRouteHandler({ + test: rpcOperation().handler(() => { + throw Error('Something went wrong'); + }) + })(req); + + const json = await res?.json(); + expect(res?.status).toEqual(500); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.unexpectedError + }); +}); + +it('executes middleware before validating input', async () => { + const body = { + foo: 'bar' + }; + + const { req } = createMockRpcRouteRequest({ body }); + + const schema = z.object({ + foo: z.number() + }); + + console.log = jest.fn(); + + const res = await rpcRouteHandler({ + test: rpcOperation() + .input(schema) + .middleware(() => { + console.log('foo'); + }) + .handler(() => {}) + })(req); + + expect(console.log).toHaveBeenCalledWith('foo'); + + const json = await res?.json(); + expect(res?.status).toEqual(400); + + const { errors } = await validateSchema({ schema, obj: body }); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); +}); + +it('does not execute handler if middleware returns a response', async () => { + const { req } = createMockRpcRouteRequest(); + + console.log = jest.fn(); + + const res = await rpcRouteHandler({ + test: rpcOperation() + .middleware(() => { + return { foo: 'bar' }; + }) + .handler(() => { + console.log('foo'); + }) + })(req); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + + expect(json).toEqual({ + foo: 'bar' + }); + + expect(console.log).not.toHaveBeenCalled(); +}); diff --git a/packages/next-rest-framework/tests/pages-router/index.test.ts b/packages/next-rest-framework/tests/pages-router/index.test.ts index aff63c5..3432616 100644 --- a/packages/next-rest-framework/tests/pages-router/index.test.ts +++ b/packages/next-rest-framework/tests/pages-router/index.test.ts @@ -3,7 +3,7 @@ import { getConfig, validateSchema, getHtmlForDocs -} from '../../src/utils'; +} from '../../src/shared'; import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import chalk from 'chalk'; import { createMockApiRouteRequest, resetCustomGlobals } from '../utils'; @@ -280,7 +280,7 @@ it('returns error for invalid request body', async () => { const { errors } = await validateSchema({ schema, obj: body }); expect(res._getJSONData()).toEqual({ - message: 'Invalid request body.', + message: DEFAULT_ERRORS.invalidRequestBody, errors }); }); @@ -316,7 +316,7 @@ it('returns error for invalid query parameters', async () => { const { errors } = await validateSchema({ schema, obj: query }); expect(res._getJSONData()).toEqual({ - message: 'Invalid query parameters.', + message: DEFAULT_ERRORS.invalidQueryParameters, errors }); }); @@ -450,32 +450,27 @@ it('executes middleware before validating input', async () => { .handler(() => {}) })(req, res); + expect(console.log).toHaveBeenCalledWith('foo'); + const { errors } = await validateSchema({ schema, obj: body }); expect(res._getJSONData()).toEqual({ - message: 'Invalid request body.', + message: DEFAULT_ERRORS.invalidRequestBody, errors }); expect(res.statusCode).toEqual(400); - expect(console.log).toHaveBeenCalledWith('foo'); }); it('does not execute handler if middleware returns a response', async () => { const { req, res } = createMockApiRouteRequest({ - method: ValidMethod.POST, - body: { - foo: 'bar' - }, - headers: { - 'content-type': 'application/json' - } + method: ValidMethod.GET }); console.log = jest.fn(); await apiRouteHandler({ - POST: apiRouteOperation() + GET: apiRouteOperation() .middleware((_req, res) => { res.status(200).json({ foo: 'bar' }); }) diff --git a/packages/next-rest-framework/tests/pages-router/paths.test.ts b/packages/next-rest-framework/tests/pages-router/paths.test.ts index 51b178e..9215c37 100644 --- a/packages/next-rest-framework/tests/pages-router/paths.test.ts +++ b/packages/next-rest-framework/tests/pages-router/paths.test.ts @@ -10,7 +10,7 @@ import { } from '../../src/constants'; import { z } from 'zod'; import chalk from 'chalk'; -import * as openApiUtils from '../../src/utils/open-api'; +import * as openApiUtils from '../../src/shared/open-api'; import { apiRouteHandler, apiRouteOperation, @@ -434,10 +434,10 @@ it.each([ it('handles error if the OpenAPI spec generation fails', async () => { console.error = jest.fn(); - jest.mock('../../src/utils/open-api', () => { + jest.mock('../../src/shared/open-api', () => { return { __esModule: true, - ...jest.requireActual('../src/utils/open-api') + ...jest.requireActual('../../src/shared/open-api') }; }); diff --git a/packages/next-rest-framework/tests/pages-router/rpc.test.ts b/packages/next-rest-framework/tests/pages-router/rpc.test.ts new file mode 100644 index 0000000..a4d630f --- /dev/null +++ b/packages/next-rest-framework/tests/pages-router/rpc.test.ts @@ -0,0 +1,159 @@ +import { validateSchema } from '../../src/shared'; +import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; +import { z } from 'zod'; +import { rpcApiRouteHandler, rpcOperation } from '../../src'; +import { createMockRpcApiRouteRequest } from '../utils'; + +it.each(Object.values(ValidMethod))( + 'only works with HTTP method POST: %p', + async (method) => { + const { req, res } = createMockRpcApiRouteRequest({ + method + }); + + const data = ['All good!']; + + const operation = rpcOperation() + .output([z.array(z.string())]) + .handler(() => data); + + await rpcApiRouteHandler({ + test: operation + })(req, res); + + if (method === ValidMethod.POST) { + const json = res._getJSONData(); + expect(res.statusCode).toEqual(200); + expect(json).toEqual(data); + } else { + expect(res.statusCode).toEqual(405); + expect(res.getHeader('Allow')).toEqual('POST'); + } + } +); + +it('returns error for missing operation', async () => { + const { req, res } = createMockRpcApiRouteRequest({ + operation: 'does-not-exist' + }); + + await rpcApiRouteHandler({ + // @ts-expect-error: Intentionally invalid. + test: rpcOperation().handler() + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(400); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.operationNotAllowed + }); +}); + +it('returns error for invalid request body', async () => { + const body = { + foo: 'bar' + }; + + const { req, res } = createMockRpcApiRouteRequest({ + body + }); + + const schema = z.object({ + foo: z.number() + }); + + await rpcApiRouteHandler({ + test: rpcOperation() + .input(schema) + .handler(() => {}) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(400); + + const { errors } = await validateSchema({ schema, obj: body }); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); +}); + +it('returns a default error response', async () => { + const { req, res } = createMockRpcApiRouteRequest(); + + console.error = jest.fn(); + + await rpcApiRouteHandler({ + test: rpcOperation().handler(() => { + throw Error('Something went wrong'); + }) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(500); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.unexpectedError + }); +}); + +it('executes middleware before validating input', async () => { + const body = { + foo: 'bar' + }; + + const { req, res } = createMockRpcApiRouteRequest({ body }); + + const schema = z.object({ + foo: z.number() + }); + + console.log = jest.fn(); + + await rpcApiRouteHandler({ + test: rpcOperation() + .input(schema) + .middleware(() => { + console.log('foo'); + }) + .handler(() => {}) + })(req, res); + + expect(console.log).toHaveBeenCalledWith('foo'); + + const { errors } = await validateSchema({ schema, obj: body }); + + expect(res._getJSONData()).toEqual({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); + + expect(res.statusCode).toEqual(400); +}); + +it('does not execute handler if middleware returns a response', async () => { + const { req, res } = createMockRpcApiRouteRequest(); + + console.log = jest.fn(); + + await rpcApiRouteHandler({ + test: rpcOperation() + .middleware(() => { + return { foo: 'bar' }; + }) + .handler(() => { + console.log('foo'); + }) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(200); + + expect(json).toEqual({ + foo: 'bar' + }); + + expect(console.log).not.toHaveBeenCalled(); +}); diff --git a/packages/next-rest-framework/tests/utils.ts b/packages/next-rest-framework/tests/utils.ts index 834337b..0f5ce1a 100644 --- a/packages/next-rest-framework/tests/utils.ts +++ b/packages/next-rest-framework/tests/utils.ts @@ -5,14 +5,14 @@ import { DEFAULT_TITLE, OPEN_API_VERSION, VERSION, - type ValidMethod + ValidMethod } from '../src/constants'; import { createMocks, type RequestOptions, type ResponseOptions } from 'node-mocks-http'; -import { defaultResponse } from '../src/utils'; +import { defaultResponse } from '../src/shared'; import chalk from 'chalk'; import zodToJsonSchema from 'zod-to-json-schema'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; @@ -57,6 +57,35 @@ export const createMockRouteRequest = ({ context: { params } }); +export const createMockRpcRouteRequest = ({ + path = '/', + body, + method = ValidMethod.POST, + operation = 'test', + headers +}: { + method?: ValidMethod; + path?: string; + body?: Body; + operation?: string; + headers?: Record; +} = {}): { + req: NextRequest; +} => { + const { req } = createMockRouteRequest({ + path, + body, + method, + headers: { + 'X-RPC-Operation': operation, + 'Content-Type': 'application/json', + ...headers + } + }); + + return { req }; +}; + export const createMockApiRouteRequest = < Body, Query = Partial> @@ -77,6 +106,30 @@ export const createMockApiRouteRequest = < return createMocks(reqOptions, resOptions); }; +export const createMockRpcApiRouteRequest = ({ + path = '/', + body, + method = ValidMethod.POST, + operation = 'test', + headers +}: { + method?: ValidMethod; + path?: string; + body?: Body; + operation?: string; + headers?: Record; +} = {}) => + createMockApiRouteRequest({ + path, + body, + method, + headers: { + 'X-RPC-Operation': operation, + 'Content-Type': 'application/json', + ...headers + } + }); + export const getExpectedSpec = ({ zodSchema, allowedPaths, @@ -225,7 +278,7 @@ export const getExpectedSpec = ({ description: DEFAULT_DESCRIPTION, version: `v${VERSION}` }, - paths + paths: Object.keys(paths).length ? paths : undefined }; return spec;