From 530e154b4b618bca1676b1d8477841013a1e1707 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Tue, 19 Mar 2024 02:58:11 +0200 Subject: [PATCH] WIP --- apps/example/public/openapi.json | 48 +++++++----- .../app/api/v2/form-data/multipart/route.ts | 48 ++++++++++++ .../example/src/app/api/v2/form-data/route.ts | 46 ----------- .../app/api/v2/form-data/url-encoded/route.ts | 42 ++++++++++ .../src/pages/api/v1/form-data/index.ts | 43 ---------- .../pages/api/v1/form-data/multipart/index.ts | 58 ++++++++++++++ .../api/v1/form-data/url-encoded/index.ts | 39 ++++++++++ apps/example/src/utils.ts | 10 +++ packages/next-rest-framework/package.json | 6 +- .../src/app-router/route-operation.ts | 23 ++---- .../src/app-router/route.ts | 74 +++++++++++++----- .../src/app-router/rpc-route.ts | 44 +++++++---- packages/next-rest-framework/src/constants.ts | 5 ++ .../src/pages-router/api-route-operation.ts | 13 ++-- .../src/pages-router/api-route.ts | 78 ++++++++++++------- .../src/pages-router/rpc-api-route.ts | 66 ++++++++++------ .../src/shared/rpc-operation.ts | 8 +- .../next-rest-framework/src/shared/schemas.ts | 3 +- .../next-rest-framework/src/shared/utils.ts | 39 ++++++++++ packages/next-rest-framework/src/types.ts | 9 +++ .../tests/app-router/rpc-route.test.ts | 46 +++++++++++ .../tests/pages-router/api-route.test.ts | 47 +++++++++++ pnpm-lock.yaml | 32 ++++++++ 23 files changed, 599 insertions(+), 228 deletions(-) create mode 100644 apps/example/src/app/api/v2/form-data/multipart/route.ts delete mode 100644 apps/example/src/app/api/v2/form-data/route.ts create mode 100644 apps/example/src/app/api/v2/form-data/url-encoded/route.ts delete mode 100644 apps/example/src/pages/api/v1/form-data/index.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 diff --git a/apps/example/public/openapi.json b/apps/example/public/openapi.json index b406798..a7641cd 100644 --- a/apps/example/public/openapi.json +++ b/apps/example/public/openapi.json @@ -6,13 +6,15 @@ "version": "v5.1.11" }, "paths": { - "/api/v1/form-data": { + "/api/v1/form-data/url-encoded": { "post": { - "operationId": "formData", + "operationId": "urlEncodedFormData", "requestBody": { "content": { "application/x-www-form-urlencoded": { - "schema": { "$ref": "#/components/schemas/FormDataRequestBody" } + "schema": { + "$ref": "#/components/schemas/UrlEncodedFormDataRequestBody" + } } } }, @@ -22,7 +24,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormData200ResponseBody" + "$ref": "#/components/schemas/UrlEncodedFormData200ResponseBody" } } } @@ -36,7 +38,7 @@ } } }, - "tags": ["example-api", "pages-router", "form-data"] + "tags": ["example-api", "pages-router", "form-data", "url-encoded"] } }, "/api/v1/route-with-query-params": { @@ -398,13 +400,15 @@ "tags": ["example-api", "pages-router", "todos"] } }, - "/api/v2/form-data": { + "/api/v2/form-data/url-encoded": { "post": { - "operationId": "formData", + "operationId": "urlEncodedFormData", "requestBody": { "content": { "application/x-www-form-urlencoded": { - "schema": { "$ref": "#/components/schemas/FormDataRequestBody" } + "schema": { + "$ref": "#/components/schemas/UrlEncodedFormDataRequestBody" + } } } }, @@ -412,9 +416,9 @@ "200": { "description": "Response for status 200", "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/FormData200ResponseBody" + "$ref": "#/components/schemas/UrlEncodedFormData200ResponseBody" } } } @@ -428,7 +432,7 @@ } } }, - "tags": ["example-api", "app-router", "form-data"] + "tags": ["example-api", "app-router", "form-data", "url-encoded"] } }, "/api/v2/route-with-query-params": { @@ -826,16 +830,6 @@ "required": ["message"], "additionalProperties": false }, - "FormData200ResponseBody": { - "type": "object", - "properties": { - "foo": { "type": "string", "format": "uuid" }, - "bar": { "type": "string" }, - "baz": { "type": "string" } - }, - "required": ["foo", "baz"], - "additionalProperties": false - }, "FormDataRequestBody": { "type": "object", "properties": { @@ -924,6 +918,18 @@ "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/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..6e85108 --- /dev/null +++ b/apps/example/src/app/api/v2/form-data/multipart/route.ts @@ -0,0 +1,48 @@ +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { zfd } from 'zod-form-data'; +import { z } from 'zod'; +import { jsonFileSchema } from '@/utils'; + +/* + * Example app router route handler with multipart form-data. + * Edge runtime is not supported for multipart/form-data. + * A zod-form-data schema is required for the input instead of a regular Zod schema. + */ +export const { POST } = route({ + multipartFormData: routeOperation({ + method: 'POST', + openApiOperation: { + tags: ['example-api', 'app-router', 'form-data', 'multipart'] + } + }) + .input({ + contentType: 'multipart/form-data', + body: zfd.formData( + z.object({ + text: z.string(), + file: jsonFileSchema + }) + ) + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + schema: jsonFileSchema + } + ]) + .handler(async (req) => { + // const json = await req.json(); // Strongly typed parsed form data as JSON - multipart content like files are empty objects in the JSON but can be accessed from the form data below. + const formData = await req.formData(); // Strongly typed form data. + const file = formData.get('file'); + + // Type-checked response. + 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/route.ts b/apps/example/src/app/api/v2/form-data/route.ts deleted file mode 100644 index b91db9b..0000000 --- a/apps/example/src/app/api/v2/form-data/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; -import { zfd } from 'zod-form-data'; -import { z } from 'zod'; - -const schema = z.object({ - foo: z.string().uuid(), - bar: z.string().optional(), - baz: z.string() -}); - -export const runtime = 'edge'; - -/* - * Example app router route handler with form data. - * Using application/x-www-form-urlencoded or multipart/form-data content types - * requires a zod-form-data schema for the input instead of a regular Zod schema. - */ -export const { POST } = route({ - formData: routeOperation({ - method: 'POST', - openApiOperation: { - tags: ['example-api', 'app-router', 'form-data'] - } - }) - .input({ - contentType: 'application/x-www-form-urlencoded', // multipart/form-data is also supported. - body: zfd.formData(schema) - }) - .outputs([ - { - status: 200, - contentType: 'application/json', - schema - } - ]) - .handler(async (req) => { - // const json = await req.json(); // Trying to parse JSON body gives a TS error because of the specified content type. - const formData = await req.formData(); // Strongly typed form data. - - return TypedNextResponse.json({ - foo: formData.get('foo'), - bar: formData.get('bar'), - baz: formData.get('baz') - }); - }) -}); 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..243d290 --- /dev/null +++ b/apps/example/src/app/api/v2/form-data/url-encoded/route.ts @@ -0,0 +1,42 @@ +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { zfd } from 'zod-form-data'; +import { z } from 'zod'; + +const schema = z.object({ + text: z.string() +}); + +export const runtime = 'edge'; // Edge runtime is supported for application/x-www-form-urlencoded. + +/* + * Example app router route handler with application/x-www-form-urlencoded form data. + * A zod-form-data schema is required for the input instead of a regular Zod schema. + */ +export const { POST } = route({ + urlEncodedFormData: routeOperation({ + method: 'POST', + openApiOperation: { + tags: ['example-api', 'app-router', 'form-data', 'url-encoded'] + } + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: zfd.formData(schema) + }) + .outputs([ + { + status: 200, + contentType: 'application/octet-stream', + schema + } + ]) + .handler(async (req) => { + const { text } = await req.json(); // Strongly typed parsed form data as JSON. + // const formData = await req.formData(); // Strongly typed form data. + + // Type-checked response. + return TypedNextResponse.json({ + text + }); + }) +}); diff --git a/apps/example/src/pages/api/v1/form-data/index.ts b/apps/example/src/pages/api/v1/form-data/index.ts deleted file mode 100644 index 1f31584..0000000 --- a/apps/example/src/pages/api/v1/form-data/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { apiRoute, apiRouteOperation } from 'next-rest-framework'; -import { z } from 'zod'; -import { zfd } from 'zod-form-data'; - -const schema = z.object({ - foo: z.string().uuid(), - bar: z.string().optional(), - baz: z.string() -}); - -/* - * Example pages router route handler with form data. - * Using application/x-www-form-urlencoded content type - * requires a zod-form-data schema for the input instead of a regular Zod schema. - */ -export default apiRoute({ - formData: apiRouteOperation({ - method: 'POST', - openApiOperation: { - tags: ['example-api', 'pages-router', 'form-data'] - } - }) - .input({ - contentType: 'application/x-www-form-urlencoded', // multipart/form-data is also supported with app router. - body: zfd.formData(schema) - }) - .outputs([ - { - status: 200, - contentType: 'application/json', - schema - } - ]) - .handler((req, res) => { - const formData = req.body; // Strongly typed form data. - - res.json({ - foo: formData.get('foo'), - bar: formData.get('bar'), - baz: formData.get('baz') - }); - }) -}); 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..8bb6f5c --- /dev/null +++ b/apps/example/src/pages/api/v1/form-data/multipart/index.ts @@ -0,0 +1,58 @@ +import { jsonFileSchema } from '@/utils'; +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +const schema = z.object({ + text: z.string(), + file: jsonFileSchema +}); + +// Body parser must be disabled when parsing multipart/form-data requests with pages router. +export const config = { + api: { + bodyParser: false + } +}; + +/* + * Example pages router route handler with multipart form data. + * A zod-form-data schema is required for the input instead of a regular Zod schema. + */ +export default apiRoute({ + multipartFormData: apiRouteOperation({ + method: 'POST', + openApiOperation: { + tags: ['example-api', 'pages-router', 'form-data', 'multipart'] + } + }) + .input({ + contentType: 'multipart/form-data', + body: zfd.formData(schema) + }) + .handler(async (req, res) => { + const formData = req.body; // Strongly typed form data. + 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..2ee1a76 --- /dev/null +++ b/apps/example/src/pages/api/v1/form-data/url-encoded/index.ts @@ -0,0 +1,39 @@ +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +const schema = z.object({ + text: z.string() +}); + +/* + * Example pages router route handler with application/x-www-form-urlencoded form data form data. + * A zod-form-data schema is required for the input instead of a regular Zod schema. + */ +export default apiRoute({ + urlEncodedFormData: apiRouteOperation({ + method: 'POST', + openApiOperation: { + tags: ['example-api', 'pages-router', 'form-data', 'url-encoded'] + } + }) + .input({ + contentType: 'application/x-www-form-urlencoded', + body: zfd.formData(schema) + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + schema + } + ]) + .handler((req, res) => { + const formData = req.body; // Strongly typed form data. + + // Type-checked response. + res.json({ + text: formData.get('text') + }); + }) +}); diff --git a/apps/example/src/utils.ts b/apps/example/src/utils.ts index f542690..4e02229 100644 --- a/apps/example/src/utils.ts +++ b/apps/example/src/utils.ts @@ -6,6 +6,16 @@ export const todoSchema = z.object({ completed: z.boolean() }); +export const jsonFileSchema = z + .custom() + .refine((file) => !!file, { message: 'File is required.' }) + .refine((file) => file.type === 'application/json', { + message: 'File is not a JSON file.' + }) + .refine((file) => file.size <= 1024 * 1024, { + message: 'File size must be less than 1MB.' + }); + export type Todo = z.infer; export const MOCK_TODOS: Todo[] = [ diff --git a/packages/next-rest-framework/package.json b/packages/next-rest-framework/package.json index 2f7a58f..cabddf2 100644 --- a/packages/next-rest-framework/package.json +++ b/packages/next-rest-framework/package.json @@ -40,12 +40,14 @@ "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", "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", @@ -56,8 +58,8 @@ "ts-jest": "29.1.1", "ts-node": "10.9.1", "tsup": "8.0.1", + "typescript": "*", "zod": "*", - "zod-form-data": "*", - "typescript": "*" + "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 a151ecb..1f4bdb7 100644 --- a/packages/next-rest-framework/src/app-router/route-operation.ts +++ b/packages/next-rest-framework/src/app-router/route-operation.ts @@ -12,7 +12,9 @@ import { type TypedFormData, type AnyContentTypeWithAutocompleteForMostCommonOnes, type BaseContentType, - type ZodFormSchema + type ZodFormSchema, + type FormDataContentType, + type ContentTypesThatSupportInputValidation } from '../types'; import { NextResponse, type NextRequest } from 'next/server'; import { type ZodSchema, type z } from 'zod'; @@ -21,15 +23,6 @@ import { type I18NConfig } from 'next/dist/server/config-shared'; import { type ResponseCookies } from 'next/dist/server/web/spec-extension/cookies'; import { type NextURL } from 'next/dist/server/web/next-url'; -type ContentTypesThatSupportInputValidation = - | 'application/json' - | 'application/x-www-form-urlencoded' - | 'multipart/form-data'; - -export type FormDataContentType = - | 'application/x-www-form-urlencoded' - | 'multipart/form-data'; - export type TypedNextRequest< Method = keyof typeof ValidMethod, ContentType = BaseContentType, @@ -39,13 +32,9 @@ export type TypedNextRequest< NextRequest, { method: Method; - /*! For GET and non-JSON requests, attempting to parse a JSON body gives a type error. */ - json: Method extends 'GET' - ? never - : ContentType extends 'application/json' - ? () => Promise - : never; - /*! For GET and non-form requests, attempting to parse form data gives a type error. */ + /*! 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 diff --git a/packages/next-rest-framework/src/app-router/route.ts b/packages/next-rest-framework/src/app-router/route.ts index ea5a6df..29c5f18 100644 --- a/packages/next-rest-framework/src/app-router/route.ts +++ b/packages/next-rest-framework/src/app-router/route.ts @@ -5,12 +5,12 @@ import { validateSchema } from '../shared'; import { logNextRestFrameworkError } from '../shared/logging'; import { getPathsFromRoute } from '../shared/paths'; import { + type FormDataContentType, type BaseOptions, type BaseParams, type OpenApiPathItem } from '../types'; import { - type FormDataContentType, type RouteOperationDefinition, type TypedNextRequest } from './route-operation'; @@ -49,14 +49,11 @@ export const route = >( const { input, handler, middleware1, middleware2, middleware3 } = operation; + let reqClone = new NextRequest(req.clone()); 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'; @@ -68,11 +65,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; @@ -82,7 +75,7 @@ export const route = >( if (middleware3) { const res3 = await middleware3( - new NextRequest(req.clone()), + reqClone, context, middlewareOptions ); @@ -113,13 +106,11 @@ export const route = >( } if (bodySchema) { - const reqClone = req.clone(); - if (contentType === 'application/json') { try { - const json = await reqClone.json(); + const json = await req.clone().json(); - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: bodySchema, obj: json }); @@ -135,6 +126,13 @@ export const route = >( } ); } + + reqClone = new NextRequest(reqClone.url, { + ...reqClone, + method: reqClone.method, + headers: reqClone.headers, + body: JSON.stringify(data) + }); } catch { return NextResponse.json( { @@ -151,9 +149,9 @@ export const route = >( FORM_DATA_CONTENT_TYPES.includes(contentType as FormDataContentType) ) { try { - const formData = await reqClone.formData(); + const formData = await reqClone.clone().formData(); - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: bodySchema, obj: formData }); @@ -169,6 +167,25 @@ export const route = >( } ); } + + // 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( { @@ -183,7 +200,7 @@ export const route = >( } if (querySchema) { - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: querySchema, obj: qs.parse(req.nextUrl.search, { ignoreQueryPrefix: true }) }); @@ -199,11 +216,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 07c95a6..74691ba 100644 --- a/packages/next-rest-framework/src/app-router/rpc-route.ts +++ b/packages/next-rest-framework/src/app-router/rpc-route.ts @@ -1,12 +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, - type FormDataContentType + type RpcOperationDefinition } from '../shared'; import { + type FormDataContentType, type BaseOptions, type BaseParams, type OpenApiPathItem @@ -14,10 +18,6 @@ import { import { type RpcClient } from '../client/rpc-client'; import { getPathsFromRpcRoute } from '../shared/paths'; -const FORM_DATA_CONTENT_TYPES: FormDataContentType[] = [ - 'application/x-www-form-urlencoded' -]; - export const rpcRoute = < T extends Record> >( @@ -58,22 +58,23 @@ export const rpcRoute = < operation._meta; const contentType = req.headers.get('content-type')?.split(';')[0]; - const reqClone = req.clone(); const parseRequestBody = async () => { if (contentType === 'application/json') { try { - return await reqClone.json(); + return await req.clone().json(); } catch { return {}; } } if ( - FORM_DATA_CONTENT_TYPES.includes(contentType as FormDataContentType) + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION.includes( + contentType as FormDataContentType + ) ) { try { - return await reqClone.formData(); + return await req.clone().formData(); } catch { return {}; } @@ -82,8 +83,7 @@ export const rpcRoute = < return {}; }; - const body = await parseRequestBody(); - + let body = await parseRequestBody(); let middlewareOptions: BaseOptions = {}; if (middleware1) { @@ -101,7 +101,7 @@ export const rpcRoute = < if (input) { const { contentType: contentTypeSchema, body: bodySchema } = input; - if (contentType !== contentTypeSchema) { + if (contentTypeSchema && contentType !== contentTypeSchema) { return NextResponse.json( { message: DEFAULT_ERRORS.invalidMediaType }, { status: 400 } @@ -111,7 +111,7 @@ export const rpcRoute = < if (bodySchema) { if (contentType === 'application/json') { try { - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: bodySchema, obj: body }); @@ -127,6 +127,8 @@ export const rpcRoute = < } ); } + + body = data; } catch { return NextResponse.json( { @@ -139,12 +141,12 @@ export const rpcRoute = < } if ( - FORM_DATA_CONTENT_TYPES.includes( + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION.includes( contentType as FormDataContentType ) ) { try { - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: bodySchema, obj: body }); @@ -160,6 +162,14 @@ export const rpcRoute = < } ); } + + 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( { 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 d199392..c804415 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 @@ -11,17 +11,13 @@ import { type OpenApiOperation, type BaseOptions, type TypedFormData, - type ZodFormSchema + type ZodFormSchema, + type ContentTypesThatSupportInputValidation, + type FormDataContentType } from '../types'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; import { type ZodSchema, type z } from 'zod'; -type ContentTypesThatSupportInputValidation = - | 'application/json' - | 'application/x-www-form-urlencoded'; - -export type FormDataContentType = 'application/x-www-form-urlencoded'; - export type TypedNextApiRequest< Method = keyof typeof ValidMethod, ContentType = BaseContentType, @@ -33,7 +29,8 @@ export type TypedNextApiRequest< /*! * 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 are typed with a strongly-typed form data object. + * application/x-www-form-urlencoded and multipart/form-data requests are + * typed with a strongly-typed form data object. */ body: Method extends 'GET' ? never 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 65a6887..b5c8bcd 100644 --- a/packages/next-rest-framework/src/pages-router/api-route.ts +++ b/packages/next-rest-framework/src/pages-router/api-route.ts @@ -1,23 +1,26 @@ -import { DEFAULT_ERRORS } from '../constants'; +import { + DEFAULT_ERRORS, + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION +} from '../constants'; import { validateSchema, logNextRestFrameworkError, - logPagesEdgeRuntimeErrorForRoute + logPagesEdgeRuntimeErrorForRoute, + parseMultiPartFormData } 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, - type FormDataContentType + type ApiRouteOperationDefinition } from './api-route-operation'; import { type NextRequest } from 'next/server'; import { getPathsFromRoute } from '../shared/paths'; -const FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION: FormDataContentType[] = [ - 'application/x-www-form-urlencoded' -]; - export const apiRoute = >( operations: T, options?: { @@ -112,7 +115,7 @@ export const apiRoute = >( if (bodySchema) { if (contentType === 'application/json') { - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: bodySchema, obj: req.body }); @@ -125,6 +128,8 @@ export const apiRoute = >( return; } + + req.body = data; } if ( @@ -133,27 +138,46 @@ export const apiRoute = >( ) ) { if ( - !( - req.body instanceof FormData || - req.body instanceof URLSearchParams - ) + contentType === 'multipart/form-data' && + !(req.body instanceof FormData) ) { - res.status(415).json({ - message: `${DEFAULT_ERRORS.invalidRequestBody} Expected a FormData or URLSearchParams.` + // 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 }); - return; - } + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); - const { valid, errors } = validateSchema({ - schema: bodySchema, - obj: req.body - }); + return; + } - if (!valid) { + 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, - errors + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` }); return; @@ -162,7 +186,7 @@ export const apiRoute = >( } if (querySchema) { - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: querySchema, obj: req.query }); @@ -175,6 +199,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 7b1c9dc..64499d5 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,13 +1,18 @@ -import { DEFAULT_ERRORS, ValidMethod } from '../constants'; +import { + DEFAULT_ERRORS, + FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION, + ValidMethod +} from '../constants'; import { validateSchema, logNextRestFrameworkError, type RpcOperationDefinition, logPagesEdgeRuntimeErrorForRoute, - type FormDataContentType + parseMultiPartFormData } from '../shared'; import { type NextApiRequest, type NextApiResponse } from 'next/types'; import { + type FormDataContentType, type BaseOptions, type OpenApiOperation, type OpenApiPathItem @@ -16,10 +21,6 @@ import { type RpcClient } from '../client/rpc-client'; import { type NextRequest } from 'next/server'; import { getPathsFromRpcRoute } from '../shared/paths'; -const FORM_DATA_CONTENT_TYPES_THAT_SUPPORT_VALIDATION: FormDataContentType[] = [ - 'application/x-www-form-urlencoded' -]; - export const rpcApiRoute = < T extends Record> >( @@ -85,7 +86,7 @@ export const rpcApiRoute = < if (bodySchema) { if (contentType === 'application/json') { - const { valid, errors } = validateSchema({ + const { valid, errors, data } = validateSchema({ schema: bodySchema, obj: req.body }); @@ -98,6 +99,8 @@ export const rpcApiRoute = < return; } + + req.body = data; } if ( @@ -106,27 +109,46 @@ export const rpcApiRoute = < ) ) { if ( - !( - req.body instanceof FormData || - req.body instanceof URLSearchParams - ) + contentType === 'multipart/form-data' && + !(req.body instanceof FormData) ) { - res.status(415).json({ - message: `${DEFAULT_ERRORS.invalidRequestBody} Expected a FormData or URLSearchParams.` + // 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 }); - return; - } + if (!valid) { + res.status(400).json({ + message: DEFAULT_ERRORS.invalidRequestBody, + errors + }); - const { valid, errors } = validateSchema({ - schema: bodySchema, - obj: req.body - }); + return; + } - if (!valid) { + 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, - errors + message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.` }); return; diff --git a/packages/next-rest-framework/src/shared/rpc-operation.ts b/packages/next-rest-framework/src/shared/rpc-operation.ts index 6ca0bfd..394b532 100644 --- a/packages/next-rest-framework/src/shared/rpc-operation.ts +++ b/packages/next-rest-framework/src/shared/rpc-operation.ts @@ -7,13 +7,11 @@ import { type ZodFormSchema, type BaseOptions, type OpenApiOperation, - type TypedFormData + type TypedFormData, + type BaseContentType, + type FormDataContentType } from '../types'; -type BaseContentType = 'application/json' | 'application/x-www-form-urlencoded'; - -export type FormDataContentType = 'application/x-www-form-urlencoded'; - interface InputObject { contentType?: ContentType; body?: ContentType extends FormDataContentType diff --git a/packages/next-rest-framework/src/shared/schemas.ts b/packages/next-rest-framework/src/shared/schemas.ts index 597df03..c72e9e2 100644 --- a/packages/next-rest-framework/src/shared/schemas.ts +++ b/packages/next-rest-framework/src/shared/schemas.ts @@ -21,7 +21,8 @@ const zodSchemaValidator = ({ return { valid: data.success, - errors + errors, + data: data.success ? data.data : null }; }; diff --git a/packages/next-rest-framework/src/shared/utils.ts b/packages/next-rest-framework/src/shared/utils.ts index b0fa235..6ab7be9 100644 --- a/packages/next-rest-framework/src/shared/utils.ts +++ b/packages/next-rest-framework/src/shared/utils.ts @@ -1,7 +1,46 @@ +import { type NextApiRequest } from 'next/types'; import { ValidMethod } from '../constants'; +import { Formidable } from 'formidable'; +import { readFileSync } from 'fs'; export const isValidMethod = (x: unknown): x is ValidMethod => Object.values(ValidMethod).includes(x as ValidMethod); export const capitalizeFirstLetter = (str: string) => str[0]?.toUpperCase() + str.slice(1); + +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/types.ts b/packages/next-rest-framework/src/types.ts index dd49554..6078f92 100644 --- a/packages/next-rest-framework/src/types.ts +++ b/packages/next-rest-framework/src/types.ts @@ -167,6 +167,15 @@ export type AnyContentTypeWithAutocompleteForMostCommonOnes = 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, { 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 ac963cf..f5fdd9a 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 @@ -230,6 +230,52 @@ describe('rpcRoute', () => { }); }); + 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([ + { + status: 200, + contentType: 'application/json', + schema + } + ]) + .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 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 249f428..91dd0cd 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 @@ -322,6 +322,53 @@ describe('apiRoute', () => { 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', + 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({ method: ValidMethod.GET diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ce5952..99502e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,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 @@ -141,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 @@ -3624,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: @@ -5597,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 @@ -6641,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'} @@ -7048,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: