From 492fce4dd417309548699b2e3c6e92216898e733 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Mon, 18 Mar 2024 02:05:36 +0200 Subject: [PATCH] Improve form support This adds support for strongly-typed form data using the `zod-form-data` schema that are now supported alongside regular Zod schemas. Pages router router routes now support validating and strongly typing form data requests that have the `application/x-www-form-urlencoded`, whereas app router routes also support strongly typed `multipart/form-data` requests using the `req.formData()` method from the NextRequest object. Because RPC routes share the common `rpcOperation` method for both pages and app router, strongly typed form data is for now only supported with the `application/x-www-form-urlencoded` content type. (+1 squashed commit) Squashed commits: [5e4cf8d] Improve form support This adds support for strongly-typed form data using the `zod-form-data` schema that are now supported alongside regular Zod schemas. Pages router router routes now support validating and strongly typing form data requests that have the `application/x-www-form-urlencoded`, whereas app router routes also support strongly typed `multipart/form-data` requests using the `req.formData()` method from the NextRequest object. Because RPC routes share the common `rpcOperation` method for both pages and app router, strongly typed form data is for now only supported with the `application/x-www-form-urlencoded` content type. --- apps/example/package.json | 3 +- apps/example/public/openapi.json | 358 ++++++++++- apps/example/src/actions.ts | 114 ++-- .../app/api/v2/form-data/multipart/route.ts | 53 ++ .../app/api/v2/form-data/url-encoded/route.ts | 30 + .../api/v2/route-with-query-params/route.ts | 9 +- apps/example/src/app/api/v2/route.ts | 3 +- .../src/app/api/v2/rpc/[operationId]/route.ts | 13 +- .../app/api/v2/third-party-endpoint/route.ts | 3 +- .../src/app/api/v2/todos/[id]/route.ts | 41 +- apps/example/src/app/api/v2/todos/route.ts | 43 +- .../pages/api/v1/form-data/multipart/index.ts | 70 +++ .../api/v1/form-data/url-encoded/index.ts | 26 + apps/example/src/pages/api/v1/index.ts | 3 +- .../api/v1/route-with-query-params/index.ts | 7 +- .../src/pages/api/v1/rpc/[operationId].ts | 13 +- .../api/v1/third-party-endpoint/index.ts | 9 + apps/example/src/pages/api/v1/todos/[id].ts | 38 +- apps/example/src/pages/api/v1/todos/index.ts | 49 +- apps/example/src/utils.ts | 10 + docs/docs/api-reference.md | 132 +++-- docs/docs/getting-started.md | 427 ++++++++----- docs/docs/intro.md | 5 +- packages/next-rest-framework/README.md | 560 +++++++++++------- packages/next-rest-framework/package.json | 13 +- .../src/app-router/route-operation.ts | 222 +++++-- .../src/app-router/route.ts | 153 +++-- .../src/app-router/rpc-route.ts | 167 ++++-- .../src/client/rpc-client.ts | 4 +- packages/next-rest-framework/src/constants.ts | 5 + .../src/pages-router/api-route-operation.ts | 208 +++++-- .../src/pages-router/api-route.ts | 114 +++- .../src/pages-router/rpc-api-route.ts | 104 +++- .../src/shared/form-data.ts | 39 ++ .../next-rest-framework/src/shared/paths.ts | 144 +++-- .../src/shared/rpc-operation.ts | 212 +++++-- .../next-rest-framework/src/shared/schemas.ts | 44 +- packages/next-rest-framework/src/types.ts | 51 +- .../tests/app-router/route.test.ts | 201 +++++-- .../tests/app-router/rpc-route.test.ts | 205 ++++++- .../tests/pages-router/api-route.test.ts | 195 ++++-- .../tests/pages-router/rpc-api-route.test.ts | 205 ++++++- packages/next-rest-framework/tests/utils.ts | 13 +- pnpm-lock.yaml | 45 ++ 44 files changed, 3260 insertions(+), 1103 deletions(-) create mode 100644 apps/example/src/app/api/v2/form-data/multipart/route.ts create mode 100644 apps/example/src/app/api/v2/form-data/url-encoded/route.ts create mode 100644 apps/example/src/pages/api/v1/form-data/multipart/index.ts create mode 100644 apps/example/src/pages/api/v1/form-data/url-encoded/index.ts create mode 100644 apps/example/src/pages/api/v1/third-party-endpoint/index.ts create mode 100644 packages/next-rest-framework/src/shared/form-data.ts diff --git a/apps/example/package.json b/apps/example/package.json index 315ac7b..8d62e72 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -11,7 +11,8 @@ "lint": "tsc && next lint" }, "dependencies": { - "next-rest-framework": "workspace:*" + "next-rest-framework": "workspace:*", + "zod-form-data": "2.0.2" }, "devDependencies": { "autoprefixer": "10.0.1", diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index fa0cab7..15a6772 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -6,6 +6,74 @@ "version": "v5.1.12" }, "paths": { + "/api/v1/form-data/multipart": { + "post": { + "operationId": "multipartFormData", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MultipartFormDataRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Response for status 200", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/MultipartFormData200ResponseBody" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } + } + }, + "/api/v1/form-data/url-encoded": { + "post": { + "operationId": "urlEncodedFormData", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UrlEncodedFormDataRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Response for status 200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrlEncodedFormData200ResponseBody" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } + } + }, "/api/v1/route-with-query-params": { "get": { "operationId": "getQueryParams", @@ -80,8 +148,7 @@ } } } - }, - "tags": ["RPC"] + } } }, "/api/v1/rpc/deleteTodo": { @@ -113,8 +180,75 @@ } } } + } + } + }, + "/api/v1/rpc/formDataMultipart": { + "post": { + "operationId": "formDataMultipart", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FormDataMultipartRequestBody" + } + } + } }, - "tags": ["RPC"] + "responses": { + "200": { + "description": "FormDataMultipartResponseBody", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormDataMultipartResponseBody" + } + } + } + }, + "400": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } + } + }, + "/api/v1/rpc/formDataUrlEncoded": { + "post": { + "operationId": "formDataUrlEncoded", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FormDataUrlEncodedRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "FormDataUrlEncodedResponseBody", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormDataUrlEncodedResponseBody" + } + } + } + }, + "400": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } } }, "/api/v1/rpc/getTodoById": { @@ -148,8 +282,7 @@ } } } - }, - "tags": ["RPC"] + } } }, "/api/v1/rpc/getTodos": { @@ -174,8 +307,7 @@ } } } - }, - "tags": ["RPC"] + } } }, "/api/v1/todos": { @@ -200,8 +332,7 @@ } } } - }, - "tags": ["example-api", "todos", "pages-router"] + } }, "post": { "operationId": "createTodo", @@ -241,8 +372,7 @@ } } } - }, - "tags": ["example-api", "todos", "pages-router"] + } } }, "/api/v1/todos/{id}": { @@ -285,8 +415,7 @@ "required": true, "schema": { "type": "string" } } - ], - "tags": ["example-api", "todos", "pages-router"] + ] }, "delete": { "operationId": "deleteTodo", @@ -327,8 +456,75 @@ "required": true, "schema": { "type": "string" } } - ], - "tags": ["example-api", "todos", "pages-router"] + ] + } + }, + "/api/v2/form-data/multipart": { + "post": { + "operationId": "multipartFormData", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/MultipartFormDataRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Response for status 200", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/MultipartFormData200ResponseBody" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } + } + }, + "/api/v2/form-data/url-encoded": { + "post": { + "operationId": "urlEncodedFormData", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UrlEncodedFormDataRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "Response for status 200", + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/UrlEncodedFormData200ResponseBody" + } + } + } + }, + "500": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } } }, "/api/v2/route-with-query-params": { @@ -405,8 +601,7 @@ } } } - }, - "tags": ["RPC"] + } } }, "/api/v2/rpc/deleteTodo": { @@ -438,8 +633,75 @@ } } } + } + } + }, + "/api/v2/rpc/formDataMultipart": { + "post": { + "operationId": "formDataMultipart", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FormDataMultipartRequestBody" + } + } + } + }, + "responses": { + "200": { + "description": "FormDataMultipartResponseBody", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormDataMultipartResponseBody" + } + } + } + }, + "400": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } + } + }, + "/api/v2/rpc/formDataUrlEncoded": { + "post": { + "operationId": "formDataUrlEncoded", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/FormDataUrlEncodedRequestBody" + } + } + } }, - "tags": ["RPC"] + "responses": { + "200": { + "description": "FormDataUrlEncodedResponseBody", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FormDataUrlEncodedResponseBody" + } + } + } + }, + "400": { + "description": "An unknown error occurred, trying again might help.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UnexpectedError" } + } + } + } + } } }, "/api/v2/rpc/getTodoById": { @@ -473,8 +735,7 @@ } } } - }, - "tags": ["RPC"] + } } }, "/api/v2/rpc/getTodos": { @@ -499,8 +760,7 @@ } } } - }, - "tags": ["RPC"] + } } }, "/api/v2/todos": { @@ -525,8 +785,7 @@ } } } - }, - "tags": ["example-api", "todos", "app-router"] + } }, "post": { "operationId": "createTodo", @@ -566,8 +825,7 @@ } } } - }, - "tags": ["example-api", "todos", "app-router"] + } } }, "/api/v2/todos/{id}": { @@ -610,8 +868,7 @@ "required": true, "schema": { "type": "string" } } - ], - "tags": ["example-api", "todos", "app-router"] + ] }, "delete": { "operationId": "deleteTodo", @@ -652,8 +909,7 @@ "required": true, "schema": { "type": "string" } } - ], - "tags": ["example-api", "todos", "app-router"] + ] } } }, @@ -692,6 +948,25 @@ "required": ["message"], "additionalProperties": false }, + "FormDataMultipartRequestBody": { + "type": "object", + "properties": { "text": { "type": "string" }, "file": {} }, + "required": ["text", "file"], + "additionalProperties": false + }, + "FormDataMultipartResponseBody": { "type": "string", "format": "binary" }, + "FormDataUrlEncodedRequestBody": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + "additionalProperties": false + }, + "FormDataUrlEncodedResponseBody": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + "additionalProperties": false + }, "GetQueryParams200ResponseBody": { "type": "object", "properties": { @@ -756,10 +1031,33 @@ "additionalProperties": false } }, + "MultipartFormData200ResponseBody": { + "type": "string", + "format": "binary" + }, + "MultipartFormDataRequestBody": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "file": { "type": "string", "format": "binary" } + } + }, "UnexpectedError": { "type": "object", "properties": { "message": { "type": "string" } }, "additionalProperties": false + }, + "UrlEncodedFormData200ResponseBody": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + "additionalProperties": false + }, + "UrlEncodedFormDataRequestBody": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"], + "additionalProperties": false } } } diff --git a/apps/example/src/actions.ts b/apps/example/src/actions.ts index 027e8d8..c0428d4 100644 --- a/apps/example/src/actions.ts +++ b/apps/example/src/actions.ts @@ -1,83 +1,117 @@ 'use server'; import { rpcOperation } from 'next-rest-framework'; -import { MOCK_TODOS, todoSchema } from '@/utils'; +import { + MOCK_TODOS, + formSchema, + multipartFormSchema, + todoSchema +} from '@/utils'; import { z } from 'zod'; // The RPC operations can be used as server-actions and imported in the RPC route handlers. -export const getTodos = rpcOperation({ - tags: ['RPC'] -}) +export const getTodos = rpcOperation() .outputs([ { - schema: z.array(todoSchema) + body: z.array(todoSchema), + contentType: 'application/json' } ]) .handler(() => { - return MOCK_TODOS; // Type-checked output. + return MOCK_TODOS; }); -export const getTodoById = rpcOperation({ - tags: ['RPC'] -}) - .input(z.string()) +export const getTodoById = rpcOperation() + .input({ + contentType: 'application/json', + body: z.string() + }) .outputs([ { - schema: z.object({ + body: z.object({ error: z.string() - }) + }), + contentType: 'application/json' }, { - schema: todoSchema + body: todoSchema, + contentType: 'application/json' } ]) .handler((id) => { const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { - return { error: 'TODO not found.' }; // Type-checked output. + return { error: 'TODO not found.' }; } - return todo; // Type-checked output. + return todo; }); -export const createTodo = rpcOperation({ - tags: ['RPC'] -}) - .input( - z.object({ +export const createTodo = rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ name: z.string() }) - ) - .outputs([{ schema: todoSchema }]) - .handler( - async ({ - name // Strictly-typed input. - }) => { - // Create todo. - const todo = { id: 2, name, completed: false }; - return todo; // Type-checked output. - } - ); + }) + .outputs([{ body: todoSchema, contentType: 'application/json' }]) + .handler(async ({ name }) => { + const todo = { id: 4, name, completed: false }; + return todo; + }); -export const deleteTodo = rpcOperation({ - tags: ['RPC'] -}) - .input(z.string()) +export const deleteTodo = rpcOperation() + .input({ + contentType: 'application/json', + body: z.string() + }) .outputs([ - { schema: z.object({ error: z.string() }) }, - { schema: z.object({ message: z.string() }) } + { body: z.object({ error: z.string() }), contentType: 'application/json' }, + { body: z.object({ message: z.string() }), contentType: 'application/json' } ]) .handler((id) => { - // Delete todo. const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { return { - error: 'TODO not found.' // Type-checked output. + error: 'TODO not found.' }; } - return { message: 'TODO deleted.' }; // Type-checked output. + return { message: 'TODO deleted.' }; + }); + +export const formDataUrlEncoded = rpcOperation() + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([{ body: formSchema, contentType: 'application/json' }]) + .handler((formData) => { + return { + text: formData.get('text') + }; + }); + +export const formDataMultipart = rpcOperation() + .input({ + contentType: 'multipart/form-data', + body: multipartFormSchema // A zod-form-data schema is required. + }) + .outputs([ + { + body: z.custom(), + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'string', + format: 'binary' + }, + contentType: 'application/json' + } + ]) + .handler((formData) => { + const file = formData.get('file'); + return file; }); diff --git a/apps/example/src/app/api/v2/form-data/multipart/route.ts b/apps/example/src/app/api/v2/form-data/multipart/route.ts new file mode 100644 index 0000000..2f594b8 --- /dev/null +++ b/apps/example/src/app/api/v2/form-data/multipart/route.ts @@ -0,0 +1,53 @@ +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { multipartFormSchema } from '@/utils'; +import { z } from 'zod'; + +export const runtime = 'edge'; + +export const { POST } = route({ + multipartFormData: routeOperation({ + method: 'POST' + }) + .input({ + contentType: 'multipart/form-data', + body: multipartFormSchema, // A zod-form-data schema is required. + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'object', + properties: { + text: { + type: 'string' + }, + file: { + type: 'string', + format: 'binary' + } + } + } + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + body: z.unknown(), + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'string', + format: 'binary' + } + } + ]) + .handler(async (req) => { + // const json = await req.json(); // Form can also be parsed as JSON. + const formData = await req.formData(); + const file = formData.get('file'); + + return new TypedNextResponse(file, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${file.name}"` + } + }); + }) +}); diff --git a/apps/example/src/app/api/v2/form-data/url-encoded/route.ts b/apps/example/src/app/api/v2/form-data/url-encoded/route.ts new file mode 100644 index 0000000..d72d834 --- /dev/null +++ b/apps/example/src/app/api/v2/form-data/url-encoded/route.ts @@ -0,0 +1,30 @@ +import { formSchema } from '@/utils'; +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; + +export const runtime = 'edge'; + +export const { POST } = route({ + urlEncodedFormData: routeOperation({ + method: 'POST' + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + body: formSchema + } + ]) + .handler(async (req) => { + const { text } = await req.json(); + // const formData = await req.formData(); // Form can also be parsed as form data. + + // Type-checked response. + return TypedNextResponse.json({ + text + }); + }) +}); diff --git a/apps/example/src/app/api/v2/route-with-query-params/route.ts b/apps/example/src/app/api/v2/route-with-query-params/route.ts index eeca1e2..f3248d5 100644 --- a/apps/example/src/app/api/v2/route-with-query-params/route.ts +++ b/apps/example/src/app/api/v2/route-with-query-params/route.ts @@ -1,28 +1,27 @@ import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; -const schema = z.object({ +const querySchema = z.object({ foo: z.string().uuid(), bar: z.string().optional(), baz: z.string() }); -export const dynamic = 'force-dynamic'; +export const runtime = 'edge'; -// Example app router route handler with query params. export const { GET } = route({ getQueryParams: routeOperation({ method: 'GET' }) .input({ contentType: 'application/json', - query: schema + query: querySchema }) .outputs([ { status: 200, contentType: 'application/json', - schema + body: querySchema } ]) .handler((req) => { diff --git a/apps/example/src/app/api/v2/route.ts b/apps/example/src/app/api/v2/route.ts index a80583e..6825496 100644 --- a/apps/example/src/app/api/v2/route.ts +++ b/apps/example/src/app/api/v2/route.ts @@ -3,6 +3,5 @@ import { docsRoute } from 'next-rest-framework'; export const runtime = 'edge'; export const { GET } = docsRoute({ - deniedPaths: ['/api/routes/third-party-endpoint'], - openApiJsonPath: '/openapi.json' + deniedPaths: ['/api/v2/third-party-endpoint', '/api/v1/third-party-endpoint'] }); diff --git a/apps/example/src/app/api/v2/rpc/[operationId]/route.ts b/apps/example/src/app/api/v2/rpc/[operationId]/route.ts index bfdded8..d77d386 100644 --- a/apps/example/src/app/api/v2/rpc/[operationId]/route.ts +++ b/apps/example/src/app/api/v2/rpc/[operationId]/route.ts @@ -1,4 +1,11 @@ -import { createTodo, deleteTodo, getTodoById, getTodos } from '@/actions'; +import { + createTodo, + deleteTodo, + getTodoById, + getTodos, + formDataUrlEncoded, + formDataMultipart +} from '@/actions'; import { rpcRoute } from 'next-rest-framework'; export const runtime = 'edge'; @@ -7,7 +14,9 @@ export const { POST } = rpcRoute({ getTodos, getTodoById, createTodo, - deleteTodo + deleteTodo, + formDataUrlEncoded, + formDataMultipart }); export type RpcClient = typeof POST.client; diff --git a/apps/example/src/app/api/v2/third-party-endpoint/route.ts b/apps/example/src/app/api/v2/third-party-endpoint/route.ts index e9230f5..1a0bd40 100644 --- a/apps/example/src/app/api/v2/third-party-endpoint/route.ts +++ b/apps/example/src/app/api/v2/third-party-endpoint/route.ts @@ -4,8 +4,7 @@ export const runtime = 'edge'; // You can still write regular routes with Next REST Framework. export const GET = () => { - return NextResponse.json('Server error', { - status: 500, + return NextResponse.json('Hello World!', { headers: { 'Content-Type': 'text/plain' } }); }; diff --git a/apps/example/src/app/api/v2/todos/[id]/route.ts b/apps/example/src/app/api/v2/todos/[id]/route.ts index 0c4afec..5eecbda 100644 --- a/apps/example/src/app/api/v2/todos/[id]/route.ts +++ b/apps/example/src/app/api/v2/todos/[id]/route.ts @@ -1,43 +1,27 @@ +import { MOCK_TODOS, todoSchema } from '@/utils'; import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ - { - id: 1, - name: 'TODO 1', - completed: false - } -]; - export const runtime = 'edge'; -// Example dynamic app router route handler with GET/DELETE handlers. export const { GET, DELETE } = route({ getTodoById: routeOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'GET' }) .outputs([ { - schema: z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }), + body: todoSchema, status: 200, contentType: 'application/json' }, { - schema: z.string(), + body: z.string(), status: 404, contentType: 'application/json' } ]) .handler((_req, { params: { id } }) => { - const todo = TODOS.find((t) => t.id === Number(id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { return TypedNextResponse.json('TODO not found.', { @@ -51,27 +35,22 @@ export const { GET, DELETE } = route({ }), deleteTodo: routeOperation({ - method: 'DELETE', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'DELETE' }) .outputs([ { - schema: z.string(), + body: z.string(), status: 204, contentType: 'application/json' }, { - schema: z.string(), + body: z.string(), status: 404, contentType: 'application/json' } ]) .handler((_req, { params: { id } }) => { - // Delete todo. - const todo = TODOS.find((t) => t.id === Number(id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { return TypedNextResponse.json('TODO not found.', { @@ -79,6 +58,8 @@ export const { GET, DELETE } = route({ }); } + // Delete todo. + return TypedNextResponse.json('TODO deleted.', { status: 204 }); diff --git a/apps/example/src/app/api/v2/todos/route.ts b/apps/example/src/app/api/v2/todos/route.ts index de06878..af51993 100644 --- a/apps/example/src/app/api/v2/todos/route.ts +++ b/apps/example/src/app/api/v2/todos/route.ts @@ -4,72 +4,55 @@ import { z } from 'zod'; export const runtime = 'edge'; -// Example app router route handler with GET/POST handlers. export const { GET, POST } = route({ getTodos: routeOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'GET' }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array(todoSchema) + body: z.array(todoSchema) } ]) .handler(() => { - // Type-checked response. return TypedNextResponse.json(MOCK_TODOS, { status: 200 }); }), createTodo: routeOperation({ - method: 'POST', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'POST' }) - // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ contentType: 'application/json', body: z.object({ name: z.string() }) }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 201, contentType: 'application/json', - schema: z.string() + body: z.string() }, { status: 401, contentType: 'application/json', - schema: z.string() + body: z.string() } ]) - .middleware( - // Optional middleware logic executed before request validation. - (req) => { - if (!req.headers.get('authorization')) { - // Type-checked response. - return TypedNextResponse.json('Unauthorized', { - status: 401 - }); - } + // Optional middleware logic executed before request validation. + .middleware((req) => { + if (!req.headers.get('very-secure')) { + return TypedNextResponse.json('Unauthorized', { + status: 401 + }); } - ) + }) .handler(async (req) => { - const { name } = await req.json(); // Strictly-typed request. + const { name } = await req.json(); - // Type-checked response. return TypedNextResponse.json(`New TODO created: ${name}`, { status: 201 }); diff --git a/apps/example/src/pages/api/v1/form-data/multipart/index.ts b/apps/example/src/pages/api/v1/form-data/multipart/index.ts new file mode 100644 index 0000000..be261da --- /dev/null +++ b/apps/example/src/pages/api/v1/form-data/multipart/index.ts @@ -0,0 +1,70 @@ +import { multipartFormSchema } from '@/utils'; +import { z } from 'zod'; +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; + +// Body parser must be disabled when parsing multipart/form-data requests with pages router. +export const config = { + api: { + bodyParser: false + } +}; + +export default apiRoute({ + multipartFormData: apiRouteOperation({ + method: 'POST' + }) + .input({ + contentType: 'multipart/form-data', + body: multipartFormSchema, // A zod-form-data schema is required. + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'object', + properties: { + text: { + type: 'string' + }, + file: { + type: 'string', + format: 'binary' + } + } + } + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + body: z.unknown(), + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'string', + format: 'binary' + } + } + ]) + .handler(async (req, res) => { + const formData = req.body; + const file = formData.get('file'); + const reader = file.stream().getReader(); + res.setHeader('Content-Type', 'application/octet-stream'); + + res.setHeader( + 'Content-Disposition', + `attachment; filename="${file.name}"` + ); + + const pump = async () => { + await reader.read().then(async ({ done, value }) => { + if (done) { + res.end(); + return; + } + + res.write(value); + await pump(); + }); + }; + + await pump(); + }) +}); diff --git a/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts b/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts new file mode 100644 index 0000000..235aaef --- /dev/null +++ b/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts @@ -0,0 +1,26 @@ +import { formSchema } from '@/utils'; +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; + +export default apiRoute({ + urlEncodedFormData: apiRouteOperation({ + method: 'POST' + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: formSchema + } + ]) + .handler((req, res) => { + const formData = req.body; + + res.json({ + text: formData.get('text') + }); + }) +}); diff --git a/apps/example/src/pages/api/v1/index.ts b/apps/example/src/pages/api/v1/index.ts index aab61c7..4be091b 100644 --- a/apps/example/src/pages/api/v1/index.ts +++ b/apps/example/src/pages/api/v1/index.ts @@ -1,8 +1,7 @@ import { docsApiRoute } from 'next-rest-framework'; export default docsApiRoute({ - deniedPaths: ['/api/routes/third-party-endpoint'], - openApiJsonPath: '/openapi.json', + deniedPaths: ['/api/v2/third-party-endpoint', '/api/v1/third-party-endpoint'], // Ignore endpoints from the generated OpenAPI spec. docsConfig: { provider: 'swagger-ui' } diff --git a/apps/example/src/pages/api/v1/route-with-query-params/index.ts b/apps/example/src/pages/api/v1/route-with-query-params/index.ts index 884f8e0..f122c4d 100644 --- a/apps/example/src/pages/api/v1/route-with-query-params/index.ts +++ b/apps/example/src/pages/api/v1/route-with-query-params/index.ts @@ -1,26 +1,25 @@ import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; -const schema = z.object({ +const querySchema = z.object({ foo: z.string().uuid(), bar: z.string().optional(), baz: z.string() }); -// Example pages router API route handler with query params. export default apiRoute({ getQueryParams: apiRouteOperation({ method: 'GET' }) .input({ contentType: 'application/json', - query: schema + query: querySchema }) .outputs([ { status: 200, contentType: 'application/json', - schema + body: querySchema } ]) .handler((req, res) => { diff --git a/apps/example/src/pages/api/v1/rpc/[operationId].ts b/apps/example/src/pages/api/v1/rpc/[operationId].ts index 89a38f3..43078ae 100644 --- a/apps/example/src/pages/api/v1/rpc/[operationId].ts +++ b/apps/example/src/pages/api/v1/rpc/[operationId].ts @@ -1,11 +1,20 @@ -import { createTodo, deleteTodo, getTodoById, getTodos } from '@/actions'; +import { + createTodo, + deleteTodo, + getTodoById, + getTodos, + formDataUrlEncoded, + formDataMultipart +} from '@/actions'; import { rpcApiRoute } from 'next-rest-framework'; const handler = rpcApiRoute({ getTodos, getTodoById, createTodo, - deleteTodo + deleteTodo, + formDataUrlEncoded, + formDataMultipart }); export type RpcClient = typeof handler.client; diff --git a/apps/example/src/pages/api/v1/third-party-endpoint/index.ts b/apps/example/src/pages/api/v1/third-party-endpoint/index.ts new file mode 100644 index 0000000..ade13f0 --- /dev/null +++ b/apps/example/src/pages/api/v1/third-party-endpoint/index.ts @@ -0,0 +1,9 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +// You can still write regular API routes with Next REST Framework. +const handler = (_req: NextApiRequest, res: NextApiResponse) => { + res.setHeader('Content-Type', 'text/plain'); + res.json('Hello World!'); +}; + +export default handler; diff --git a/apps/example/src/pages/api/v1/todos/[id].ts b/apps/example/src/pages/api/v1/todos/[id].ts index d6dd225..ac53781 100644 --- a/apps/example/src/pages/api/v1/todos/[id].ts +++ b/apps/example/src/pages/api/v1/todos/[id].ts @@ -1,21 +1,10 @@ +import { MOCK_TODOS, todoSchema } from '@/utils'; import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ - { - id: 1, - name: 'TODO 1', - completed: false - } -]; - -// Example dynamic pages router API route with GET/DELETE handlers. export default apiRoute({ getTodoById: apiRouteOperation({ - method: 'GET', - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'GET' }) .input({ query: z.object({ @@ -24,22 +13,18 @@ export default apiRoute({ }) .outputs([ { - schema: z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }), + body: todoSchema, status: 200, contentType: 'application/json' }, { - schema: z.string(), + body: z.string(), status: 404, contentType: 'application/json' } ]) .handler((req, res) => { - const todo = TODOS.find((t) => t.id === Number(req.query.id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(req.query.id)); if (!todo) { res.status(404).json('TODO not found.'); @@ -50,10 +35,7 @@ export default apiRoute({ }), deleteTodo: apiRouteOperation({ - method: 'DELETE', - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'DELETE' }) .input({ query: z.object({ @@ -62,24 +44,24 @@ export default apiRoute({ }) .outputs([ { - schema: z.string(), + body: z.string(), status: 204, contentType: 'application/json' }, { - schema: z.string(), + body: z.string(), status: 404, contentType: 'application/json' } ]) .handler((req, res) => { - // Delete todo. - const todo = TODOS.find((t) => t.id === Number(req.query.id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(req.query.id)); if (!todo) { res.status(404).json('TODO not found.'); } + // Delete the todo. res.status(204).json('TODO deleted.'); }) }); diff --git a/apps/example/src/pages/api/v1/todos/index.ts b/apps/example/src/pages/api/v1/todos/index.ts index 9f7ee67..13f51f8 100644 --- a/apps/example/src/pages/api/v1/todos/index.ts +++ b/apps/example/src/pages/api/v1/todos/index.ts @@ -1,77 +1,52 @@ +import { MOCK_TODOS, todoSchema } from '@/utils'; import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ - { - id: 1, - name: 'TODO 1', - completed: false - } -]; - -// Example pages router API route with GET/POST handlers. export default apiRoute({ getTodos: apiRouteOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'GET' }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) + body: z.array(todoSchema) } ]) .handler((_req, res) => { - // Type-checked response. - res.status(200).json(TODOS); + res.status(200).json(MOCK_TODOS); }), createTodo: apiRouteOperation({ - method: 'POST', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'POST' }) - // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ contentType: 'application/json', body: z.object({ name: z.string() }) }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 201, contentType: 'application/json', - schema: z.string() + body: z.string() }, { status: 401, contentType: 'application/json', - schema: z.string() + body: z.string() } ]) // Optional middleware logic executed before request validation. .middleware((req, res) => { - if (!req.headers.authorization) { - res.status(401).json('Unauthorized'); // Type-checked response. + if (!req.headers['very-secure']) { + res.status(401).json('Unauthorized'); } }) .handler((req, res) => { - const { name } = req.body; // Strictly-typed request. - res.status(201).json(`New TODO created: ${name}`); // Type-checked response. + const { name } = req.body; + // Create a new TODO. + res.status(201).json(`New TODO created: ${name}`); }) }); diff --git a/apps/example/src/utils.ts b/apps/example/src/utils.ts index f542690..0930d92 100644 --- a/apps/example/src/utils.ts +++ b/apps/example/src/utils.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { zfd } from 'zod-form-data'; export const todoSchema = z.object({ id: z.number(), @@ -6,6 +7,15 @@ export const todoSchema = z.object({ completed: z.boolean() }); +export const formSchema = zfd.formData({ + text: zfd.text() +}); + +export const multipartFormSchema = zfd.formData({ + text: zfd.text(), + file: zfd.file() // In development with Edge runtime this won't work: https://github.com/vercel/next.js/issues/38184 +}); + export type Todo = z.infer; export const MOCK_TODOS: Todo[] = [ diff --git a/docs/docs/api-reference.md b/docs/docs/api-reference.md index 1285a7e..4e0fbcf 100644 --- a/docs/docs/api-reference.md +++ b/docs/docs/api-reference.md @@ -33,16 +33,11 @@ The docs config options can be used to customize the generated docs: #### [Route handler options](#route-handler-options) -The following options can be passed to the `routeHandler` (app router) and `apiRouteHandler` (pages router) functions to create new API endpoints: - -| Name | Description | Required | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| `GET \| PUT \| POST \| DELETE \| OPTIONS \| HEAD \| PATCH` | A [Method handler](#method-handlers) object. | `true` | -| `openApiPath` | An OpenAPI [Path Item Object](https://swagger.io/specification/#path-item-object) that can be used to override and extend the auto-generated specification. | `false` | +The `routeHandler` (app router) and `apiRouteHandler` (pages router) functions allow you to pass an object as the second parameter, where you can define a property called `openApiPath`. This property is an OpenAPI [Path Item Object](https://swagger.io/specification/#path-item-object) that can be used to override and extend the auto-generated specification for the given route. #### [Route operations](#route-operations) -The route operation functions `routeOperation` (app router) and `apiRouteOperation` (pages router) allow you to define your API handlers for your endpoints. These functions accept an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) as a parameter, that can be used to override the auto-generated specification. Calling this function allows you to chain your API handler logic with the following functions: +The route operation functions `routeOperation` (app router) and `apiRouteOperation` (pages router) allow you to define your method handlers for your endpoints. These functions require you to pass an object where you will define the method for the given operation, as well as optionally a property called `openApiOperation`. This property is an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) that can be used to override and extend the auto-generated specification for the given operation. Calling the `routeOperation` and `apiRouteOperation` functions allows you to chain your API handler logic with the following functions: | Name | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -55,11 +50,14 @@ The route operation functions `routeOperation` (app router) and `apiRouteOperati The route operation input function is used for type-checking, validation and documentation of the request, taking in an object with the following properties: -| Name | Description | Required | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | -| `contentType` | The content type header of the request. When the content type is defined, a request with an incorrect content type header will get an error response. | `false` | -| `body` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the request body. When the body schema is defined, a request with an invalid request body will get an error response. | `false` | -| `query` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the query parameters. When the query schema is defined, a request with invalid query parameters will get an error response. | `false` | +| Name | Description | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `contentType` | The content type header of the request. When the content type is defined, a request with an incorrect content type header will get an error response. | `false` | +| `body` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the request body. When using `application/x-www-form-urlencoded` or `multipart/form-data` content types, this should be a `zod-form-data` schema instead. When the body schema is defined, a request with an invalid request body will get an error response. The request body is parsed using this schema and updated to the request if valid, so the body should always match the schema. | `false` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `query` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the query parameters. When the query schema is defined, a request with invalid query parameters will get an error response. Query parameters are parsed using this schema and updated to the request if valid, so the query parameters from the request should always match the schema. | `false` | +| `querySchema` | A JSON schema that you can provide in case the conversion of the `query` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `params` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the path parameters for strong typing when using them in your route handler. | `false` | Calling the route operation input function allows you to chain your API handler logic with the [Route operation outputs](#route-operation-outputs), [Route operation middleware](#route-operation-middleware) and [Route operation handler](#route-operation-handler) functions. @@ -67,29 +65,31 @@ Calling the route operation input function allows you to chain your API handler The route operation outputs function is used for type-checking and documentation of the response, taking in an array of objects with the following properties: -| Name | Description | Required | -| ------------- | --------------------------------------------------------------------------------------------- | -------- | -| `status` | A status code that your API can return. | `true` | -| `contentType` | The content type header of the response. | `true` | -| `schema` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the response data. |  `true` | +| Name | Description | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `status` | A status code that your API can return. | `true` | +| `contentType` | The content type header of the response. | `true` | +| `body` | A [Zod](https://github.com/colinhacks/zod) (or `zod-form-data`) schema describing the format of the response data. |  `true` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `name` | An optional name used in the generated OpenAPI spec for the response body, e.g. `GetTodosSuccessResponse`. | `false` | Calling the route operation outputs function allows you to chain your API handler logic with the [Route operation middleware](#route-operation-middleware) and [Route operation handler](#route-operation-handler) functions. ##### [Route operation middleware](#route-operation-middleware) -The route operation middleware function is executed before validating the request input. The function takes in the same parameters as the Next.js [router handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) handlers. Additionally, as a second parameter this function takes the return value of your last middleware function, defaulting to an empty object. Throwing an error inside a middleware function will stop the execution of the handler and you can also return a custom response like you would do within the [Handler](#handler) function. Calling the route operation middleware function allows you to chain your API handler logic with the [Handler](#handler) function. Alternatively, you may chain up to three middleware functions together: +The route operation middleware function is executed before validating the request input. The function takes in the same parameters as the Next.js [router handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) (app router) and [API route](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) (pages router) functions. Additionally, as a second parameter this function takes the return value of your last middleware function, defaulting to an empty object. Throwing an error inside a middleware function will stop the execution of the handler and you can also return a custom response like you would do within the [Route operation handler](#route-operation-handler) function. Calling the route operation middleware function allows you to chain your API handler logic with the [Route operation handler](#route-operation-handler) function. Alternatively, you may chain up to three middleware functions together: ```typescript -// ... -const handler = route({ - getTodos: routeOperation() +// App router. +export const { GET } = route({ + getTodos: routeOperation({ method: 'GET' }) .middleware(() => { return { foo: 'bar' }; }) .middleware((_req, _ctx, { foo }) => { - // if (myCondition) { - // return NextResponse.json({ error: 'My error.' }); - // } + if (myCondition) { + return NextResponse.json({ error: 'My error.' }, { status: 400 }); + } return { foo, @@ -100,34 +100,43 @@ const handler = route({ // ... }) }); -``` - -##### [Route operation handler](#route-operation-handler) -The route operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly-typed versions of the same parameters as the Next.js [router handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) handlers. Additionally, as a third parameter this function takes the return value of your last middleware function: - -```typescript -// ... -const handler = route({ - getTodos: routeOperation() +// Pages router. +export default apiRoute({ + getTodos: routeOperation({ method: 'GET' }) .middleware(() => { - return { foo: "bar" }; + return { foo: 'bar' }; }) - .handler((_req, _ctx, { foo }) => { + .middleware((req, res, { foo }) => { + if (myCondition) { + res.status(400).json({ error: 'My error.' }); + return; + } + + return { + foo, + bar: 'baz' + }; + }) + .handler((req, res, { foo, bar }) => { // ... - }); + }) }); ``` +##### [Route operation handler](#route-operation-handler) + +The route operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly-typed versions of the same parameters as the Next.js [router handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) (app router) and [API route](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) (pages router) functions. Additionally, as a third parameter this function takes the return value of your last middleware function (see above), defaulting to an empty object. + ### RPC #### [RPC route handler options](#rpc-route-handler-options) -The `rpcRouteHandler` (app router) and `rpcApiRouteHandler` (pages router) functions allow you to define your API handlers for your RPC endpoints. These functions accept an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) as a parameter, that can be used to override the auto-generated specification. +The `rpcRouteHandler` (app router) and `rpcApiRouteHandler` (pages router) functions allow you to pass an object as the second parameter, where you can define a property called `openApiPath`. This property is an OpenAPI [Path Item Object](https://swagger.io/specification/#path-item-object) that can be used to override and extend the auto-generated specification for the given route. #### [RPC operations](#rpc-operations) -The `rpcOperation` function allows you to define your API handlers for your RPC endpoint. Calling this function allows you to chain your API handler logic with the following functions. +The `rpcOperation` function allows you to define your API handlers for your RPC endpoint. This function allows you to pass an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) as a parameter, that can be used to override and extend the auto-generated specification for the given operation. Calling this function allows you to chain your API handler logic with the following functions. | Name | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -138,7 +147,13 @@ The `rpcOperation` function allows you to define your API handlers for your RPC ##### [RPC operation input](#rpc-operation-input) -The RPC operation input function is used for type-checking, validation and documentation of the RPC call. It takes in a A [Zod](https://github.com/colinhacks/zod) schema as a parameter that describes the format of the operation input. When the input schema is defined, an RPC call with invalid input will get an error response. +The RPC operation input function is used for type-checking, validation and documentation of the RPC call, taking in an object with the following properties: + +| Name | Description | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `contentType` | The content type header of the request, limited to `application/json`, `application/x-www-form-urlencoded` and `multipart/form-data`. When the content type is defined, a request with an incorrect content type header will get an error response. | `false` | +| `body` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the request body. When using `application/x-www-form-urlencoded` or `multipart/form-data` content types, this should be a `zod-form-data` schema instead. When the body schema is defined, a request with an invalid request body will get an error response. The request body is parsed using this schema and updated to the request if valid, so the body should always match the schema. | `false` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | Calling the RPC input function allows you to chain your API handler logic with the [RPC operation outputs](#rpc-operation-outputs), [RPC middleware](#rpc-operation-middleware) and [RPC handler](#rpc-operation-handler) functions. @@ -146,10 +161,11 @@ Calling the RPC input function allows you to chain your API handler logic with t The RPC operation outputs function is used for type-checking and documentation of the response, taking in an array of objects with the following properties: -| Name | Description | Required | -| -------- | --------------------------------------------------------------------------------------------- | -------- | -| `schema` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the response data. |  `true` | -| `name` | An optional name used in the generated OpenAPI spec, e.g. `GetTodosErrorResponse`. | `false` | +| Name | Description | Required | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `body` | A [Zod](https://github.com/colinhacks/zod) (or `zod-form-data`) schema describing the format of the response data. |  `true` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `name` | An optional name used in the generated OpenAPI spec for the response body, e.g. `GetTodosSuccessResponse`. | `false` | Calling the RPC operation outputs function allows you to chain your API handler logic with the [RPC operation middleware](#rpc-operation-middleware) and [RPC operation handler](#rpc-operation-handler) functions. @@ -158,16 +174,16 @@ Calling the RPC operation outputs function allows you to chain your API handler The RPC operation middleware function is executed before validating RPC operation input. The function takes in strongly typed parameters typed by the [RPC operation input](#rpc-operation-input) function. Additionally, as a second parameter this function takes the return value of your last middleware function, defaulting to an empty object. Throwing an error inside a middleware function will stop the execution of the handler. Calling the RPC operation middleware function allows you to chain your RPC API handler logic with the [RPC operation handler](#rpc-operation-handler) function. Alternatively, you may chain up to three middleware functions together: ```typescript -// ... -const handler = rpcRoute({ +// App router. +export const { POST } = rpcRoute({ getTodos: rpcOperation() .middleware(() => { return { foo: 'bar' }; }) .middleware((_input, { foo }) => { - // if (myCondition) { - // throw Error('My error.') - // } + if (myCondition) { + throw Error('My error.'); + } return { foo, @@ -178,24 +194,16 @@ const handler = rpcRoute({ // ... }) }); + +// Pages router. +export default rpcApiRoute({ + // ... Same as above. +}); ``` ##### [RPC operation handler](#rpc-operation-handler) -The RPC operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly typed parameters typed by the [RPC operation input](#rpc-operation-input) function. Additionally, as a second parameter this function takes the return value of your last middleware function: - -```typescript -// ... -const handler = rpcApiRoute({ - getTodos: rpcOperation() - .middleware(() => { - return { foo: "bar" }; - }) - .handler((_input, { foo }) => { - // ... - }); -}); -``` +The RPC operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly typed parameters typed by the [RPC operation input](#rpc-operation-input) function. Additionally, as a second parameter this function takes the return value of your last middleware function (see above), defaulting to an empty object. ## [CLI](#cli) diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 5231f9c..d15ea0f 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -6,11 +6,14 @@ sidebar_position: 2 ## Requirements -In order to use Next REST Framework you need to have a Next.js project with the following dependencies installed: +- Node.js v18.x. If you have an API using `File` or `FormData` web APIs, you might need Node v20.x, see: https://github.com/vercel/next.js/discussions/56032 + +You also need the following dependencies installed in you Next.js project: - [Next.js](https://github.com/vercel/next.js) >= v12 - [Zod](https://github.com/colinhacks/zod) >= v3 - [TypeScript](https://www.typescriptlang.org/) >= v3 +- Optional, needed if working with forms: [zod-form-data](https://www.npmjs.com/package/zod-form-data) >= v2 ## [Installation](#installation) @@ -25,26 +28,48 @@ To get access to the auto-generated documentation, initialize the docs endpoint #### [App router docs route](#app-router-docs-route): ```typescript -// src/app/api/route.ts - -// export const runtime = 'edge'; // Edge runtime is supported. +// src/app/api/v2/route.ts import { docsRoute } from 'next-rest-framework'; -export const { GET } = docsRoute(); +// export const runtime = 'edge'; // Edge runtime is supported. + +export const { GET } = docsRoute({ + // deniedPaths: [...] // Ignore endpoints from the generated OpenAPI spec. + // allowedPaths: [...], // Explicitly set which endpoints to include in the generated OpenAPI spec. + // Override and customize the generated OpenAPI spec. + openApiObject: { + info: { + title: 'My API', + version: '1.0.0', + description: 'My API description.' + } + // ... + }, + // openApiJsonPath: '/openapi.json', // Customize the path where the OpenAPI spec will be generated. + // Customize the rendered documentation. + docsConfig: { + provider: 'redoc', // redoc | swagger-ui + title: 'My API', + description: 'My API description.' + // ... + } +}); ``` #### [Pages router docs API route](#pages-router-docs-api-route): ```typescript -// src/pages/api.ts +// src/pages/api/v1/index.ts import { docsApiRoute } from 'next-rest-framework'; -export default docsApiRoute(); +export default docsApiRoute({ + // See configuration options from above. +}); ``` -This is enough to get you started. Now you can access the API documentation in your browser. Running `npx next-rest-framework generate` in the project root will generate the `openapi.json` OpenAPI specification file, located in the `public` folder. You can create multiple docs endpoints if needed and specify which config to use for the [CLI](#cli). See the full configuration options of this endpoint in the [Docs handler options](#docs-handler-options) section. +This is enough to get you started. Now you can access the API documentation in your browser. Running `npx next-rest-framework generate` in the project root will generate the `openapi.json` OpenAPI specification file, located in the `public` folder by default. You can create multiple docs endpoints if needed and specify which config to use for the [CLI](#cli). See the full configuration options of this endpoint in the [Docs handler options](#docs-handler-options) section. ### [Create endpoint](#create-endpoint) @@ -53,93 +78,77 @@ This is enough to get you started. Now you can access the API documentation in y ##### [App router route](#app-router-route): ```typescript -// src/app/api/todos/route.ts +// src/app/api/v2/todos/route.ts import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ +// export const runtime = 'edge'; // Edge runtime is supported. + +const MOCK_TODOS = [ { id: 1, name: 'TODO 1', completed: false } + // ... ]; -// export const runtime = 'edge'; // Edge runtime is supported. +const todoSchema = z.object({ + id: z.number(), + name: z.string(), + completed: z.boolean() +}); -// Example app router route handler with GET/POST handlers. export const { GET, POST } = route({ getTodos: routeOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'GET' }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) + body: z.array(todoSchema) } ]) .handler(() => { - // Type-checked response. - return TypedNextResponse.json(TODOS, { + return TypedNextResponse.json(MOCK_TODOS, { status: 200 }); }), createTodo: routeOperation({ - method: 'POST', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'POST' }) - // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ contentType: 'application/json', body: z.object({ name: z.string() }) }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 201, contentType: 'application/json', - schema: z.string() + body: z.string() }, { status: 401, contentType: 'application/json', - schema: z.string() + body: z.string() } ]) - .middleware( - // Optional middleware logic executed before request validation. - (req) => { - if (!req.headers.get('authorization')) { - // Type-checked response. - return TypedNextResponse.json('Unauthorized', { - status: 401 - }); - } + // Optional middleware logic executed before request validation. + .middleware((req) => { + if (!req.headers.get('very-secure')) { + return TypedNextResponse.json('Unauthorized', { + status: 401 + }); } - ) + }) .handler(async (req) => { - const { name } = await req.json(); // Strictly-typed request. + const { name } = await req.json(); - // Type-checked response. return TypedNextResponse.json(`New TODO created: ${name}`, { status: 201 }); @@ -147,7 +156,7 @@ export const { GET, POST } = route({ }); ``` -The `TypedNextResponse` ensures that the response status codes and content-type headers are type-checked. You can still use the regular `NextResponse` if you prefer to have less type-safety. +The `TypedNextResponse` ensures that the response status codes and content-type headers are type-checked against the defined outputs. You can still use the regular `NextResponse` if you prefer to have less type-safety. When using the default `nodejs` runtime with app router routes (`docsRoute` or `route`), you may encounter the [Dynamic server usage](https://nextjs.org/docs/messages/dynamic-server-error) Next.js error when running `next build`. In that case you should force the route to be dynamically rendered with the [dynamic](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic) option: @@ -158,83 +167,72 @@ export const dynamic = 'force-dynamic'; ##### [Pages router API route](#pages-router-api-route): ```typescript -// src/pages/api/todos.ts +// src/pages/api/v1/todos/index.ts import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ +const MOCK_TODOS = [ { id: 1, name: 'TODO 1', completed: false } + // ... ]; -// Example pages router API route with GET/POST handlers. +const todoSchema = z.object({ + id: z.number(), + name: z.string(), + completed: z.boolean() +}); + export default apiRoute({ getTodos: apiRouteOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'GET' }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) + body: z.array(todoSchema) } ]) .handler((_req, res) => { - // Type-checked response. - res.status(200).json(TODOS); + res.status(200).json(MOCK_TODOS); }), createTodo: apiRouteOperation({ - method: 'POST', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'POST' }) - // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ contentType: 'application/json', body: z.object({ name: z.string() }) }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 201, contentType: 'application/json', - schema: z.string() + body: z.string() }, { status: 401, contentType: 'application/json', - schema: z.string() + body: z.string() } ]) // Optional middleware logic executed before request validation. .middleware((req, res) => { - if (!req.headers.authorization) { - res.status(401).json('Unauthorized'); // Type-checked response. + if (!req.headers['very-secure']) { + res.status(401).json('Unauthorized'); } }) .handler((req, res) => { - const { name } = req.body; // Strictly-typed request. - res.status(201).json(`New TODO created: ${name}`); // Type-checked response. + const { name } = req.body; + // Create a new TODO. + res.status(201).json(`New TODO created: ${name}`); }) }); ``` @@ -243,11 +241,100 @@ After running `next-rest-framework generate`, all of above type-safe endpoints w ![Next REST Framework docs](@site/static/img/docs-screenshot.jpg) -#### [RPC endpoints](#rpc-endpoints) +#### [Form endpoints](#form-endpoints) -##### [App router RPC route](#app-router-rpc-route): +##### [App router form route](#app-router-form-route): + +When specifying request input schema for validation, the content type header determines what kind of schema you can use to validate the request body. +When using `application/json`, a plain Zod object schema can be used for the validation. When using `application/x-www-form-urlencoded` or `multipart/form-data` content types, a [zod-form-data](https://www.npmjs.com/package/zod-form-data) schema must be used: + +```typescript +// src/app/api/v2/form-data/url-encoded/route.ts + +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { zfd } from 'zod-form-data'; + +// export const runtime = 'edge'; // Edge runtime is supported. + +const formSchema = zfd.formData({ + text: zfd.text() +}); + +export const { POST } = route({ + urlEncodedFormData: routeOperation({ + method: 'POST' + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + body: formSchema + } + ]) + .handler(async (req) => { + const { text } = await req.json(); + // const formData = await req.formData(); // Form can also be parsed as form data. + + // Type-checked response. + return TypedNextResponse.json({ + text + }); + }) +}); +``` + +For `multipart/form-data` app router example, see [this example](https://github.com/blomqma/next-rest-framework/tree/main/apps/example/src/app/api/v2/form-data/multipart/route.ts). -A recommended way is to write your RPC operation in a separate server-side module where they can be consumed both by the RPC endpoints and directly as server-side functions (server actions): +##### [Pages router form API route](#pages-router-form-api-route): + +A form API route with pages router works similarly as the [App router form route](#app-router-form-route) using a `zod-form-data` schema: + +```typescript +// src/pages/api/v1/form-data/url-encoded/index.ts + +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; +import { zfd } from 'zod-form-data'; + +const formSchema = zfd.formData({ + text: zfd.text() +}); + +export default apiRoute({ + urlEncodedFormData: apiRouteOperation({ + method: 'POST' + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: formSchema + } + ]) + .handler((req, res) => { + const formData = req.body; + + res.json({ + text: formData.get('text') + }); + }) +}); +``` + +For `multipart/form-data` pages router example, see [this example](https://github.com/blomqma/next-rest-framework/tree/main/apps/example/pages/api/v1/form-data/multipart/index.ts/form-data/multipart/index.ts). + +The form routes will also be included in your OpenAPI spec after running `next-rest-framework generate`. + +#### [RPC endpoints](#rpc-endpoints) + +Next REST Framework also supports writing RPC-styled APIs that support JSON and form data. A recommended way is to write your RPC operations in a separate server-side module where they can be consumed both by the RPC endpoints and directly as server-side functions (server actions): ```typescript // src/app/actions.ts @@ -256,13 +343,17 @@ A recommended way is to write your RPC operation in a separate server-side modul import { rpcOperation } from 'next-rest-framework'; import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +// The RPC operations can be used as server-actions and imported in the RPC route handlers. -const TODOS = [ +const MOCK_TODOS = [ { id: 1, name: 'TODO 1', completed: false } + // ... ]; const todoSchema = z.object({ @@ -271,104 +362,118 @@ const todoSchema = z.object({ completed: z.boolean() }); -export const getTodos = rpcOperation({ - tags: ['RPC'] -}) +export const getTodos = rpcOperation() .outputs([ { - schema: z.array(todoSchema) + body: z.array(todoSchema) } ]) .handler(() => { - return TODOS; // Type-checked output. + return MOCK_TODOS; }); -export const getTodoById = rpcOperation({ - tags: ['RPC'] -}) - .input(z.string()) +export const getTodoById = rpcOperation() + .input({ + contentType: 'application/json', + body: z.string() + }) .outputs([ { - schema: z.object({ + body: z.object({ error: z.string() }) }, { - schema: todoSchema + body: todoSchema } ]) .handler((id) => { - const todo = TODOS.find((t) => t.id === Number(id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { - return { error: 'TODO not found.' }; // Type-checked output. + return { error: 'TODO not found.' }; } - return todo; // Type-checked output. + return todo; }); -export const createTodo = rpcOperation({ - tags: ['RPC'] -}) - .input( - z.object({ +export const createTodo = rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ name: z.string() }) - ) - .outputs([{ schema: todoSchema }]) - .handler( - async ({ - name // Strictly-typed input. - }) => { - // Create todo. - const todo = { id: 2, name, completed: false }; - return todo; // Type-checked output. - } - ); + }) + .outputs([{ body: todoSchema }]) + .handler(async ({ name }) => { + const todo = { id: 4, name, completed: false }; + return todo; + }); -export const deleteTodo = rpcOperation({ - tags: ['RPC'] -}) - .input(z.string()) +export const deleteTodo = rpcOperation() + .input({ + contentType: 'application/json', + body: z.string() + }) .outputs([ - { schema: z.object({ error: z.string() }) }, - { schema: z.object({ message: z.string() }) } + { body: z.object({ error: z.string() }) }, + { body: z.object({ message: z.string() }) } ]) .handler((id) => { - // Delete todo. - const todo = TODOS.find((t) => t.id === Number(id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { return { - error: 'TODO not found.' // Type-checked output. + error: 'TODO not found.' }; } - return { message: 'TODO deleted.' }; // Type-checked output. + return { message: 'TODO deleted.' }; }); -``` - -The file path to and RPC route must end with `/[operationId]/route.ts`. Import the RPC operations in to your RPC route handler: -```typescript -// src/app/api/rpc/[operationId]/route.ts - -import { createTodo, deleteTodo, getTodoById, getTodos } from 'src/app/actions'; -import { rpcRoute } from 'next-rest-framework'; +const formSchema = zfd.formData({ + text: zfd.text() +}); -// export const runtime = 'edge'; // Edge runtime is supported. +export const formDataUrlEncoded = rpcOperation() + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([{ body: formSchema }]) + .handler((formData) => { + return { + text: formData.get('text') + }; + }); -export const { POST } = rpcRoute({ - getTodos, - getTodoById, - createTodo, - deleteTodo +const multipartFormSchema = zfd.formData({ + text: zfd.text(), + file: zfd.file() }); -export type RpcClient = typeof POST.client; +export const formDataMultipart = rpcOperation() + .input({ + contentType: 'multipart/form-data', + body: multipartFormSchema // A zod-form-data schema is required. + }) + .outputs([ + { + body: z.custom(), + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'string', + format: 'binary' + } + } + ]) + .handler((formData) => { + const file = formData.get('file'); + return file; + }); ``` -Consume the RPC operations directly in your server-side components: +Now you can consume the RPC operations directly in your server-side components: ```typescript 'use server'; @@ -387,7 +492,39 @@ export default async function Page() { } ``` -##### [Pages router RPC route](#pages-router-rpc-api-route): +##### [App router RPC route](#app-router-rpc-route): + +The file path to an RPC route must end with `/[operationId]/route.ts`. Simply import the RPC operations in to your RPC route handler: + +```typescript +// src/app/api/rpc/[operationId]/route.ts + +import { + createTodo, + deleteTodo, + getTodoById, + getTodos, + formDataUrlEncoded, + formDataMultipart +} from 'src/app/actions'; +import { rpcRoute } from 'next-rest-framework'; + +// export const runtime = 'edge'; // Edge runtime is supported. + +export const { POST } = rpcRoute({ + getTodos, + getTodoById, + createTodo, + deleteTodo, + formDataUrlEncoded, + formDataMultipart + // You can also inline the RPC operations in this object if you don't need to use server actions. +}); + +export type RpcClient = typeof POST.client; +``` + +##### [Pages router RPC API route](#pages-router-rpc-api-route): The filename of an RPC API route must be `[operationId].ts`. @@ -395,11 +532,11 @@ The filename of an RPC API route must be `[operationId].ts`. // src/pages/api/rpc/[operationId].ts import { rpcApiRoute } from 'next-rest-framework'; +// import { ... } from 'src/app/actions'; -// Example pages router RPC handler. const handler = rpcApiRoute({ // ... - // Exactly the same as the app router example. You can also inline the RPC operations in this object. + // Exactly the same as the app router example above. }); export default handler; @@ -413,11 +550,11 @@ The RPC routes will also be included in your OpenAPI spec after running `next-re #### [REST client](#rest-client) -To achieve end-to-end type-safety, you can use any client implementation that relies on the generated OpenAPI specification, e.g. [openapi-client-axios](https://github.com/openapistack/openapi-client-axios). +To achieve end-to-end type-safety with your REST endpoints, you can use any client implementation that relies on the generated OpenAPI specification, e.g. [openapi-client-axios](https://github.com/openapistack/openapi-client-axios). #### [RPC client](#rpc-client) -For client-rendered components you can use the strongly-typed `rpcClient`: +While you can consume your RPC operations directly as server actions in your React server components, for client-rendered components you can use the strongly-typed `rpcClient`, passing in the exported type from your RPC endpoint as a generic parameter: ```typescript 'use client'; diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 928355d..d13d9b5 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -25,11 +25,14 @@ Next REST Framework is an open-source, opinionated, lightweight, easy-to-use set ## [Requirements](#requirements) -In order to use Next REST Framework you need to have a Next.js project with the following dependencies installed: +- Node.js v18.x. If you have an API using `File` or `FormData` web APIs, you might need Node v20.x, see: https://github.com/vercel/next.js/discussions/56032 + +You also need the following dependencies installed in you Next.js project: - [Next.js](https://github.com/vercel/next.js) >= v12 - [Zod](https://github.com/colinhacks/zod) >= v3 - [TypeScript](https://www.typescriptlang.org/) >= v3 +- Optional, needed if working with forms: [zod-form-data](https://www.npmjs.com/package/zod-form-data) >= v2 ### [Installation](#installation) diff --git a/packages/next-rest-framework/README.md b/packages/next-rest-framework/README.md index b5f248c..0205223 100644 --- a/packages/next-rest-framework/README.md +++ b/packages/next-rest-framework/README.md @@ -37,9 +37,12 @@ - [REST endpoints](#rest-endpoints) - [App router route:](#app-router-route) - [Pages router API route:](#pages-router-api-route) + - [Form endpoints](#form-endpoints) + - [App router form route:](#app-router-form-route) + - [Pages router form API route:](#pages-router-form-api-route) - [RPC endpoints](#rpc-endpoints) - [App router RPC route:](#app-router-rpc-route) - - [Pages router RPC route:](#pages-router-rpc-route) + - [Pages router RPC API route:](#pages-router-rpc-api-route) - [Client](#client) - [REST client](#rest-client) - [RPC client](#rpc-client) @@ -93,11 +96,14 @@ This is a monorepo containing the following packages / projects: ## Requirements -In order to use Next REST Framework you need to have a Next.js project with the following dependencies installed: +- Node.js v18.x. If you have an API using `File` or `FormData` web APIs, you might need Node v20.x, see: https://github.com/vercel/next.js/discussions/56032 + +You also need the following dependencies installed in you Next.js project: - [Next.js](https://github.com/vercel/next.js) >= v12 - [Zod](https://github.com/colinhacks/zod) >= v3 - [TypeScript](https://www.typescriptlang.org/) >= v3 +- Optional, needed if working with forms: [zod-form-data](https://www.npmjs.com/package/zod-form-data) >= v2 ## [Installation](#installation) @@ -114,26 +120,48 @@ To get access to the auto-generated documentation, initialize the docs endpoint #### [App router docs route](#app-router-docs-route): ```typescript -// src/app/api/route.ts +// src/app/api/v2/route.ts import { docsRoute } from 'next-rest-framework'; // export const runtime = 'edge'; // Edge runtime is supported. -export const { GET } = docsRoute(); +export const { GET } = docsRoute({ + // deniedPaths: [...] // Ignore endpoints from the generated OpenAPI spec. + // allowedPaths: [...], // Explicitly set which endpoints to include in the generated OpenAPI spec. + // Override and customize the generated OpenAPI spec. + openApiObject: { + info: { + title: 'My API', + version: '1.0.0', + description: 'My API description.' + } + // ... + }, + // openApiJsonPath: '/openapi.json', // Customize the path where the OpenAPI spec will be generated. + // Customize the rendered documentation. + docsConfig: { + provider: 'redoc', // redoc | swagger-ui + title: 'My API', + description: 'My API description.' + // ... + } +}); ``` #### [Pages router docs API route](#pages-router-docs-api-route): ```typescript -// src/pages/api.ts +// src/pages/api/v1/index.ts import { docsApiRoute } from 'next-rest-framework'; -export default docsApiRoute(); +export default docsApiRoute({ + // See configuration options from above. +}); ``` -This is enough to get you started. Now you can access the API documentation in your browser. Running `npx next-rest-framework generate` in the project root will generate the `openapi.json` OpenAPI specification file, located in the `public` folder. You can create multiple docs endpoints if needed and specify which config to use for the [CLI](#cli). See the full configuration options of this endpoint in the [Docs handler options](#docs-handler-options) section. +This is enough to get you started. Now you can access the API documentation in your browser. Running `npx next-rest-framework generate` in the project root will generate the `openapi.json` OpenAPI specification file, located in the `public` folder by default. You can create multiple docs endpoints if needed and specify which config to use for the [CLI](#cli). See the full configuration options of this endpoint in the [Docs handler options](#docs-handler-options) section. ### [Create endpoint](#create-endpoint) @@ -142,93 +170,77 @@ This is enough to get you started. Now you can access the API documentation in y ##### [App router route](#app-router-route): ```typescript -// src/app/api/todos/route.ts +// src/app/api/v2/todos/route.ts import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ +// export const runtime = 'edge'; // Edge runtime is supported. + +const MOCK_TODOS = [ { id: 1, name: 'TODO 1', completed: false } + // ... ]; -// export const runtime = 'edge'; // Edge runtime is supported. +const todoSchema = z.object({ + id: z.number(), + name: z.string(), + completed: z.boolean() +}); -// Example app router route handler with GET/POST handlers. export const { GET, POST } = route({ getTodos: routeOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'GET' }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) + body: z.array(todoSchema) } ]) .handler(() => { - // Type-checked response. - return TypedNextResponse.json(TODOS, { + return TypedNextResponse.json(MOCK_TODOS, { status: 200 }); }), createTodo: routeOperation({ - method: 'POST', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'app-router'] - } + method: 'POST' }) - // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ contentType: 'application/json', body: z.object({ name: z.string() }) }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 201, contentType: 'application/json', - schema: z.string() + body: z.string() }, { status: 401, contentType: 'application/json', - schema: z.string() + body: z.string() } ]) - .middleware( - // Optional middleware logic executed before request validation. - (req) => { - if (!req.headers.get('authorization')) { - // Type-checked response. - return TypedNextResponse.json('Unauthorized', { - status: 401 - }); - } + // Optional middleware logic executed before request validation. + .middleware((req) => { + if (!req.headers.get('very-secure')) { + return TypedNextResponse.json('Unauthorized', { + status: 401 + }); } - ) + }) .handler(async (req) => { - const { name } = await req.json(); // Strictly-typed request. + const { name } = await req.json(); - // Type-checked response. return TypedNextResponse.json(`New TODO created: ${name}`, { status: 201 }); @@ -236,7 +248,7 @@ export const { GET, POST } = route({ }); ``` -The `TypedNextResponse` ensures that the response status codes and content-type headers are type-checked. You can still use the regular `NextResponse` if you prefer to have less type-safety. +The `TypedNextResponse` ensures that the response status codes and content-type headers are type-checked against the defined outputs. You can still use the regular `NextResponse` if you prefer to have less type-safety. When using the default `nodejs` runtime with app router routes (`docsRoute` or `route`), you may encounter the [Dynamic server usage](https://nextjs.org/docs/messages/dynamic-server-error) Next.js error when running `next build`. In that case you should force the route to be dynamically rendered with the [dynamic](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic) option: @@ -247,83 +259,72 @@ export const dynamic = 'force-dynamic'; ##### [Pages router API route](#pages-router-api-route): ```typescript -// src/pages/api/todos.ts +// src/pages/api/v1/todos/index.ts import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; -const TODOS = [ +const MOCK_TODOS = [ { id: 1, name: 'TODO 1', completed: false } + // ... ]; -// Example pages router API route with GET/POST handlers. +const todoSchema = z.object({ + id: z.number(), + name: z.string(), + completed: z.boolean() +}); + export default apiRoute({ getTodos: apiRouteOperation({ - method: 'GET', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'GET' }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 200, contentType: 'application/json', - schema: z.array( - z.object({ - id: z.number(), - name: z.string(), - completed: z.boolean() - }) - ) + body: z.array(todoSchema) } ]) .handler((_req, res) => { - // Type-checked response. - res.status(200).json(TODOS); + res.status(200).json(MOCK_TODOS); }), createTodo: apiRouteOperation({ - method: 'POST', - // Optional OpenAPI operation documentation. - openApiOperation: { - tags: ['example-api', 'todos', 'pages-router'] - } + method: 'POST' }) - // Input schema for strictly-typed request, request validation and OpenAPI documentation. .input({ contentType: 'application/json', body: z.object({ name: z.string() }) }) - // Output schema for strictly-typed responses and OpenAPI documentation. .outputs([ { status: 201, contentType: 'application/json', - schema: z.string() + body: z.string() }, { status: 401, contentType: 'application/json', - schema: z.string() + body: z.string() } ]) // Optional middleware logic executed before request validation. .middleware((req, res) => { - if (!req.headers.authorization) { - res.status(401).json('Unauthorized'); // Type-checked response. + if (!req.headers['very-secure']) { + res.status(401).json('Unauthorized'); } }) .handler((req, res) => { - const { name } = req.body; // Strictly-typed request. - res.status(201).json(`New TODO created: ${name}`); // Type-checked response. + const { name } = req.body; + // Create a new TODO. + res.status(201).json(`New TODO created: ${name}`); }) }); ``` @@ -332,11 +333,100 @@ After running `next-rest-framework generate`, all of above type-safe endpoints w ![Next REST Framework docs](./docs/static/img/docs-screenshot.jpg) -#### [RPC endpoints](#rpc-endpoints) +#### [Form endpoints](#form-endpoints) -##### [App router RPC route](#app-router-rpc-route): +##### [App router form route](#app-router-form-route): + +When specifying request input schema for validation, the content type header determines what kind of schema you can use to validate the request body. +When using `application/json`, a plain Zod object schema can be used for the validation. When using `application/x-www-form-urlencoded` or `multipart/form-data` content types, a [zod-form-data](https://www.npmjs.com/package/zod-form-data) schema must be used: + +```typescript +// src/app/api/v2/form-data/url-encoded/route.ts + +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { zfd } from 'zod-form-data'; + +// export const runtime = 'edge'; // Edge runtime is supported. + +const formSchema = zfd.formData({ + text: zfd.text() +}); + +export const { POST } = route({ + urlEncodedFormData: routeOperation({ + method: 'POST' + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + body: formSchema + } + ]) + .handler(async (req) => { + const { text } = await req.json(); + // const formData = await req.formData(); // Form can also be parsed as form data. + + // Type-checked response. + return TypedNextResponse.json({ + text + }); + }) +}); +``` + +For `multipart/form-data` app router example, see [this example](https://github.com/blomqma/next-rest-framework/tree/main/apps/example/src/app/api/v2/form-data/multipart/route.ts). + +##### [Pages router form API route](#pages-router-form-api-route): -A recommended way is to write your RPC operation in a separate server-side module where they can be consumed both by the RPC endpoints and directly as server-side functions (server actions): +A form API route with pages router works similarly as the [App router form route](#app-router-form-route) using a `zod-form-data` schema: + +```typescript +// src/pages/api/v1/form-data/url-encoded/index.ts + +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; +import { zfd } from 'zod-form-data'; + +const formSchema = zfd.formData({ + text: zfd.text() +}); + +export default apiRoute({ + urlEncodedFormData: apiRouteOperation({ + method: 'POST' + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: formSchema + } + ]) + .handler((req, res) => { + const formData = req.body; + + res.json({ + text: formData.get('text') + }); + }) +}); +``` + +For `multipart/form-data` pages router example, see [this example](https://github.com/blomqma/next-rest-framework/tree/main/apps/example/pages/api/v1/form-data/multipart/index.ts/form-data/multipart/index.ts). + +The form routes will also be included in your OpenAPI spec after running `next-rest-framework generate`. + +#### [RPC endpoints](#rpc-endpoints) + +Next REST Framework also supports writing RPC-styled APIs that support JSON and form data. A recommended way is to write your RPC operations in a separate server-side module where they can be consumed both by the RPC endpoints and directly as server-side functions (server actions): ```typescript // src/app/actions.ts @@ -345,13 +435,17 @@ A recommended way is to write your RPC operation in a separate server-side modul import { rpcOperation } from 'next-rest-framework'; import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +// The RPC operations can be used as server-actions and imported in the RPC route handlers. -const TODOS = [ +const MOCK_TODOS = [ { id: 1, name: 'TODO 1', completed: false } + // ... ]; const todoSchema = z.object({ @@ -360,104 +454,118 @@ const todoSchema = z.object({ completed: z.boolean() }); -export const getTodos = rpcOperation({ - tags: ['RPC'] -}) +export const getTodos = rpcOperation() .outputs([ { - schema: z.array(todoSchema) + body: z.array(todoSchema) } ]) .handler(() => { - return TODOS; // Type-checked output. + return MOCK_TODOS; }); -export const getTodoById = rpcOperation({ - tags: ['RPC'] -}) - .input(z.string()) +export const getTodoById = rpcOperation() + .input({ + contentType: 'application/json', + body: z.string() + }) .outputs([ { - schema: z.object({ + body: z.object({ error: z.string() }) }, { - schema: todoSchema + body: todoSchema } ]) .handler((id) => { - const todo = TODOS.find((t) => t.id === Number(id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { - return { error: 'TODO not found.' }; // Type-checked output. + return { error: 'TODO not found.' }; } - return todo; // Type-checked output. + return todo; }); -export const createTodo = rpcOperation({ - tags: ['RPC'] -}) - .input( - z.object({ +export const createTodo = rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ name: z.string() }) - ) - .outputs([{ schema: todoSchema }]) - .handler( - async ({ - name // Strictly-typed input. - }) => { - // Create todo. - const todo = { id: 2, name, completed: false }; - return todo; // Type-checked output. - } - ); + }) + .outputs([{ body: todoSchema }]) + .handler(async ({ name }) => { + const todo = { id: 4, name, completed: false }; + return todo; + }); -export const deleteTodo = rpcOperation({ - tags: ['RPC'] -}) - .input(z.string()) +export const deleteTodo = rpcOperation() + .input({ + contentType: 'application/json', + body: z.string() + }) .outputs([ - { schema: z.object({ error: z.string() }) }, - { schema: z.object({ message: z.string() }) } + { body: z.object({ error: z.string() }) }, + { body: z.object({ message: z.string() }) } ]) .handler((id) => { - // Delete todo. - const todo = TODOS.find((t) => t.id === Number(id)); + const todo = MOCK_TODOS.find((t) => t.id === Number(id)); if (!todo) { return { - error: 'TODO not found.' // Type-checked output. + error: 'TODO not found.' }; } - return { message: 'TODO deleted.' }; // Type-checked output. + return { message: 'TODO deleted.' }; }); -``` - -The file path to and RPC route must end with `/[operationId]/route.ts`. Import the RPC operations in to your RPC route handler: -```typescript -// src/app/api/rpc/[operationId]/route.ts - -import { createTodo, deleteTodo, getTodoById, getTodos } from 'src/app/actions'; -import { rpcRoute } from 'next-rest-framework'; +const formSchema = zfd.formData({ + text: zfd.text() +}); -// export const runtime = 'edge'; // Edge runtime is supported. +export const formDataUrlEncoded = rpcOperation() + .input({ + contentType: 'application/x-www-form-urlencoded', + body: formSchema // A zod-form-data schema is required. + }) + .outputs([{ body: formSchema }]) + .handler((formData) => { + return { + text: formData.get('text') + }; + }); -export const { POST } = rpcRoute({ - getTodos, - getTodoById, - createTodo, - deleteTodo +const multipartFormSchema = zfd.formData({ + text: zfd.text(), + file: zfd.file() }); -export type RpcClient = typeof POST.client; +export const formDataMultipart = rpcOperation() + .input({ + contentType: 'multipart/form-data', + body: multipartFormSchema // A zod-form-data schema is required. + }) + .outputs([ + { + body: z.custom(), + // The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec. + bodySchema: { + type: 'string', + format: 'binary' + } + } + ]) + .handler((formData) => { + const file = formData.get('file'); + return file; + }); ``` -Consume the RPC operations directly in your server-side components: +Now you can consume the RPC operations directly in your server-side components: ```typescript 'use server'; @@ -476,7 +584,39 @@ export default async function Page() { } ``` -##### [Pages router RPC route](#pages-router-rpc-api-route): +##### [App router RPC route](#app-router-rpc-route): + +The file path to an RPC route must end with `/[operationId]/route.ts`. Simply import the RPC operations in to your RPC route handler: + +```typescript +// src/app/api/rpc/[operationId]/route.ts + +import { + createTodo, + deleteTodo, + getTodoById, + getTodos, + formDataUrlEncoded, + formDataMultipart +} from 'src/app/actions'; +import { rpcRoute } from 'next-rest-framework'; + +// export const runtime = 'edge'; // Edge runtime is supported. + +export const { POST } = rpcRoute({ + getTodos, + getTodoById, + createTodo, + deleteTodo, + formDataUrlEncoded, + formDataMultipart + // You can also inline the RPC operations in this object if you don't need to use server actions. +}); + +export type RpcClient = typeof POST.client; +``` + +##### [Pages router RPC API route](#pages-router-rpc-api-route): The filename of an RPC API route must be `[operationId].ts`. @@ -484,11 +624,11 @@ The filename of an RPC API route must be `[operationId].ts`. // src/pages/api/rpc/[operationId].ts import { rpcApiRoute } from 'next-rest-framework'; +// import { ... } from 'src/app/actions'; -// Example pages router RPC handler. const handler = rpcApiRoute({ // ... - // Exactly the same as the app router example. You can also inline the RPC operations in this object. + // Exactly the same as the app router example above. }); export default handler; @@ -502,11 +642,11 @@ The RPC routes will also be included in your OpenAPI spec after running `next-re #### [REST client](#rest-client) -To achieve end-to-end type-safety, you can use any client implementation that relies on the generated OpenAPI specification, e.g. [openapi-client-axios](https://github.com/openapistack/openapi-client-axios). +To achieve end-to-end type-safety with your REST endpoints, you can use any client implementation that relies on the generated OpenAPI specification, e.g. [openapi-client-axios](https://github.com/openapistack/openapi-client-axios). #### [RPC client](#rpc-client) -For client-rendered components you can use the strongly-typed `rpcClient`: +While you can consume your RPC operations directly as server actions in your React server components, for client-rendered components you can use the strongly-typed `rpcClient`, passing in the exported type from your RPC endpoint as a generic parameter: ```typescript 'use client'; @@ -572,16 +712,11 @@ The docs config options can be used to customize the generated docs: #### [Route handler options](#route-handler-options) -The following options can be passed to the `routeHandler` (app router) and `apiRouteHandler` (pages router) functions to create new API endpoints: - -| Name | Description | Required | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| `GET \| PUT \| POST \| DELETE \| OPTIONS \| HEAD \| PATCH` | A [Method handler](#method-handlers) object. | `true` | -| `openApiPath` | An OpenAPI [Path Item Object](https://swagger.io/specification/#path-item-object) that can be used to override and extend the auto-generated specification. | `false` | +The `routeHandler` (app router) and `apiRouteHandler` (pages router) functions allow you to pass an object as the second parameter, where you can define a property called `openApiPath`. This property is an OpenAPI [Path Item Object](https://swagger.io/specification/#path-item-object) that can be used to override and extend the auto-generated specification for the given route. #### [Route operations](#route-operations) -The route operation functions `routeOperation` (app router) and `apiRouteOperation` (pages router) allow you to define your API handlers for your endpoints. These functions accept an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) as a parameter, that can be used to override the auto-generated specification. Calling this function allows you to chain your API handler logic with the following functions: +The route operation functions `routeOperation` (app router) and `apiRouteOperation` (pages router) allow you to define your method handlers for your endpoints. These functions require you to pass an object where you will define the method for the given operation, as well as optionally a property called `openApiOperation`. This property is an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) that can be used to override and extend the auto-generated specification for the given operation. Calling the `routeOperation` and `apiRouteOperation` functions allows you to chain your API handler logic with the following functions: | Name | Description | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -594,11 +729,14 @@ The route operation functions `routeOperation` (app router) and `apiRouteOperati The route operation input function is used for type-checking, validation and documentation of the request, taking in an object with the following properties: -| Name | Description | Required | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | -| `contentType` | The content type header of the request. When the content type is defined, a request with an incorrect content type header will get an error response. | `false` | -| `body` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the request body. When the body schema is defined, a request with an invalid request body will get an error response. | `false` | -| `query` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the query parameters. When the query schema is defined, a request with invalid query parameters will get an error response. | `false` | +| Name | Description | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `contentType` | The content type header of the request. When the content type is defined, a request with an incorrect content type header will get an error response. | `false` | +| `body` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the request body. When using `application/x-www-form-urlencoded` or `multipart/form-data` content types, this should be a `zod-form-data` schema instead. When the body schema is defined, a request with an invalid request body will get an error response. The request body is parsed using this schema and updated to the request if valid, so the body should always match the schema. | `false` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `query` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the query parameters. When the query schema is defined, a request with invalid query parameters will get an error response. Query parameters are parsed using this schema and updated to the request if valid, so the query parameters from the request should always match the schema. | `false` | +| `querySchema` | A JSON schema that you can provide in case the conversion of the `query` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `params` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the path parameters for strong typing when using them in your route handler. | `false` | Calling the route operation input function allows you to chain your API handler logic with the [Route operation outputs](#route-operation-outputs), [Route operation middleware](#route-operation-middleware) and [Route operation handler](#route-operation-handler) functions. @@ -606,29 +744,31 @@ Calling the route operation input function allows you to chain your API handler The route operation outputs function is used for type-checking and documentation of the response, taking in an array of objects with the following properties: -| Name | Description | Required | -| ------------- | --------------------------------------------------------------------------------------------- | -------- | -| `status` | A status code that your API can return. | `true` | -| `contentType` | The content type header of the response. | `true` | -| `schema` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the response data. |  `true` | +| Name | Description | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `status` | A status code that your API can return. | `true` | +| `contentType` | The content type header of the response. | `true` | +| `body` | A [Zod](https://github.com/colinhacks/zod) (or `zod-form-data`) schema describing the format of the response data. |  `true` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `name` | An optional name used in the generated OpenAPI spec for the response body, e.g. `GetTodosSuccessResponse`. | `false` | Calling the route operation outputs function allows you to chain your API handler logic with the [Route operation middleware](#route-operation-middleware) and [Route operation handler](#route-operation-handler) functions. ##### [Route operation middleware](#route-operation-middleware) -The route operation middleware function is executed before validating the request input. The function takes in the same parameters as the Next.js [router handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) handlers. Additionally, as a second parameter this function takes the return value of your last middleware function, defaulting to an empty object. Throwing an error inside a middleware function will stop the execution of the handler and you can also return a custom response like you would do within the [Handler](#handler) function. Calling the route operation middleware function allows you to chain your API handler logic with the [Handler](#handler) function. Alternatively, you may chain up to three middleware functions together: +The route operation middleware function is executed before validating the request input. The function takes in the same parameters as the Next.js [router handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) (app router) and [API route](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) (pages router) functions. Additionally, as a second parameter this function takes the return value of your last middleware function, defaulting to an empty object. Throwing an error inside a middleware function will stop the execution of the handler and you can also return a custom response like you would do within the [Route operation handler](#route-operation-handler) function. Calling the route operation middleware function allows you to chain your API handler logic with the [Route operation handler](#route-operation-handler) function. Alternatively, you may chain up to three middleware functions together: ```typescript -// ... -const handler = route({ - getTodos: routeOperation() +// App router. +export const { GET } = route({ + getTodos: routeOperation({ method: 'GET' }) .middleware(() => { return { foo: 'bar' }; }) .middleware((_req, _ctx, { foo }) => { - // if (myCondition) { - // return NextResponse.json({ error: 'My error.' }); - // } + if (myCondition) { + return NextResponse.json({ error: 'My error.' }, { status: 400 }); + } return { foo, @@ -639,34 +779,43 @@ const handler = route({ // ... }) }); -``` - -##### [Route operation handler](#route-operation-handler) -The route operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly-typed versions of the same parameters as the Next.js [router handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) handlers. Additionally, as a third parameter this function takes the return value of your last middleware function: - -```typescript -// ... -const handler = route({ - getTodos: routeOperation() +// Pages router. +export default apiRoute({ + getTodos: routeOperation({ method: 'GET' }) .middleware(() => { - return { foo: "bar" }; + return { foo: 'bar' }; }) - .handler((_req, _ctx, { foo }) => { + .middleware((req, res, { foo }) => { + if (myCondition) { + res.status(400).json({ error: 'My error.' }); + return; + } + + return { + foo, + bar: 'baz' + }; + }) + .handler((req, res, { foo, bar }) => { // ... - }); + }) }); ``` +##### [Route operation handler](#route-operation-handler) + +The route operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly-typed versions of the same parameters as the Next.js [router handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) (app router) and [API route](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) (pages router) functions. Additionally, as a third parameter this function takes the return value of your last middleware function (see above), defaulting to an empty object. + ### RPC #### [RPC route handler options](#rpc-route-handler-options) -The `rpcRouteHandler` (app router) and `rpcApiRouteHandler` (pages router) functions allow you to define your API handlers for your RPC endpoints. These functions accept an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) as a parameter, that can be used to override the auto-generated specification. +The `rpcRouteHandler` (app router) and `rpcApiRouteHandler` (pages router) functions allow you to pass an object as the second parameter, where you can define a property called `openApiPath`. This property is an OpenAPI [Path Item Object](https://swagger.io/specification/#path-item-object) that can be used to override and extend the auto-generated specification for the given route. #### [RPC operations](#rpc-operations) -The `rpcOperation` function allows you to define your API handlers for your RPC endpoint. Calling this function allows you to chain your API handler logic with the following functions. +The `rpcOperation` function allows you to define your API handlers for your RPC endpoint. This function allows you to pass an OpenAPI [Operation object](https://swagger.io/specification/#operation-object) as a parameter, that can be used to override and extend the auto-generated specification for the given operation. Calling this function allows you to chain your API handler logic with the following functions. | Name | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -677,7 +826,13 @@ The `rpcOperation` function allows you to define your API handlers for your RPC ##### [RPC operation input](#rpc-operation-input) -The RPC operation input function is used for type-checking, validation and documentation of the RPC call. It takes in a A [Zod](https://github.com/colinhacks/zod) schema as a parameter that describes the format of the operation input. When the input schema is defined, an RPC call with invalid input will get an error response. +The RPC operation input function is used for type-checking, validation and documentation of the RPC call, taking in an object with the following properties: + +| Name | Description | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `contentType` | The content type header of the request, limited to `application/json`, `application/x-www-form-urlencoded` and `multipart/form-data`. When the content type is defined, a request with an incorrect content type header will get an error response. | `false` | +| `body` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the request body. When using `application/x-www-form-urlencoded` or `multipart/form-data` content types, this should be a `zod-form-data` schema instead. When the body schema is defined, a request with an invalid request body will get an error response. The request body is parsed using this schema and updated to the request if valid, so the body should always match the schema. | `false` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | Calling the RPC input function allows you to chain your API handler logic with the [RPC operation outputs](#rpc-operation-outputs), [RPC middleware](#rpc-operation-middleware) and [RPC handler](#rpc-operation-handler) functions. @@ -685,10 +840,11 @@ Calling the RPC input function allows you to chain your API handler logic with t The RPC operation outputs function is used for type-checking and documentation of the response, taking in an array of objects with the following properties: -| Name | Description | Required | -| -------- | --------------------------------------------------------------------------------------------- | -------- | -| `schema` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the response data. |  `true` | -| `name` | An optional name used in the generated OpenAPI spec, e.g. `GetTodosErrorResponse`. | `false` | +| Name | Description | Required | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `body` | A [Zod](https://github.com/colinhacks/zod) (or `zod-form-data`) schema describing the format of the response data. |  `true` | +| `bodySchema` | A JSON schema that you can provide in case the conversion of the `body` Zod schema fails or produces an incorrect result in your OpenAPI spec. | `false` | +| `name` | An optional name used in the generated OpenAPI spec for the response body, e.g. `GetTodosSuccessResponse`. | `false` | Calling the RPC operation outputs function allows you to chain your API handler logic with the [RPC operation middleware](#rpc-operation-middleware) and [RPC operation handler](#rpc-operation-handler) functions. @@ -697,16 +853,16 @@ Calling the RPC operation outputs function allows you to chain your API handler The RPC operation middleware function is executed before validating RPC operation input. The function takes in strongly typed parameters typed by the [RPC operation input](#rpc-operation-input) function. Additionally, as a second parameter this function takes the return value of your last middleware function, defaulting to an empty object. Throwing an error inside a middleware function will stop the execution of the handler. Calling the RPC operation middleware function allows you to chain your RPC API handler logic with the [RPC operation handler](#rpc-operation-handler) function. Alternatively, you may chain up to three middleware functions together: ```typescript -// ... -const handler = rpcRoute({ +// App router. +export const { POST } = rpcRoute({ getTodos: rpcOperation() .middleware(() => { return { foo: 'bar' }; }) .middleware((_input, { foo }) => { - // if (myCondition) { - // throw Error('My error.') - // } + if (myCondition) { + throw Error('My error.'); + } return { foo, @@ -717,24 +873,16 @@ const handler = rpcRoute({ // ... }) }); + +// Pages router. +export default rpcApiRoute({ + // ... Same as above. +}); ``` ##### [RPC operation handler](#rpc-operation-handler) -The RPC operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly typed parameters typed by the [RPC operation input](#rpc-operation-input) function. Additionally, as a second parameter this function takes the return value of your last middleware function: - -```typescript -// ... -const handler = rpcApiRoute({ - getTodos: rpcOperation() - .middleware(() => { - return { foo: "bar" }; - }) - .handler((_input, { foo }) => { - // ... - }); -}); -``` +The RPC operation handler function is a strongly-typed function to implement the business logic for your API. The function takes in strongly typed parameters typed by the [RPC operation input](#rpc-operation-input) function. Additionally, as a second parameter this function takes the return value of your last middleware function (see above), defaulting to an empty object. ## [CLI](#cli) diff --git a/packages/next-rest-framework/package.json b/packages/next-rest-framework/package.json index 39c8e44..a138d1d 100644 --- a/packages/next-rest-framework/package.json +++ b/packages/next-rest-framework/package.json @@ -39,24 +39,27 @@ "chalk": "4.1.2", "commander": "10.0.1", "esbuild": "0.19.11", + "fast-glob": "3.3.2", + "formidable": "^3.5.1", "lodash": "4.17.21", "prettier": "3.0.2", - "fast-glob": "3.3.2", - "zod-to-json-schema": "3.21.4", - "qs": "6.11.2" + "qs": "6.11.2", + "zod-to-json-schema": "3.21.4" }, "devDependencies": { + "@types/formidable": "^3.4.5", "@types/jest": "29.5.4", "@types/lodash": "4.14.197", "@types/qs": "6.9.11", "jest": "29.6.4", + "next": "*", "node-mocks-http": "1.13.0", "openapi-types": "12.1.3", "ts-jest": "29.1.1", "ts-node": "10.9.1", "tsup": "8.0.1", "typescript": "*", - "next": "*", - "zod": "*" + "zod": "*", + "zod-form-data": "*" } } 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 708f5a2..f0dbcaa 100644 --- a/packages/next-rest-framework/src/app-router/route-operation.ts +++ b/packages/next-rest-framework/src/app-router/route-operation.ts @@ -4,12 +4,17 @@ import { type BaseStatus, type BaseQuery, type OutputObject, - type BaseContentType, type Modify, type AnyCase, type OpenApiOperation, type BaseParams, - type BaseOptions + type BaseOptions, + type TypedFormData, + type AnyContentTypeWithAutocompleteForMostCommonOnes, + type BaseContentType, + type ZodFormSchema, + type FormDataContentType, + type ContentTypesThatSupportInputValidation } from '../types'; import { NextResponse, type NextRequest } from 'next/server'; import { type ZodSchema, type z } from 'zod'; @@ -17,12 +22,25 @@ 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'; +import { type OpenAPIV3_1 } from 'openapi-types'; -export type TypedNextRequest = Modify< +export type TypedNextRequest< + Method = keyof typeof ValidMethod, + ContentType = BaseContentType, + Body = unknown, + Query = BaseQuery +> = Modify< NextRequest, { - json: () => Promise; - method: ValidMethod; + method: Method; + /*! Prevent parsing JSON body for GET requests. Form requests return parsed form data as JSON when the form schema is defined. */ + json: Method extends 'GET' ? never : () => Promise; + /*! Prevent parsing form data for GET and non-form requests. */ + formData: Method extends 'GET' + ? never + : ContentType extends FormDataContentType + ? () => Promise> + : never; nextUrl: Modify< NextURL, { @@ -130,17 +148,18 @@ type RouteMiddleware< OutputOptions extends BaseOptions = BaseOptions, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType, + ResponseContentType extends + AnyContentTypeWithAutocompleteForMostCommonOnes = AnyContentTypeWithAutocompleteForMostCommonOnes, Outputs extends ReadonlyArray< - OutputObject - > = ReadonlyArray>, + OutputObject + > = ReadonlyArray>, TypedResponse = | TypedNextResponseType< - z.infer, + z.infer, Outputs[number]['status'], Outputs[number]['contentType'] > - | NextResponse> + | NextResponse> | void > = ( req: NextRequest, @@ -153,34 +172,50 @@ type RouteMiddleware< | OutputOptions; type TypedRouteHandler< + Method extends keyof typeof ValidMethod = keyof typeof ValidMethod, + ContentType extends BaseContentType = BaseContentType, Body = unknown, Query extends BaseQuery = BaseQuery, Params extends BaseParams = BaseParams, Options extends BaseOptions = BaseOptions, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType, + ResponseContentType extends BaseContentType = BaseContentType, Outputs extends ReadonlyArray< - OutputObject - > = ReadonlyArray>, + OutputObject + > = ReadonlyArray>, TypedResponse = | TypedNextResponseType< - z.infer, + z.infer, Outputs[number]['status'], Outputs[number]['contentType'] > - | NextResponse> + | NextResponse> | void > = ( - req: TypedNextRequest, + req: TypedNextRequest, context: { params: Params }, options: Options ) => Promise | TypedResponse; -interface InputObject { - contentType?: BaseContentType; - body?: ZodSchema; +interface InputObject< + ContentType = BaseContentType, + Body = unknown, + Query = BaseQuery, + Params = BaseParams +> { + contentType?: ContentType; + /*! Body schema is supported only for certain content types that support input validation. */ + body?: ContentType extends ContentTypesThatSupportInputValidation + ? ContentType extends FormDataContentType + ? ZodFormSchema + : ZodSchema + : never; + /*! If defined, this will override the body schema for the OpenAPI spec. */ + bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; query?: ZodSchema; + /*! If defined, this will override the query schema for the OpenAPI spec. */ + querySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; params?: ZodSchema; } @@ -218,7 +253,7 @@ export const routeOperation = ({ middleware1?: RouteMiddleware; middleware2?: RouteMiddleware; middleware3?: RouteMiddleware; - handler?: TypedRouteHandler; + handler?: TypedRouteHandler; }): RouteOperationDefinition => ({ openApiOperation, method, @@ -231,15 +266,20 @@ export const routeOperation = ({ }); return { - input: ( - input: InputObject + input: < + ContentType extends BaseContentType, + Body, + Query extends BaseQuery, + Params extends BaseParams + >( + input: InputObject ) => ({ outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs @@ -250,7 +290,7 @@ export const routeOperation = ({ Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ @@ -260,7 +300,7 @@ export const routeOperation = ({ Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ @@ -270,19 +310,21 @@ export const routeOperation = ({ Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -297,13 +339,15 @@ export const routeOperation = ({ }), handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -317,26 +361,30 @@ export const routeOperation = ({ }), handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ input, outputs, middleware1, handler }) }), handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, BaseOptions, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ input, outputs, handler }) @@ -353,22 +401,24 @@ export const routeOperation = ({ outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs ) => ({ handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -382,28 +432,37 @@ export const routeOperation = ({ }) }), handler: ( - handler: TypedRouteHandler + handler: TypedRouteHandler< + Method, + ContentType, + Body, + Query, + Params, + Options2 + > ) => createOperation({ input, middleware1, middleware2, handler }) }), outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs ) => ({ handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -416,44 +475,62 @@ export const routeOperation = ({ }) }), handler: ( - handler: TypedRouteHandler + handler: TypedRouteHandler< + Method, + ContentType, + Body, + Query, + Params, + Options2 + > ) => createOperation({ input, middleware1, middleware2, handler }) }), outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs ) => ({ handler: ( handler: TypedRouteHandler< + Method, + ContentType, Body, Query, Params, Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ input, outputs, middleware1, handler }) }), - handler: (handler: TypedRouteHandler) => - createOperation({ input, middleware1, handler }) + handler: ( + handler: TypedRouteHandler< + Method, + ContentType, + Body, + Query, + Params, + Options1 + > + ) => createOperation({ input, middleware1, handler }) }), - handler: (handler: TypedRouteHandler) => - createOperation({ input, handler }) + handler: ( + handler: TypedRouteHandler + ) => createOperation({ input, handler }) }), outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs @@ -464,7 +541,7 @@ export const routeOperation = ({ Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ @@ -474,7 +551,7 @@ export const routeOperation = ({ Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ @@ -484,19 +561,21 @@ export const routeOperation = ({ Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ handler: ( handler: TypedRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, BaseParams, Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -510,39 +589,45 @@ export const routeOperation = ({ }), handler: ( handler: TypedRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, BaseParams, Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ outputs, middleware1, middleware2, handler }) }), handler: ( handler: TypedRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, BaseParams, Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ outputs, middleware1, handler }) }), handler: ( handler: TypedRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, BaseParams, BaseOptions, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ outputs, handler }) @@ -557,16 +642,37 @@ export const routeOperation = ({ middleware3: RouteMiddleware ) => ({ handler: ( - handler: TypedRouteHandler + handler: TypedRouteHandler< + Method, + BaseContentType, + unknown, + BaseQuery, + BaseParams, + Options3 + > ) => createOperation({ middleware1, middleware2, middleware3, handler }) }), handler: ( - handler: TypedRouteHandler + handler: TypedRouteHandler< + Method, + BaseContentType, + unknown, + BaseQuery, + BaseParams, + Options2 + > ) => createOperation({ middleware1, middleware2, handler }) }), handler: ( - handler: TypedRouteHandler + handler: TypedRouteHandler< + Method, + BaseContentType, + unknown, + BaseQuery, + BaseParams, + Options1 + > ) => createOperation({ middleware1, handler }) }), handler: (handler: TypedRouteHandler) => createOperation({ handler }) diff --git a/packages/next-rest-framework/src/app-router/route.ts b/packages/next-rest-framework/src/app-router/route.ts index e88a07c..fc9bd9a 100644 --- a/packages/next-rest-framework/src/app-router/route.ts +++ b/packages/next-rest-framework/src/app-router/route.ts @@ -5,6 +5,7 @@ import { validateSchema } from '../shared'; import { logNextRestFrameworkError } from '../shared/logging'; import { getPathsFromRoute } from '../shared/paths'; import { + type FormDataContentType, type BaseOptions, type BaseParams, type OpenApiPathItem @@ -14,6 +15,11 @@ import { type TypedNextRequest } from './route-operation'; +const FORM_DATA_CONTENT_TYPES: FormDataContentType[] = [ + 'multipart/form-data', + 'application/x-www-form-urlencoded' +]; + export const route = >( operations: T, options?: { @@ -43,14 +49,12 @@ export const route = >( const { input, handler, middleware1, middleware2, middleware3 } = operation; + let reqClone = req.clone() as NextRequest; + let middlewareOptions: BaseOptions = {}; if (middleware1) { - const res = await middleware1( - new NextRequest(req.clone()), - context, - middlewareOptions - ); + const res = await middleware1(reqClone, context, middlewareOptions); const isOptionsResponse = (res: unknown): res is BaseOptions => typeof res === 'object'; @@ -62,11 +66,7 @@ export const route = >( } if (middleware2) { - const res2 = await middleware2( - new NextRequest(req.clone()), - context, - middlewareOptions - ); + const res2 = await middleware2(reqClone, context, middlewareOptions); if (res2 instanceof Response) { return res2; @@ -76,7 +76,7 @@ export const route = >( if (middleware3) { const res3 = await middleware3( - new NextRequest(req.clone()), + reqClone, context, middlewareOptions ); @@ -91,12 +91,15 @@ export const route = >( } if (input) { - const { body: bodySchema, query: querySchema, contentType } = input; + const { + body: bodySchema, + query: querySchema, + contentType: contentTypeSchema + } = input; + + const contentType = req.headers.get('content-type')?.split(';')[0]; - if ( - contentType && - req.headers.get('content-type')?.split(';')[0] !== contentType - ) { + if (contentTypeSchema && contentType !== contentTypeSchema) { return NextResponse.json( { message: DEFAULT_ERRORS.invalidMediaType }, { status: 415 } @@ -104,40 +107,101 @@ export const route = >( } if (bodySchema) { - try { - const reqClone = req.clone(); - const body = await reqClone.json(); + if (contentType === 'application/json') { + try { + const json = await req.clone().json(); - const { valid, errors } = await validateSchema({ - schema: bodySchema, - obj: body - }); + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: json + }); - if (!valid) { + if (!valid) { + return NextResponse.json( + { + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }, + { + status: 400 + } + ); + } + + reqClone = new NextRequest(reqClone.url, { + ...reqClone, + method: reqClone.method, + headers: reqClone.headers, + body: JSON.stringify(data) + }); + } catch { return NextResponse.json( { - message: DEFAULT_ERRORS.invalidRequestBody, - errors + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse JSON body.` }, { status: 400 } ); } - } catch (error) { - return NextResponse.json( - { - message: DEFAULT_ERRORS.missingRequestBody - }, - { - status: 400 + } + + if ( + FORM_DATA_CONTENT_TYPES.includes(contentType as FormDataContentType) + ) { + try { + const formData = await req.clone().formData(); + + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: formData + }); + + if (!valid) { + return NextResponse.json( + { + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }, + { + status: 400 + } + ); } - ); + + // Inject parsed for data to JSON body. + reqClone = new NextRequest(reqClone.url, { + ...reqClone, + method: reqClone.method, + headers: reqClone.headers, + body: JSON.stringify(data) + }); + + // Return parsed form data. + reqClone.formData = async () => { + const formData = new FormData(); + + for (const [key, value] of Object.entries(data)) { + formData.append(key, value as string | Blob); + } + + return formData; + }; + } catch { + return NextResponse.json( + { + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` + }, + { + status: 400 + } + ); + } } } if (querySchema) { - const { valid, errors } = await validateSchema({ + const { valid, errors, data } = validateSchema({ schema: querySchema, obj: qs.parse(req.nextUrl.search, { ignoreQueryPrefix: true }) }); @@ -153,11 +217,28 @@ export const route = >( } ); } + + const url = new URL(reqClone.url); + + // Update the query parameters + url.searchParams.forEach((_value, key) => { + url.searchParams.delete(key); + + if (data[key]) { + url.searchParams.append(key, data[key]); + } + }); + + reqClone = new NextRequest(url, { + ...reqClone, + method: reqClone.method, + headers: reqClone.headers + }); } } const res = await handler?.( - req as TypedNextRequest, + reqClone as TypedNextRequest, context, middlewareOptions ); diff --git a/packages/next-rest-framework/src/app-router/rpc-route.ts b/packages/next-rest-framework/src/app-router/rpc-route.ts index 2ec836d..92ab62d 100644 --- a/packages/next-rest-framework/src/app-router/rpc-route.ts +++ b/packages/next-rest-framework/src/app-router/rpc-route.ts @@ -1,11 +1,16 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { DEFAULT_ERRORS, ValidMethod } from '../constants'; +import { + DEFAULT_ERRORS, + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION, + ValidMethod +} from '../constants'; import { validateSchema, logNextRestFrameworkError, type RpcOperationDefinition } from '../shared'; import { + type FormDataContentType, type BaseOptions, type BaseParams, type OpenApiPathItem @@ -14,7 +19,7 @@ import { type RpcClient } from '../client/rpc-client'; import { getPathsFromRpcRoute } from '../shared/paths'; export const rpcRoute = < - T extends Record> + T extends Record> >( operations: T, options?: { @@ -52,19 +57,36 @@ export const rpcRoute = < const { input, handler, middleware1, middleware2, middleware3 } = operation._meta; - const parseRequestBody = async (req: NextRequest) => { - try { - return await req.clone().json(); - } catch { - return {}; + const contentType = req.headers.get('content-type')?.split(';')[0]; + + const parseRequestBody = async () => { + if (contentType === 'application/json') { + try { + return await req.clone().json(); + } catch { + return {}; + } + } + + if ( + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION.includes( + contentType as FormDataContentType + ) + ) { + try { + return await req.clone().formData(); + } catch { + return {}; + } } + + return {}; }; + let body = await parseRequestBody(); let middlewareOptions: BaseOptions = {}; if (middleware1) { - const body = await parseRequestBody(req); - middlewareOptions = await middleware1(body, middlewareOptions); if (middleware2) { @@ -77,48 +99,92 @@ export const rpcRoute = < } if (input) { - if ( - req.headers.get('content-type')?.split(';')[0] !== 'application/json' - ) { + const { contentType: contentTypeSchema, body: bodySchema } = input; + + if (contentTypeSchema && contentType !== contentTypeSchema) { return NextResponse.json( { message: DEFAULT_ERRORS.invalidMediaType }, { status: 400 } ); } - 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 + if (bodySchema) { + if (contentType === 'application/json') { + try { + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + 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 + + body = data; + } catch { + return NextResponse.json( + { + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse JSON body.` + }, + { + status: 400 + } + ); } - ); + + if ( + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION.includes( + contentType as FormDataContentType + ) + ) { + try { + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: body + }); + + if (!valid) { + return NextResponse.json( + { + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }, + { + status: 400 + } + ); + } + + const formData = new FormData(); + + for (const [key, value] of Object.entries(data)) { + formData.append(key, value as string | Blob); + } + + body = formData; + } catch { + return NextResponse.json( + { + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` + }, + { + status: 400 + } + ); + } + } + } } } - const body = await parseRequestBody(req); const res = await handler?.(body, middlewareOptions); if (!res) { @@ -128,7 +194,26 @@ export const rpcRoute = < ); } - return NextResponse.json(res, { status: 200 }); + const parseRes = (res: unknown): BodyInit => { + if ( + res instanceof ReadableStream || + res instanceof ArrayBuffer || + res instanceof Blob || + res instanceof FormData || + res instanceof URLSearchParams + ) { + return res; + } + + return JSON.stringify(res); + }; + + return new NextResponse(parseRes(res), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); } catch (error) { logNextRestFrameworkError(error); diff --git a/packages/next-rest-framework/src/client/rpc-client.ts b/packages/next-rest-framework/src/client/rpc-client.ts index 22299d8..1a734cc 100644 --- a/packages/next-rest-framework/src/client/rpc-client.ts +++ b/packages/next-rest-framework/src/client/rpc-client.ts @@ -3,7 +3,7 @@ import { type RpcOperationDefinition } from '../shared'; type RpcRequestInit = Omit; export type RpcClient< - T extends Record> + T extends Record> > = { [key in keyof T]: T[key] & { _meta: never }; }; @@ -41,7 +41,7 @@ const fetcher = async ({ }; export const rpcClient = < - T extends Record> + T extends Record> >({ url: _url, init diff --git a/packages/next-rest-framework/src/constants.ts b/packages/next-rest-framework/src/constants.ts index 144790a..7baf818 100644 --- a/packages/next-rest-framework/src/constants.ts +++ b/packages/next-rest-framework/src/constants.ts @@ -1,3 +1,5 @@ +import { type FormDataContentType } from './types'; + export const DEFAULT_ERRORS = { unexpectedError: 'An unknown error occurred, trying again might help.', methodNotAllowed: 'Method not allowed.', @@ -39,3 +41,6 @@ export const DEFAULT_FAVICON_URL = export const DEFAULT_LOGO_URL = 'https://raw.githubusercontent.com/blomqma/next-rest-framework/d02224b38d07ede85257b22ed50159a947681f99/packages/next-rest-framework/logo.svg'; + +export const FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION: FormDataContentType[] = + ['multipart/form-data', 'application/x-www-form-urlencoded']; 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 142fa94..b36e9ff 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,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-invalid-void-type */ +import { type OpenAPIV3_1 } from 'openapi-types'; import { type ValidMethod } from '../constants'; import { type OutputObject, @@ -9,15 +10,34 @@ import { type BaseStatus, type BaseContentType, type OpenApiOperation, - type BaseOptions + type BaseOptions, + type TypedFormData, + type ZodFormSchema, + type ContentTypesThatSupportInputValidation, + type FormDataContentType } from '../types'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; import { type ZodSchema, type z } from 'zod'; -export type TypedNextApiRequest = Modify< +export type TypedNextApiRequest< + Method = keyof typeof ValidMethod, + ContentType = BaseContentType, + Body = unknown, + Query = BaseQuery +> = Modify< NextApiRequest, { - body: Body; + /*! + * For GET requests, attempting to parse a JSON body gives a type error. + * application/json requests are typed with a strongly-typed JSON body. + * application/x-www-form-urlencoded and multipart/form-data requests are + * typed with a strongly-typed form data object. + */ + body: Method extends 'GET' + ? never + : ContentType extends FormDataContentType + ? TypedFormData + : never; query: Query; method: ValidMethod; } @@ -59,19 +79,21 @@ type TypedNextApiResponse = Modify< >; type TypedApiRouteHandler< + Method extends keyof typeof ValidMethod = keyof typeof ValidMethod, + ContentType extends BaseContentType = BaseContentType, Body = unknown, Query extends BaseQuery = BaseQuery, Options extends BaseOptions = BaseOptions, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType, + ResponseContentType extends BaseContentType = BaseContentType, Outputs extends ReadonlyArray< - OutputObject - > = ReadonlyArray> + OutputObject + > = ReadonlyArray> > = ( - req: TypedNextApiRequest, + req: TypedNextApiRequest, res: TypedNextApiResponse< - z.infer, + z.infer, Outputs[number]['status'], Outputs[number]['contentType'] >, @@ -83,24 +105,40 @@ type ApiRouteMiddleware< OutputOptions extends BaseOptions = BaseOptions, ResponseBody = unknown, Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType, + ResponseContentType extends BaseContentType = BaseContentType, Outputs extends ReadonlyArray< - OutputObject - > = ReadonlyArray> + OutputObject + > = ReadonlyArray> > = ( req: NextApiRequest, res: TypedNextApiResponse< - z.infer, + z.infer, Outputs[number]['status'], Outputs[number]['contentType'] >, options: InputOptions ) => Promise | void | Promise | OutputOptions; -interface InputObject { - contentType?: BaseContentType; - body?: ZodSchema; +interface InputObject< + ContentType = BaseContentType, + Body = unknown, + Query = BaseQuery +> { + contentType?: ContentType; + /*! + * Body schema is supported only for certain content types that support input validation. + * multipart/form-data validation is also supported with app router. + */ + body?: ContentType extends ContentTypesThatSupportInputValidation + ? ContentType extends FormDataContentType + ? ZodFormSchema + : ZodSchema + : never; + /*! If defined, this will override the body schema for the OpenAPI spec. */ + bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; query?: ZodSchema; + /*! If defined, this will override the query schema for the OpenAPI spec. */ + querySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; } export interface ApiRouteOperationDefinition< @@ -137,7 +175,7 @@ export const apiRouteOperation = ({ middleware1?: ApiRouteMiddleware; middleware2?: ApiRouteMiddleware; middleware3?: ApiRouteMiddleware; - handler?: TypedApiRouteHandler; + handler?: TypedApiRouteHandler; }): ApiRouteOperationDefinition => ({ openApiOperation, method, @@ -150,15 +188,15 @@ export const apiRouteOperation = ({ }); return { - input: ( - input: InputObject + input: ( + input: InputObject ) => ({ outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs @@ -169,7 +207,7 @@ export const apiRouteOperation = ({ Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ @@ -179,7 +217,7 @@ export const apiRouteOperation = ({ Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ @@ -189,18 +227,20 @@ export const apiRouteOperation = ({ Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => ({ handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -215,12 +255,14 @@ export const apiRouteOperation = ({ }), handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -234,24 +276,28 @@ export const apiRouteOperation = ({ }), handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ input, outputs, middleware1, handler }) }), handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, BaseOptions, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ input, outputs, handler }) @@ -268,21 +314,23 @@ export const apiRouteOperation = ({ outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs ) => ({ handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -295,7 +343,15 @@ export const apiRouteOperation = ({ handler }) }), - handler: (handler: TypedApiRouteHandler) => + handler: ( + handler: TypedApiRouteHandler< + Method, + ContentType, + Body, + Query, + Options3 + > + ) => createOperation({ input, middleware1, @@ -307,21 +363,23 @@ export const apiRouteOperation = ({ outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs ) => ({ handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -333,43 +391,60 @@ export const apiRouteOperation = ({ handler }) }), - handler: (handler: TypedApiRouteHandler) => - createOperation({ input, middleware1, middleware2, handler }) + handler: ( + handler: TypedApiRouteHandler< + Method, + ContentType, + Body, + Query, + Options2 + > + ) => createOperation({ input, middleware1, middleware2, handler }) }), outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs ) => ({ handler: ( handler: TypedApiRouteHandler< + Method, + ContentType, Body, Query, Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ input, outputs, middleware1, handler }) }), - handler: (handler: TypedApiRouteHandler) => - createOperation({ input, middleware1, handler }) + handler: ( + handler: TypedApiRouteHandler< + Method, + ContentType, + Body, + Query, + Options1 + > + ) => createOperation({ input, middleware1, handler }) }), - handler: (handler: TypedApiRouteHandler) => - createOperation({ input, handler }) + handler: ( + handler: TypedApiRouteHandler + ) => createOperation({ input, handler }) }), outputs: < ResponseBody, Status extends BaseStatus, - ContentType extends BaseContentType, + ResponseContentType extends BaseContentType, Outputs extends ReadonlyArray< - OutputObject + OutputObject > >( outputs: Outputs @@ -385,12 +460,14 @@ export const apiRouteOperation = ({ ) => ({ handler: ( handler: TypedApiRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, Options3, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => @@ -404,36 +481,42 @@ export const apiRouteOperation = ({ }), handler: ( handler: TypedApiRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, Options2, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ outputs, middleware1, middleware2, handler }) }), handler: ( handler: TypedApiRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, Options1, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ outputs, middleware1, handler }) }), handler: ( handler: TypedApiRouteHandler< + Method, + BaseContentType, unknown, BaseQuery, BaseOptions, ResponseBody, Status, - ContentType, + ResponseContentType, Outputs > ) => createOperation({ outputs, handler }) @@ -448,16 +531,35 @@ export const apiRouteOperation = ({ middleware3: ApiRouteMiddleware ) => ({ handler: ( - handler: TypedApiRouteHandler + handler: TypedApiRouteHandler< + Method, + BaseContentType, + unknown, + BaseQuery, + Options3 + > ) => createOperation({ middleware1, middleware2, middleware3, handler }) }), handler: ( - handler: TypedApiRouteHandler + handler: TypedApiRouteHandler< + Method, + BaseContentType, + unknown, + BaseQuery, + Options2 + > ) => createOperation({ middleware1, middleware2, handler }) }), - handler: (handler: TypedApiRouteHandler) => - createOperation({ middleware1, handler }) + handler: ( + handler: TypedApiRouteHandler< + Method, + BaseContentType, + unknown, + BaseQuery, + Options1 + > + ) => createOperation({ middleware1, handler }) }), handler: (handler: TypedApiRouteHandler) => createOperation({ handler }) }; diff --git a/packages/next-rest-framework/src/pages-router/api-route.ts b/packages/next-rest-framework/src/pages-router/api-route.ts index b71719b..7903783 100644 --- a/packages/next-rest-framework/src/pages-router/api-route.ts +++ b/packages/next-rest-framework/src/pages-router/api-route.ts @@ -1,11 +1,18 @@ -import { DEFAULT_ERRORS } from '../constants'; +import { + DEFAULT_ERRORS, + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION +} from '../constants'; import { validateSchema, logNextRestFrameworkError, logPagesEdgeRuntimeErrorForRoute } from '../shared'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; -import { type BaseOptions, type OpenApiPathItem } from '../types'; +import { + type FormDataContentType, + type BaseOptions, + type OpenApiPathItem +} from '../types'; import { type TypedNextApiRequest, type ApiRouteOperationDefinition @@ -89,34 +96,101 @@ export const apiRoute = >( } if (input) { - const { body: bodySchema, query: querySchema, contentType } = input; + const { + body: bodySchema, + query: querySchema, + contentType: contentTypeSchema + } = input; + + const contentType = req.headers['content-type']?.split(';')[0]; + + if (contentTypeSchema && contentType !== contentTypeSchema) { + res.status(415).json({ + message: `${DEFAULT_ERRORS.invalidMediaType} Expected ${contentTypeSchema}.` + }); - if ( - contentType && - req.headers['content-type']?.split(';')[0] !== contentType - ) { - res.status(415).json({ message: DEFAULT_ERRORS.invalidMediaType }); return; } if (bodySchema) { - const { valid, errors } = await validateSchema({ - schema: bodySchema, - obj: req.body - }); - - if (!valid) { - res.status(400).json({ - message: DEFAULT_ERRORS.invalidRequestBody, - errors + if (contentType === 'application/json') { + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: req.body }); - return; + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); + + return; + } + + req.body = data; + } + + if ( + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION.includes( + contentType as FormDataContentType + ) + ) { + if ( + contentType === 'multipart/form-data' && + !(req.body instanceof FormData) && + typeof EdgeRuntime !== 'string' + ) { + const { parseMultiPartFormData } = await import( + '../shared/form-data' + ); + + // Parse multipart/form-data into a FormData object. + try { + req.body = await parseMultiPartFormData(req); + } catch (e) { + res.status(400).json({ + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` + }); + + return; + } + } + + try { + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: req.body + }); + + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); + + return; + } + + const formData = new FormData(); + + Object.entries(data).forEach(([key, value]) => { + formData.append(key, value as string | Blob); + }); + + req.body = formData; + } catch { + res.status(400).json({ + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` + }); + + return; + } } } if (querySchema) { - const { valid, errors } = await validateSchema({ + const { valid, errors, data } = validateSchema({ schema: querySchema, obj: req.query }); @@ -129,6 +203,8 @@ export const apiRoute = >( return; } + + req.query = data; } } diff --git a/packages/next-rest-framework/src/pages-router/rpc-api-route.ts b/packages/next-rest-framework/src/pages-router/rpc-api-route.ts index f769efa..f64389f 100644 --- a/packages/next-rest-framework/src/pages-router/rpc-api-route.ts +++ b/packages/next-rest-framework/src/pages-router/rpc-api-route.ts @@ -1,4 +1,8 @@ -import { DEFAULT_ERRORS, ValidMethod } from '../constants'; +import { + DEFAULT_ERRORS, + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION, + ValidMethod +} from '../constants'; import { validateSchema, logNextRestFrameworkError, @@ -7,8 +11,8 @@ import { } from '../shared'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; import { + type FormDataContentType, type BaseOptions, - type OpenApiOperation, type OpenApiPathItem } from '../types'; import { type RpcClient } from '../client/rpc-client'; @@ -16,12 +20,11 @@ import { type NextRequest } from 'next/server'; import { getPathsFromRpcRoute } from '../shared/paths'; export const rpcApiRoute = < - T extends Record> + T extends Record> >( operations: T, options?: { openApiPath?: OpenApiPathItem; - openApiOperation?: OpenApiOperation; } ) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -70,30 +73,89 @@ export const rpcApiRoute = < } if (input) { - if (req.headers['content-type']?.split(';')[0] !== 'application/json') { + const { body: bodySchema, contentType: contentTypeSchema } = input; + const contentType = req.headers['content-type']?.split(';')[0]; + + if (contentType !== contentTypeSchema) { res.status(400).json({ message: DEFAULT_ERRORS.invalidMediaType }); + return; } - try { - const { valid, errors } = await validateSchema({ - schema: input, - obj: req.body - }); - - if (!valid) { - res.status(400).json({ - message: DEFAULT_ERRORS.invalidRequestBody, - errors + if (bodySchema) { + if (contentType === 'application/json') { + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: req.body }); - return; + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); + + return; + } + + req.body = data; } - } catch (error) { - res.status(400).json({ - message: DEFAULT_ERRORS.missingRequestBody - }); - return; + if ( + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION.includes( + contentType as FormDataContentType + ) + ) { + if ( + contentType === 'multipart/form-data' && + !(req.body instanceof FormData) && + typeof EdgeRuntime !== 'string' + ) { + const { parseMultiPartFormData } = await import( + '../shared/form-data' + ); + + // Parse multipart/form-data into a FormData object. + try { + req.body = await parseMultiPartFormData(req); + } catch (e) { + res.status(400).json({ + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` + }); + + return; + } + } + + try { + const { valid, errors, data } = validateSchema({ + schema: bodySchema, + obj: req.body + }); + + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); + + return; + } + + const formData = new FormData(); + + Object.entries(data).forEach(([key, value]) => { + formData.append(key, value as string | Blob); + }); + + req.body = formData; + } catch { + res.status(400).json({ + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` + }); + + return; + } + } } } diff --git a/packages/next-rest-framework/src/shared/form-data.ts b/packages/next-rest-framework/src/shared/form-data.ts new file mode 100644 index 0000000..a7682c9 --- /dev/null +++ b/packages/next-rest-framework/src/shared/form-data.ts @@ -0,0 +1,39 @@ +import { type NextApiRequest } from 'next/types'; +import { Formidable } from 'formidable'; +import { readFileSync } from 'fs'; + +export const parseMultiPartFormData = async (req: NextApiRequest) => + await new Promise((resolve, reject) => { + const form = new Formidable(); + + form.parse(req, (err, fields, files) => { + if (err) { + reject(err); + return; + } + + const formData = new FormData(); + + Object.entries(fields).forEach(([key, value]) => { + if (value?.[0]) { + formData.append(key, value[0]); + } + }); + + Object.entries(files).forEach(([key, fileArray]) => { + if (fileArray && fileArray.length > 0) { + fileArray.forEach((file) => { + const fileContent = readFileSync(file.filepath); + + const blob = new Blob([fileContent], { + type: file.mimetype ?? '' + }); + + formData.append(key, blob, file.originalFilename ?? ''); + }); + } + }); + + resolve(formData); + }); + }); diff --git a/packages/next-rest-framework/src/shared/paths.ts b/packages/next-rest-framework/src/shared/paths.ts index df51d9f..cf7b704 100644 --- a/packages/next-rest-framework/src/shared/paths.ts +++ b/packages/next-rest-framework/src/shared/paths.ts @@ -10,6 +10,10 @@ import { type RouteOperationDefinition } from '../app-router'; import { type RpcOperationDefinition } from './rpc-operation'; import { capitalizeFirstLetter, isValidMethod } from './utils'; +const isSchemaRef = ( + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject +): schema is OpenAPIV3_1.ReferenceObject => '$ref' in schema; + export interface NrfOasData { paths?: OpenAPIV3_1.PathsObject; schemas?: Record; @@ -62,19 +66,31 @@ export const getPathsFromRoute = ({ if (input?.body && input?.contentType) { const key = `${capitalizeFirstLetter(operationId)}RequestBody`; - const schema = getJsonSchema({ schema: input.body }); + const schema = + input.bodySchema ?? + getJsonSchema({ + schema: input.body, + operationId, + type: 'input-body' + }); - requestBodySchemas[method] = { - key, - ref: `#/components/schemas/${key}`, - schema - }; + const ref = isSchemaRef(schema) + ? schema.$ref + : `#/components/schemas/${key}`; + + if (!isSchemaRef(schema)) { + requestBodySchemas[method] = { + key, + ref, + schema + }; + } generatedOperationObject.requestBody = { content: { [input.contentType]: { schema: { - $ref: `#/components/schemas/${key}` + $ref: ref } } } @@ -84,7 +100,7 @@ export const getPathsFromRoute = ({ const usedStatusCodes: number[] = []; generatedOperationObject.responses = outputs?.reduce( - (obj, { status, contentType, schema, name }) => { + (obj, { status, contentType, body, bodySchema, name }) => { const occurrenceOfStatusCode = usedStatusCodes.includes(status) ? usedStatusCodes.filter((s) => s === status).length + 1 : ''; @@ -97,14 +113,28 @@ export const getPathsFromRoute = ({ usedStatusCodes.push(status); - responseBodySchemas[method] = [ - ...(responseBodySchemas[method] ?? []), - { - key, - ref: `#/components/schemas/${key}`, - schema: getJsonSchema({ schema }) - } - ]; + const schema = + bodySchema ?? + getJsonSchema({ + schema: body, + operationId, + type: 'output-body' + }); + + const ref = isSchemaRef(schema) + ? schema.$ref + : `#/components/schemas/${key}`; + + if (!isSchemaRef(schema)) { + responseBodySchemas[method] = [ + ...(responseBodySchemas[method] ?? []), + { + key, + ref, + schema + } + ]; + } return Object.assign(obj, { [status]: { @@ -112,7 +142,7 @@ export const getPathsFromRoute = ({ content: { [contentType]: { schema: { - $ref: `#/components/schemas/${key}` + $ref: ref } } } @@ -147,11 +177,18 @@ export const getPathsFromRoute = ({ } if (input?.query) { + const schema = + input.querySchema ?? + getJsonSchema({ + schema: input.query, + operationId, + type: 'input-query' + }).properties ?? + {}; + generatedOperationObject.parameters = [ ...(generatedOperationObject.parameters ?? []), - ...Object.entries( - getJsonSchema({ schema: input.query }).properties ?? {} - ) + ...Object.entries(schema) // Filter out query parameters that have already been added to the path parameters automatically. .filter(([name]) => !pathParameters?.includes(name)) .map(([name, schema]) => { @@ -216,7 +253,7 @@ export const getPathsFromRpcRoute = ({ options, route: _route }: { - operations: Record>; + operations: Record>; options?: { openApiPath?: OpenApiPathItem; openApiOperation?: OpenApiOperation; @@ -260,19 +297,32 @@ export const getPathsFromRpcRoute = ({ operationId }; - if (input) { + if (input?.body && input.contentType) { const key = `${capitalizeFirstLetter(operationId)}RequestBody`; - const ref = `#/components/schemas/${key}`; - requestBodySchemas[operationId] = { - key, - ref, - schema: getJsonSchema({ schema: input }) - }; + const schema = + input.bodySchema ?? + getJsonSchema({ + schema: input.body, + operationId, + type: 'input-body' + }); + + const ref = isSchemaRef(schema) + ? schema.$ref + : `#/components/schemas/${key}`; + + if (!isSchemaRef(schema)) { + requestBodySchemas[operationId] = { + key, + ref, + schema + }; + } generatedOperationObject.requestBody = { content: { - 'application/json': { + [input.contentType]: { schema: { $ref: ref } @@ -282,29 +332,43 @@ export const getPathsFromRpcRoute = ({ } generatedOperationObject.responses = outputs?.reduce( - (obj, { schema, name }, i) => { + (obj, { body, bodySchema, contentType, name }, i) => { const key = name ?? `${capitalizeFirstLetter(operationId)}ResponseBody${ i > 0 ? i + 1 : '' }`; - responseBodySchemas[operationId] = [ - ...(responseBodySchemas[operationId] ?? []), - { - key, - ref: `#/components/schemas/${key}`, - schema: getJsonSchema({ schema }) - } - ]; + const schema = + bodySchema ?? + getJsonSchema({ + schema: body, + operationId, + type: 'output-body' + }); + + const ref = isSchemaRef(schema) + ? schema.$ref + : `#/components/schemas/${key}`; + + if (!isSchemaRef(schema)) { + responseBodySchemas[operationId] = [ + ...(responseBodySchemas[operationId] ?? []), + { + key, + ref, + schema + } + ]; + } return Object.assign(obj, { 200: { description: key, content: { - 'application/json': { + [contentType]: { schema: { - $ref: `#/components/schemas/${key}` + $ref: ref } } } diff --git a/packages/next-rest-framework/src/shared/rpc-operation.ts b/packages/next-rest-framework/src/shared/rpc-operation.ts index 1487ac1..e04d1c2 100644 --- a/packages/next-rest-framework/src/shared/rpc-operation.ts +++ b/packages/next-rest-framework/src/shared/rpc-operation.ts @@ -3,11 +3,34 @@ import { type z, type ZodSchema } from 'zod'; import { validateSchema } from './schemas'; import { DEFAULT_ERRORS } from '../constants'; -import { type BaseOptions, type OpenApiOperation } from '../types'; +import { + type ZodFormSchema, + type BaseOptions, + type OpenApiOperation, + type TypedFormData, + type FormDataContentType +} from '../types'; +import { type OpenAPIV3_1 } from 'openapi-types'; +type BaseContentType = + | 'application/json' + | 'application/x-www-form-urlencoded' + | 'multipart/form-data'; + +interface InputObject { + contentType?: ContentType; + body?: ContentType extends FormDataContentType + ? ZodFormSchema + : ZodSchema; + /*! If defined, this will override the body schema for the OpenAPI spec. */ + bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; +} interface OutputObject { - schema: ZodSchema; - name?: string; + body: ZodSchema; + /*! If defined, this will override the body schema for the OpenAPI spec. */ + bodySchema?: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject; + contentType: BaseContentType; + name?: string /*! A custom name for the response, used for the generated component name in the OpenAPI spec. */; } type RpcMiddleware< @@ -19,19 +42,22 @@ type RpcMiddleware< ) => Promise | OutputOptions | Promise | void; type RpcOperationHandler< - Input = unknown, + ContentType extends BaseContentType = BaseContentType, + Body = unknown, Options extends BaseOptions = BaseOptions, Outputs extends readonly OutputObject[] = readonly OutputObject[] > = ( - params: z.infer>, + params: ContentType extends FormDataContentType + ? TypedFormData>> + : z.infer>, options: Options ) => - | Promise> - | z.infer; + | Promise> + | z.infer; interface OperationDefinitionMeta { openApiOperation?: OpenApiOperation; - input?: ZodSchema; + input?: InputObject; outputs?: readonly OutputObject[]; middleware1?: RpcOperationHandler; middleware2?: RpcOperationHandler; @@ -40,12 +66,17 @@ interface OperationDefinitionMeta { } export type RpcOperationDefinition< - Input = unknown, + ContentType extends BaseContentType = BaseContentType, + Body = unknown, Outputs extends readonly OutputObject[] = readonly OutputObject[], HasInput extends boolean = false, - TypedResponse = Promise> + TypedResponse = Promise> > = (HasInput extends true - ? (body: z.infer>) => TypedResponse + ? ( + body: ContentType extends FormDataContentType + ? FormData + : z.infer> + ) => TypedResponse : () => TypedResponse) & { _meta: OperationDefinitionMeta; }; @@ -53,26 +84,31 @@ export type RpcOperationDefinition< // Build function chain for creating an RPC operation. export const rpcOperation = (openApiOperation?: OpenApiOperation) => { function createOperation< - Input, + ContentType extends BaseContentType, + Body, Outputs extends readonly OutputObject[] >(_params: { - input: ZodSchema; + input: InputObject; outputs?: Outputs; middleware1?: RpcMiddleware; middleware2?: RpcMiddleware; middleware3?: RpcMiddleware; - handler?: RpcOperationHandler; - }): RpcOperationDefinition; + handler?: RpcOperationHandler; + }): RpcOperationDefinition; function createOperation(_params: { outputs?: Outputs; middleware1?: RpcMiddleware; middleware2?: RpcMiddleware; middleware3?: RpcMiddleware; - handler?: RpcOperationHandler; - }): RpcOperationDefinition; + handler?: RpcOperationHandler; + }): RpcOperationDefinition; - function createOperation({ + function createOperation< + ContentType extends BaseContentType, + Body, + Outputs extends readonly OutputObject[] + >({ input, outputs, middleware1, @@ -80,13 +116,13 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { middleware3, handler }: { - input?: ZodSchema; + input?: InputObject; outputs?: Outputs; middleware1?: RpcMiddleware; middleware2?: RpcMiddleware; middleware3?: RpcMiddleware; - handler?: RpcOperationHandler; - }): RpcOperationDefinition { + handler?: RpcOperationHandler; + }): RpcOperationDefinition { const callOperation = async (body?: unknown) => { let middlewareOptions: BaseOptions = {}; @@ -102,9 +138,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { } } - if (input) { - const { valid, errors } = await validateSchema({ - schema: input, + if (input?.body) { + const { valid, errors } = validateSchema({ + schema: input.body, obj: body }); @@ -117,7 +153,12 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { throw Error('Handler not found.'); } - const res = await handler(body as Input, middlewareOptions); + const res = await handler( + body as ContentType extends FormDataContentType + ? TypedFormData + : Body, + middlewareOptions + ); return res; }; @@ -131,21 +172,36 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }; - if (input === undefined) { + if (input?.body === undefined) { const operation = async () => await callOperation(); operation._meta = meta; - return operation as RpcOperationDefinition; + return operation as RpcOperationDefinition< + ContentType, + unknown, + Outputs, + false + >; } else { - const operation = async (body: z.infer) => - await callOperation(body); + const operation = async ( + body: ContentType extends FormDataContentType + ? FormData + : z.infer> + ) => await callOperation(body); operation._meta = meta; - return operation as RpcOperationDefinition; + return operation as RpcOperationDefinition< + ContentType, + Body, + Outputs, + true + >; } } return { - input: (input: ZodSchema) => ({ + input: ( + input: InputObject + ) => ({ outputs: (outputs: Output) => ({ middleware: ( middleware1: RpcMiddleware @@ -157,7 +213,12 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { middleware3: RpcMiddleware ) => ({ handler: ( - handler: RpcOperationHandler + handler: RpcOperationHandler< + ContentType, + Body, + Options3, + Output + > ) => createOperation({ input, @@ -168,7 +229,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, outputs, @@ -177,7 +240,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, outputs, @@ -185,7 +250,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, outputs, @@ -205,7 +272,12 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { outputs: Output ) => ({ handler: ( - handler: RpcOperationHandler + handler: RpcOperationHandler< + ContentType, + Body, + Options3, + Output + > ) => createOperation({ input, @@ -216,7 +288,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, middleware1, @@ -228,7 +302,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { outputs: ( outputs: Output ) => ({ - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, outputs, @@ -237,7 +313,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, middleware1, @@ -246,7 +324,9 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { }) }), outputs: (outputs: Output) => ({ - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ input, outputs, @@ -254,14 +334,14 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: (handler: RpcOperationHandler) => createOperation({ input, middleware1, handler }) }), - handler: (handler: RpcOperationHandler) => + handler: (handler: RpcOperationHandler) => createOperation({ input, handler @@ -278,7 +358,12 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { middleware3: RpcMiddleware ) => ({ handler: ( - handler: RpcOperationHandler + handler: RpcOperationHandler< + BaseContentType, + unknown, + Options3, + Output + > ) => createOperation({ outputs, @@ -288,7 +373,14 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler< + BaseContentType, + unknown, + Options2, + Output + > + ) => createOperation({ outputs, middleware1, @@ -296,14 +388,28 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler< + BaseContentType, + unknown, + Options1, + Output + > + ) => createOperation({ outputs, middleware1, handler }) }), - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler< + BaseContentType, + unknown, + BaseOptions, + Output + > + ) => createOperation({ outputs, handler @@ -318,14 +424,18 @@ export const rpcOperation = (openApiOperation?: OpenApiOperation) => { middleware: ( middleware3: RpcMiddleware ) => ({ - handler: (handler: RpcOperationHandler) => + handler: ( + handler: RpcOperationHandler + ) => createOperation({ middleware1, middleware2, middleware3, handler }) }), - handler: (handler: RpcOperationHandler) => - createOperation({ middleware1, middleware2, handler }) + handler: ( + handler: RpcOperationHandler + ) => createOperation({ middleware1, middleware2, handler }) }), - handler: (handler: RpcOperationHandler) => - createOperation({ middleware1, handler }) + handler: ( + handler: RpcOperationHandler + ) => createOperation({ middleware1, handler }) }), handler: (handler: RpcOperationHandler) => createOperation({ handler }) }; diff --git a/packages/next-rest-framework/src/shared/schemas.ts b/packages/next-rest-framework/src/shared/schemas.ts index b83565f..fa9f116 100644 --- a/packages/next-rest-framework/src/shared/schemas.ts +++ b/packages/next-rest-framework/src/shared/schemas.ts @@ -1,6 +1,8 @@ import { type OpenAPIV3_1 } from 'openapi-types'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { type AnyZodObject, type ZodSchema } from 'zod'; +import { type zfd } from 'zod-form-data'; +import chalk from 'chalk'; const isZodSchema = (schema: unknown): schema is ZodSchema => !!schema && typeof schema === 'object' && '_def' in schema; @@ -20,15 +22,16 @@ const zodSchemaValidator = ({ return { valid: data.success, - errors + errors, + data: data.success ? data.data : null }; }; -export const validateSchema = async ({ +export const validateSchema = ({ schema, obj }: { - schema: ZodSchema; + schema: ZodSchema | typeof zfd.formData; obj: unknown; }) => { if (isZodSchema(schema)) { @@ -38,16 +41,41 @@ export const validateSchema = async ({ throw Error('Invalid schema.'); }; +type SchemaType = 'input-body' | 'input-query' | 'output-body'; + export const getJsonSchema = ({ - schema + schema, + operationId, + type }: { schema: ZodSchema; + operationId: string; + type: SchemaType; }): OpenAPIV3_1.SchemaObject => { if (isZodSchema(schema)) { - return zodToJsonSchema(schema, { - $refStrategy: 'none', - target: 'openApi3' - }); + try { + return zodToJsonSchema(schema, { + $refStrategy: 'none', + target: 'openApi3' + }); + } catch (error) { + const solutions: Record = { + 'input-body': 'bodySchema', + 'input-query': 'querySchema', + 'output-body': 'bodySchema' + }; + + console.warn( + chalk.yellowBright( + ` +Warning: ${type} schema for operation ${operationId} could not be converted to a JSON schema. The OpenAPI spec may not be accurate. +This is most likely related to an issue with the \`zod-to-json-schema\`: https://github.com/StefanTerdell/zod-to-json-schema?tab=readme-ov-file#known-issues +Please consider using the ${solutions[type]} property in addition to the Zod schema.` + ) + ); + + return {}; + } } throw Error('Invalid schema.'); diff --git a/packages/next-rest-framework/src/types.ts b/packages/next-rest-framework/src/types.ts index 3734525..21ccff8 100644 --- a/packages/next-rest-framework/src/types.ts +++ b/packages/next-rest-framework/src/types.ts @@ -1,5 +1,5 @@ import { type OpenAPIV3_1 } from 'openapi-types'; -import { type ZodSchema } from 'zod'; +import { type ZodEffects, type z, type ZodSchema } from 'zod'; export type DocsProvider = 'redoc' | 'swagger-ui'; @@ -76,7 +76,6 @@ export interface NextRestFrameworkConfig { } export type BaseStatus = number; -export type BaseContentType = AnyContentTypeWithAutocompleteForMostCommonOnes; export type BaseQuery = Record; export type BaseParams = Record; export type BaseOptions = Record; @@ -84,12 +83,16 @@ export type BaseOptions = Record; export interface OutputObject< Body = unknown, Status extends BaseStatus = BaseStatus, - ContentType extends BaseContentType = BaseContentType + ContentType extends + AnyContentTypeWithAutocompleteForMostCommonOnes = AnyContentTypeWithAutocompleteForMostCommonOnes > { - schema: ZodSchema; + body: ZodSchema; + bodySchema?: + | OpenAPIV3_1.SchemaObject + | OpenAPIV3_1.ReferenceObject /*! If defined, this will override the body schema for the OpenAPI spec. */; status: Status; contentType: ContentType; - name?: string; + name?: string /*! A custom name for the response, used for the generated component name in the OpenAPI spec. */; } export type Modify = Omit & R; @@ -164,3 +167,41 @@ export type AnyContentTypeWithAutocompleteForMostCommonOnes = | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' | 'application/vnd.mozilla.xul+xml' >; + +export type BaseContentType = AnyContentTypeWithAutocompleteForMostCommonOnes; + +export type ContentTypesThatSupportInputValidation = + | 'application/json' + | 'application/x-www-form-urlencoded' + | 'multipart/form-data'; + +export type FormDataContentType = + | 'application/x-www-form-urlencoded' + | 'multipart/form-data'; + +export type TypedFormData = Modify< + FormData, + { + append: (name: K, value: T[K] | Blob) => void; + delete: (name: K) => void; + get: (name: K) => T[K]; + getAll: (name: K) => Array; + has: (name: K) => boolean; + set: (name: K, value: T[K] | Blob) => void; + forEach: ( + callbackfn: (value: T[K], key: T, parent: TypedFormData) => void, + thisArg?: any + ) => void; + } +>; + +interface FormDataLikeInput { + [Symbol.iterator]: () => IterableIterator<[string, FormDataEntryValue]>; + entries: () => IterableIterator<[string, FormDataEntryValue]>; +} + +export type ZodFormSchema = ZodEffects< + ZodSchema, + z.output>, + FormData | FormDataLikeInput +>; diff --git a/packages/next-rest-framework/tests/app-router/route.test.ts b/packages/next-rest-framework/tests/app-router/route.test.ts index 2fb65db..41f56ad 100644 --- a/packages/next-rest-framework/tests/app-router/route.test.ts +++ b/packages/next-rest-framework/tests/app-router/route.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import { createMockRouteRequest } from '../utils'; import { NextResponse } from 'next/server'; import { validateSchema } from '../../src/shared'; +import { zfd } from 'zod-form-data'; describe('route', () => { it.each(Object.values(ValidMethod))( @@ -21,7 +22,7 @@ describe('route', () => { { status: 200, contentType: 'application/json', - schema: z.array(z.string()) + body: z.array(z.string()) } ]) .handler(() => NextResponse.json(data)); @@ -119,7 +120,7 @@ describe('route', () => { const json = await res?.json(); expect(res?.status).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -156,7 +157,7 @@ describe('route', () => { const json = await res?.json(); expect(res?.status).toEqual(400); - const { errors } = await validateSchema({ schema, obj: query }); + const { errors } = validateSchema({ schema, obj: query }); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidQueryParameters, @@ -191,7 +192,7 @@ describe('route', () => { { status: 200, contentType: 'application/json', - schema: z.object({ + body: z.object({ foo: z.string() }) } @@ -225,7 +226,9 @@ describe('route', () => { test: routeOperation({ method: 'POST' }) .input({ contentType: 'application/json', - body: z.string() + body: z.object({ + foo: z.string() + }) }) .handler(() => {}) }).POST(req, context); @@ -238,57 +241,146 @@ describe('route', () => { }); }); - it.each([ - { - definedContentType: 'application/json', - requestContentType: 'application/json' - }, - { - definedContentType: 'application/json', - requestContentType: 'application/json; charset=utf-8' - }, - { - definedContentType: 'application/form-data', - requestContentType: 'application/form-data; name: "foo"' - } - ])( - 'works with different content types: %s', - async ({ definedContentType, requestContentType }) => { - const { req, context } = createMockRouteRequest({ - method: ValidMethod.POST, - body: { - foo: 'bar' - }, - headers: { - 'content-type': requestContentType - } - }); + it('works with application/json', async () => { + const { req, context } = createMockRouteRequest({ + method: ValidMethod.POST, + body: { + foo: 'bar' + }, + headers: { + 'content-type': 'application/json' + } + }); - const res = await route({ - test: routeOperation({ method: 'POST' }) - .input({ - contentType: definedContentType, + const res = await route({ + test: routeOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + }) + .outputs([ + { + status: 201, + contentType: 'application/json', body: z.object({ foo: z.string() }) - }) - .outputs([ - { - status: 201, - contentType: 'application/json', - schema: z.object({ - foo: z.string() - }) - } - ]) - .handler(() => NextResponse.json({ foo: 'bar' })) - }).POST(req, context); + } + ]) + .handler(async (req) => { + const { foo } = await req.json(); + return NextResponse.json({ foo }); + }) + }).POST(req, context); - const json = await res?.json(); - expect(res?.status).toEqual(200); - expect(json).toEqual({ foo: 'bar' }); - } - ); + const json = await res?.json(); + expect(res?.status).toEqual(200); + expect(json).toEqual({ foo: 'bar' }); + }); + + it('works with application/x-www-form-urlencoded', async () => { + const { req, context } = createMockRouteRequest({ + method: ValidMethod.POST, + body: new URLSearchParams({ + foo: 'bar', + baz: 'qux' + }), + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + const res = await route({ + test: routeOperation({ method: 'POST' }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: zfd.formData(schema) + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: schema + } + ]) + .handler(async (req) => { + const formData = await req.formData(); + + return TypedNextResponse.json({ + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + }); + }) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + + expect(json).toEqual({ + foo: 'bar', + bar: null, + baz: 'qux' + }); + }); + + it('works with multipart/form-data', async () => { + const body = new FormData(); + body.append('foo', 'bar'); + body.append('baz', 'qux'); + + const { req, context } = createMockRouteRequest({ + method: ValidMethod.POST, + body + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + const res = await route({ + test: routeOperation({ method: 'POST' }) + .input({ + contentType: 'multipart/form-data', + body: zfd.formData(schema) + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: schema + } + ]) + .handler(async (req) => { + const formData = await req.formData(); + + return TypedNextResponse.json({ + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + }); + }) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + + expect(json).toEqual({ + foo: 'bar', + bar: null, + baz: 'qux' + }); + }); it('returns a default error response and logs the error', async () => { const { req, context } = createMockRouteRequest({ @@ -350,7 +442,7 @@ describe('route', () => { const json = await res?.json(); expect(res?.status).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -388,7 +480,10 @@ describe('route', () => { it('passes data between middleware and handler', async () => { const { req, context } = createMockRouteRequest({ method: ValidMethod.POST, - body: { foo: 'bar' } + body: { foo: 'bar' }, + headers: { + 'content-type': 'application/json' + } }); console.log = jest.fn(); diff --git a/packages/next-rest-framework/tests/app-router/rpc-route.test.ts b/packages/next-rest-framework/tests/app-router/rpc-route.test.ts index e3f2b7a..fe488da 100644 --- a/packages/next-rest-framework/tests/app-router/rpc-route.test.ts +++ b/packages/next-rest-framework/tests/app-router/rpc-route.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import { createMockRpcRouteRequest } from '../utils'; import { z } from 'zod'; import { rpcOperation, rpcRoute } from '../../src'; +import { zfd } from 'zod-form-data'; describe('rpcRoute', () => { it.each(Object.values(ValidMethod))( @@ -15,7 +16,9 @@ describe('rpcRoute', () => { const data = ['All good!']; const operation = rpcOperation() - .outputs([{ schema: z.array(z.string()) }]) + .outputs([ + { body: z.array(z.string()), contentType: 'application/json' } + ]) .handler(() => data); const res = await rpcRoute({ @@ -85,7 +88,8 @@ describe('rpcRoute', () => { }; const { req, context } = createMockRpcRouteRequest({ - body + body, + headers: { 'content-type': 'application/json' } }); const schema = z.object({ @@ -94,14 +98,17 @@ describe('rpcRoute', () => { const res = await rpcRoute({ test: rpcOperation() - .input(schema) + .input({ + contentType: 'application/json', + body: schema + }) .handler(() => {}) }).POST(req, context); const json = await res?.json(); expect(res?.status).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -109,8 +116,169 @@ describe('rpcRoute', () => { }); }); - it('returns a default error response', async () => { - const { req, context } = createMockRpcRouteRequest(); + it('returns error for invalid content-type', async () => { + const { req, context } = createMockRpcRouteRequest({ + method: ValidMethod.POST, + body: { + foo: 'bar' + }, + headers: { + 'content-type': 'application/xml' + } + }); + + const res = await rpcRoute({ + test: rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + }) + .handler(() => {}) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(400); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.invalidMediaType + }); + }); + + it('works with application/json', async () => { + const { req, context } = createMockRpcRouteRequest({ + method: ValidMethod.POST, + body: { + foo: 'bar' + }, + headers: { + 'content-type': 'application/json' + } + }); + + const res = await rpcRoute({ + test: rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + }) + .outputs([ + { + body: z.object({ + foo: z.string() + }), + contentType: 'application/json' + } + ]) + .handler(({ foo }) => ({ foo })) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + expect(json).toEqual({ foo: 'bar' }); + }); + + it('works with application/x-www-form-urlencoded', async () => { + const { req, context } = createMockRpcRouteRequest({ + method: ValidMethod.POST, + body: new URLSearchParams({ + foo: 'bar', + baz: 'qux' + }), + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + const res = await rpcRoute({ + test: rpcOperation() + .input({ + contentType: 'application/x-www-form-urlencoded', + body: zfd.formData(schema) + }) + .outputs([ + { + body: schema, + contentType: 'application/json' + } + ]) + .handler((formData) => { + return { + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + }; + }) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + + expect(json).toEqual({ + foo: 'bar', + bar: null, + baz: 'qux' + }); + }); + + it('works with multipart/form-data', async () => { + const body = new FormData(); + body.append('foo', 'bar'); + body.append('baz', 'qux'); + + const { req, context } = createMockRpcRouteRequest({ + method: ValidMethod.POST, + body + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + const res = await rpcRoute({ + test: rpcOperation() + .input({ + contentType: 'multipart/form-data', + body: zfd.formData(schema) + }) + .outputs([ + { + body: schema, + contentType: 'application/json' + } + ]) + .handler(async (formData) => ({ + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + })) + }).POST(req, context); + + const json = await res?.json(); + expect(res?.status).toEqual(200); + + expect(json).toEqual({ + foo: 'bar', + bar: null, + baz: 'qux' + }); + }); + + it('returns a default error response and logs the error', async () => { + const { req, context } = createMockRpcRouteRequest({ + method: ValidMethod.POST + }); console.error = jest.fn(); @@ -121,11 +289,14 @@ describe('rpcRoute', () => { }).POST(req, context); const json = await res?.json(); - expect(res?.status).toEqual(400); expect(json).toEqual({ message: DEFAULT_ERRORS.unexpectedError }); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Something went wrong') + ); }); it('executes middleware before validating input', async () => { @@ -133,7 +304,10 @@ describe('rpcRoute', () => { foo: 'bar' }; - const { req, context } = createMockRpcRouteRequest({ body }); + const { req, context } = createMockRpcRouteRequest({ + body, + headers: { 'content-type': 'application/json' } + }); const schema = z.object({ foo: z.number() @@ -143,7 +317,10 @@ describe('rpcRoute', () => { const res = await rpcRoute({ test: rpcOperation() - .input(schema) + .input({ + contentType: 'application/json', + body: schema + }) .middleware(() => { console.log('foo'); }) @@ -155,7 +332,7 @@ describe('rpcRoute', () => { const json = await res?.json(); expect(res?.status).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -190,14 +367,18 @@ describe('rpcRoute', () => { it('passes data between middleware and handler', async () => { const { req, context } = createMockRpcRouteRequest({ - body: { foo: 'bar' } + body: { foo: 'bar' }, + headers: { 'content-type': 'application/json' } }); console.log = jest.fn(); const res = await rpcRoute({ test: rpcOperation() - .input(z.object({ foo: z.string() })) + .input({ + contentType: 'application/json', + body: z.object({ foo: z.string() }) + }) .middleware((input) => { console.log(input); return { bar: 'baz' }; diff --git a/packages/next-rest-framework/tests/pages-router/api-route.test.ts b/packages/next-rest-framework/tests/pages-router/api-route.test.ts index 279a6ea..d686041 100644 --- a/packages/next-rest-framework/tests/pages-router/api-route.test.ts +++ b/packages/next-rest-framework/tests/pages-router/api-route.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import { validateSchema } from '../../src/shared'; import { createMockApiRouteRequest } from '../utils'; import { apiRoute, apiRouteOperation } from '../../src/pages-router'; +import { zfd } from 'zod-form-data'; describe('apiRoute', () => { it.each(Object.values(ValidMethod))( @@ -20,7 +21,7 @@ describe('apiRoute', () => { { status: 200, contentType: 'application/json', - schema: z.array(z.string()) + body: z.array(z.string()) } ]) .handler((_req, res) => { @@ -118,7 +119,7 @@ describe('apiRoute', () => { expect(res.statusCode).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(res._getJSONData()).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -154,7 +155,7 @@ describe('apiRoute', () => { expect(res.statusCode).toEqual(400); - const { errors } = await validateSchema({ schema, obj: query }); + const { errors } = validateSchema({ schema, obj: query }); expect(res._getJSONData()).toEqual({ message: DEFAULT_ERRORS.invalidQueryParameters, @@ -189,7 +190,7 @@ describe('apiRoute', () => { { status: 200, contentType: 'application/json', - schema: z.object({ + body: z.object({ foo: z.string() }) } @@ -222,7 +223,9 @@ describe('apiRoute', () => { test: apiRouteOperation({ method: 'POST' }) .input({ contentType: 'application/json', - body: z.string() + body: z.object({ + foo: z.string() + }) }) .handler(() => {}) })(req, res); @@ -230,62 +233,141 @@ describe('apiRoute', () => { expect(res.statusCode).toEqual(415); expect(res._getJSONData()).toEqual({ - message: DEFAULT_ERRORS.invalidMediaType + message: `${DEFAULT_ERRORS.invalidMediaType} Expected application/json.` }); }); - it.each([ - { - definedContentType: 'application/json', - requestContentType: 'application/json' - }, - { - definedContentType: 'application/json', - requestContentType: 'application/json; charset=utf-8' - }, - { - definedContentType: 'application/form-data', - requestContentType: 'application/form-data; name: "foo"' - } - ])( - 'works with different content types: %s', - async ({ definedContentType, requestContentType }) => { - const { req, res } = createMockApiRouteRequest({ - method: ValidMethod.POST, - body: { - foo: 'bar' - }, - headers: { - 'content-type': requestContentType - } - }); + it('works with application/json', async () => { + const { req, res } = createMockApiRouteRequest({ + method: ValidMethod.POST, + body: { + foo: 'bar' + }, + headers: { + 'content-type': 'application/json' + } + }); - await apiRoute({ - test: apiRouteOperation({ method: 'POST' }) - .input({ - contentType: definedContentType, + await apiRoute({ + test: apiRouteOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + }) + .outputs([ + { + status: 201, + contentType: 'application/json', body: z.object({ foo: z.string() }) - }) - .outputs([ - { - status: 201, - contentType: 'application/json', - schema: z.object({ - foo: z.string() - }) - } - ]) - .handler(() => { - res.json({ foo: 'bar' }); - }) - })(req, res); + } + ]) + .handler((req) => { + const { foo } = req.body; + res.json({ foo }); + }) + })(req, res); - expect(res.statusCode).toEqual(200); - expect(res._getJSONData()).toEqual({ foo: 'bar' }); - } - ); + expect(res.statusCode).toEqual(200); + expect(res._getJSONData()).toEqual({ foo: 'bar' }); + }); + + it('works with application/x-www-form-urlencoded', async () => { + const body = new FormData(); + body.append('foo', 'bar'); + body.append('baz', 'qux'); + + const { req, res } = createMockApiRouteRequest({ + method: ValidMethod.POST, + body, + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + await apiRoute({ + test: apiRouteOperation({ method: 'POST' }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: zfd.formData(schema) + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: schema + } + ]) + .handler((req, res) => { + const formData = req.body; + + res.json({ + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + }); + }) + })(req, res); + + expect(res.statusCode).toEqual(200); + expect(res._getJSONData()).toEqual({ foo: 'bar', bar: null, baz: 'qux' }); + }); + + it('works with multipart/form-data', async () => { + const body = new FormData(); + body.append('foo', 'bar'); + body.append('baz', 'qux'); + + const { req, res } = createMockApiRouteRequest({ + method: ValidMethod.POST, + body, + headers: { + 'content-type': 'multipart/form-data' + } + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + await apiRoute({ + test: apiRouteOperation({ method: 'POST' }) + .input({ + contentType: 'multipart/form-data', + body: zfd.formData(schema) + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: schema + } + ]) + .handler((req, res) => { + const formData = req.body; + + res.json({ + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + }); + }) + })(req, res); + + expect(res.statusCode).toEqual(200); + expect(res._getJSONData()).toEqual({ foo: 'bar', bar: null, baz: 'qux' }); + }); it('returns a default error response and logs the error', async () => { const { req, res } = createMockApiRouteRequest({ @@ -344,7 +426,7 @@ describe('apiRoute', () => { expect(res.statusCode).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(res._getJSONData()).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -381,7 +463,10 @@ describe('apiRoute', () => { it('passes data between middleware and handler', async () => { const { req, res } = createMockApiRouteRequest({ method: ValidMethod.POST, - body: { foo: 'bar' } + body: { foo: 'bar' }, + headers: { + 'content-type': 'application/json' + } }); console.log = jest.fn(); diff --git a/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts b/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts index d1a8a65..ff427ec 100644 --- a/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts +++ b/packages/next-rest-framework/tests/pages-router/rpc-api-route.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_ERRORS, ValidMethod } from '../../src/constants'; import { z } from 'zod'; import { rpcApiRoute, rpcOperation } from '../../src'; import { createMockRpcApiRouteRequest } from '../utils'; +import { zfd } from 'zod-form-data'; describe('rpcApiRoute', () => { it.each(Object.values(ValidMethod))( @@ -15,7 +16,9 @@ describe('rpcApiRoute', () => { const data = ['All good!']; const operation = rpcOperation() - .outputs([{ schema: z.array(z.string()) }]) + .outputs([ + { body: z.array(z.string()), contentType: 'application/json' } + ]) .handler(() => data); await rpcApiRoute({ @@ -85,7 +88,10 @@ describe('rpcApiRoute', () => { }; const { req, res } = createMockRpcApiRouteRequest({ - body + body, + headers: { + 'content-type': 'application/json' + } }); const schema = z.object({ @@ -94,14 +100,14 @@ describe('rpcApiRoute', () => { await rpcApiRoute({ test: rpcOperation() - .input(schema) + .input({ contentType: 'application/json', body: schema }) .handler(() => {}) })(req, res); const json = res._getJSONData(); expect(res.statusCode).toEqual(400); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(json).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -109,8 +115,172 @@ describe('rpcApiRoute', () => { }); }); - it('returns a default error response', async () => { - const { req, res } = createMockRpcApiRouteRequest(); + it('returns error for invalid content-type', async () => { + const { req, res } = createMockRpcApiRouteRequest({ + method: ValidMethod.POST, + body: { + foo: 'bar' + }, + headers: { + 'content-type': 'application/xml' + } + }); + + await rpcApiRoute({ + test: rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + }) + .handler(() => {}) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(400); + + expect(json).toEqual({ + message: DEFAULT_ERRORS.invalidMediaType + }); + }); + + it('works with application/json', async () => { + const { req, res } = createMockRpcApiRouteRequest({ + method: ValidMethod.POST, + body: { + foo: 'bar' + }, + headers: { + 'content-type': 'application/json' + } + }); + + await rpcApiRoute({ + test: rpcOperation() + .input({ + contentType: 'application/json', + body: z.object({ + foo: z.string() + }) + }) + .outputs([ + { + body: z.object({ + foo: z.string() + }), + contentType: 'application/json' + } + ]) + .handler(({ foo }) => ({ foo })) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(200); + expect(json).toEqual({ foo: 'bar' }); + }); + + it('works with application/x-www-form-urlencoded', async () => { + const { req, res } = createMockRpcApiRouteRequest({ + method: ValidMethod.POST, + body: new URLSearchParams({ + foo: 'bar', + baz: 'qux' + }), + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + await rpcApiRoute({ + test: rpcOperation() + .input({ + contentType: 'application/x-www-form-urlencoded', + body: zfd.formData(schema) + }) + .outputs([ + { + body: schema, + contentType: 'application/json' + } + ]) + .handler((formData) => { + return { + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + }; + }) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(200); + + expect(json).toEqual({ + foo: 'bar', + bar: null, + baz: 'qux' + }); + }); + + it('works with multipart/form-data', async () => { + const body = new FormData(); + body.append('foo', 'bar'); + body.append('baz', 'qux'); + + const { req, res } = createMockRpcApiRouteRequest({ + method: ValidMethod.POST, + body, + headers: { + 'content-type': 'multipart/form-data' + } + }); + + const schema = z.object({ + foo: z.string(), + bar: z.string().optional(), + baz: z.string() + }); + + await rpcApiRoute({ + test: rpcOperation() + .input({ + contentType: 'multipart/form-data', + body: zfd.formData(schema) + }) + .outputs([ + { + body: schema, + contentType: 'application/json' + } + ]) + .handler(async (formData) => ({ + foo: formData.get('foo'), + bar: formData.get('bar'), + baz: formData.get('baz') + })) + })(req, res); + + const json = res._getJSONData(); + expect(res.statusCode).toEqual(200); + + expect(json).toEqual({ + foo: 'bar', + bar: null, + baz: 'qux' + }); + }); + + it('returns a default error response and logs the error', async () => { + const { req, res } = createMockRpcApiRouteRequest({ + method: ValidMethod.POST + }); console.error = jest.fn(); @@ -126,6 +296,10 @@ describe('rpcApiRoute', () => { expect(json).toEqual({ message: DEFAULT_ERRORS.unexpectedError }); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Something went wrong') + ); }); it('executes middleware before validating input', async () => { @@ -133,7 +307,10 @@ describe('rpcApiRoute', () => { foo: 'bar' }; - const { req, res } = createMockRpcApiRouteRequest({ body }); + const { req, res } = createMockRpcApiRouteRequest({ + body, + headers: { 'content-type': 'application/json' } + }); const schema = z.object({ foo: z.number() @@ -143,7 +320,7 @@ describe('rpcApiRoute', () => { await rpcApiRoute({ test: rpcOperation() - .input(schema) + .input({ contentType: 'application/json', body: schema }) .middleware(() => { console.log('foo'); }) @@ -152,7 +329,7 @@ describe('rpcApiRoute', () => { expect(console.log).toHaveBeenCalledWith('foo'); - const { errors } = await validateSchema({ schema, obj: body }); + const { errors } = validateSchema({ schema, obj: body }); expect(res._getJSONData()).toEqual({ message: DEFAULT_ERRORS.invalidRequestBody, @@ -189,14 +366,20 @@ describe('rpcApiRoute', () => { it('passes data between middleware and handler', async () => { const { req, res } = createMockRpcApiRouteRequest({ - body: { foo: 'bar' } + body: { foo: 'bar' }, + headers: { + 'content-type': 'application/json' + } }); console.log = jest.fn(); await rpcApiRoute({ test: rpcOperation() - .input(z.object({ foo: z.string() })) + .input({ + contentType: 'application/json', + body: z.object({ foo: z.string() }) + }) .middleware((input) => { console.log(input); return { bar: 'baz' }; diff --git a/packages/next-rest-framework/tests/utils.ts b/packages/next-rest-framework/tests/utils.ts index 08b9242..8870869 100644 --- a/packages/next-rest-framework/tests/utils.ts +++ b/packages/next-rest-framework/tests/utils.ts @@ -40,7 +40,10 @@ export const createMockRouteRequest = ({ } => ({ req: new NextRequest(`http://localhost:3000${path}?${qs.stringify(query)}`, { method, - body: JSON.stringify(body), + body: + body instanceof URLSearchParams || body instanceof FormData + ? body + : JSON.stringify(body), headers: { host: 'localhost:3000', 'x-forwarded-proto': 'http', @@ -71,7 +74,6 @@ export const createMockRpcRouteRequest = ({ body, method, headers: { - 'Content-Type': 'application/json', ...headers } }); @@ -117,7 +119,6 @@ export const createMockRpcApiRouteRequest = ({ body, method, headers: { - 'Content-Type': 'application/json', ...headers } }); @@ -131,7 +132,11 @@ export const getExpectedSpec = ({ allowedPaths: string[]; deniedPaths: string[]; }) => { - const schema = getJsonSchema({ schema: zodSchema }); + const schema = getJsonSchema({ + schema: zodSchema, + operationId: 'test', + type: 'input-body' + }); const parameters: OpenAPIV3_1.ParameterObject[] = [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33ff331..99502e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: next-rest-framework: specifier: workspace:* version: link:../../packages/next-rest-framework + zod-form-data: + specifier: 2.0.2 + version: 2.0.2(zod@3.22.2) devDependencies: autoprefixer: specifier: 10.0.1 @@ -125,6 +128,9 @@ importers: fast-glob: specifier: 3.3.2 version: 3.3.2 + formidable: + specifier: ^3.5.1 + version: 3.5.1 lodash: specifier: 4.17.21 version: 4.17.21 @@ -138,6 +144,9 @@ importers: specifier: 3.21.4 version: 3.21.4(zod@3.22.2) devDependencies: + '@types/formidable': + specifier: ^3.4.5 + version: 3.4.5 '@types/jest': specifier: 29.5.4 version: 29.5.4 @@ -174,6 +183,9 @@ importers: zod: specifier: '*' version: 3.22.2 + zod-form-data: + specifier: '*' + version: 2.0.2(zod@3.22.2) packages: @@ -3618,6 +3630,12 @@ packages: '@types/serve-static': 1.15.2 dev: false + /@types/formidable@3.4.5: + resolution: {integrity: sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==} + dependencies: + '@types/node': 20.5.4 + dev: true + /@types/graceful-fs@4.1.6: resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} dependencies: @@ -5591,6 +5609,13 @@ packages: - supports-color dev: false + /dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dev: false + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true @@ -6635,6 +6660,14 @@ packages: webpack: 5.88.2 dev: false + /formidable@3.5.1: + resolution: {integrity: sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==} + dependencies: + dezalgo: 1.0.4 + hexoid: 1.0.0 + once: 1.4.0 + dev: false + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7042,6 +7075,11 @@ packages: hasBin: true dev: false + /hexoid@1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + dev: false + /history@4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} dependencies: @@ -12043,6 +12081,13 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /zod-form-data@2.0.2(zod@3.22.2): + resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==} + peerDependencies: + zod: '>= 3.11.0' + dependencies: + zod: 3.22.2 + /zod-to-json-schema@3.21.4(zod@3.22.2): resolution: {integrity: sha512-fjUZh4nQ1s6HMccgIeE0VP4QG/YRGPmyjO9sAh890aQKPEk3nqbfUXhMFaC+Dr5KvYBm8BCyvfpZf2jY9aGSsw==} peerDependencies: