From 1dc6506e9f182032d30bc36d15da26a6c0287434 Mon Sep 17 00:00:00 2001 From: Markus Blomqvist Date: Tue, 26 Mar 2024 20:36:18 +0200 Subject: [PATCH] Fix request body & path param parsing This fixes a bug where the Zod-parsed request body accessed via `req.json()` had potentially incorrect types due to JSON serialization. Now the `json()` method is overridden so that the parsed data is returned as is without the JSON serialization. Additionally, the request path parameters are now also validated when using app router when previously they were used only for providing types for the used path parameters. --- .../v2/route-with-path-params/[slug]/route.ts | 30 +++++++++++ .../api/v2/route-with-query-params/route.ts | 8 +-- .../v1/route-with-path-params/[slug]/index.ts | 26 +++++++++ .../api/v1/route-with-query-params/index.ts | 4 +- docs/docs/api-reference.md | 2 +- packages/next-rest-framework/README.md | 2 +- .../src/app-router/route.ts | 54 +++++++++++++++---- packages/next-rest-framework/src/constants.ts | 3 +- 8 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts create mode 100644 apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts diff --git a/apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts b/apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts new file mode 100644 index 0000000..faa748d --- /dev/null +++ b/apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts @@ -0,0 +1,30 @@ +import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; +import { z } from 'zod'; + +const paramsSchema = z.object({ + slug: z.enum(['foo', 'bar', 'baz']) +}); + +export const runtime = 'edge'; + +export const { GET } = route({ + getPathParams: routeOperation({ + method: 'GET' + }) + .input({ + contentType: 'application/json', + params: paramsSchema + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: paramsSchema + } + ]) + .handler((_req, { params: { slug } }) => { + return TypedNextResponse.json({ + slug + }); + }) +}); 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 f3248d5..084d326 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 @@ -2,9 +2,7 @@ import { TypedNextResponse, route, routeOperation } from 'next-rest-framework'; import { z } from 'zod'; const querySchema = z.object({ - foo: z.string().uuid(), - bar: z.string().optional(), - baz: z.string() + total: z.string() }); export const runtime = 'edge'; @@ -28,9 +26,7 @@ export const { GET } = route({ const query = req.nextUrl.searchParams; return TypedNextResponse.json({ - foo: query.get('foo') ?? '', - bar: query.get('bar') ?? '', - baz: query.get('baz') ?? '' + total: query.get('total') ?? '' }); }) }); diff --git a/apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts b/apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts new file mode 100644 index 0000000..72a483e --- /dev/null +++ b/apps/example/src/pages/api/v1/route-with-path-params/[slug]/index.ts @@ -0,0 +1,26 @@ +import { apiRoute, apiRouteOperation } from 'next-rest-framework'; +import { z } from 'zod'; + +const paramsSchema = z.object({ + slug: z.enum(['foo', 'bar', 'baz']) +}); + +export default apiRoute({ + getQueryParams: apiRouteOperation({ + method: 'GET' + }) + .input({ + contentType: 'application/json', + query: paramsSchema + }) + .outputs([ + { + status: 200, + contentType: 'application/json', + body: paramsSchema + } + ]) + .handler((req, res) => { + res.json(req.query); + }) +}); 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 f122c4d..ed278e5 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 @@ -2,9 +2,7 @@ import { apiRoute, apiRouteOperation } from 'next-rest-framework'; import { z } from 'zod'; const querySchema = z.object({ - foo: z.string().uuid(), - bar: z.string().optional(), - baz: z.string() + total: z.string() }); export default apiRoute({ diff --git a/docs/docs/api-reference.md b/docs/docs/api-reference.md index 4e0fbcf..15eb742 100644 --- a/docs/docs/api-reference.md +++ b/docs/docs/api-reference.md @@ -57,7 +57,7 @@ The route operation input function is used for type-checking, validation and doc | `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` | +| `params` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the path parameters. When the params schema is defined, a request with invalid path parameters will get an error response. Path parameters are parsed using this schema and updated to the request if valid, so the path parameters from the request should always match the schema. | `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. diff --git a/packages/next-rest-framework/README.md b/packages/next-rest-framework/README.md index 0205223..5c0d730 100644 --- a/packages/next-rest-framework/README.md +++ b/packages/next-rest-framework/README.md @@ -736,7 +736,7 @@ The route operation input function is used for type-checking, validation and doc | `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` | +| `params` | A [Zod](https://github.com/colinhacks/zod) schema describing the format of the path parameters. When the params schema is defined, a request with invalid path parameters will get an error response. Path parameters are parsed using this schema and updated to the request if valid, so the path parameters from the request should always match the schema. | `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. diff --git a/packages/next-rest-framework/src/app-router/route.ts b/packages/next-rest-framework/src/app-router/route.ts index fc9bd9a..ee60399 100644 --- a/packages/next-rest-framework/src/app-router/route.ts +++ b/packages/next-rest-framework/src/app-router/route.ts @@ -49,7 +49,11 @@ export const route = >( const { input, handler, middleware1, middleware2, middleware3 } = operation; - let reqClone = req.clone() as NextRequest; + let reqClone = new NextRequest(req.url, { + method: req.method, + headers: req.headers, + body: req.body + }); let middlewareOptions: BaseOptions = {}; @@ -94,7 +98,8 @@ export const route = >( const { body: bodySchema, query: querySchema, - contentType: contentTypeSchema + contentType: contentTypeSchema, + params: paramsSchema } = input; const contentType = req.headers.get('content-type')?.split(';')[0]; @@ -109,7 +114,7 @@ export const route = >( if (bodySchema) { if (contentType === 'application/json') { try { - const json = await req.clone().json(); + const json = await reqClone.json(); const { valid, errors, data } = validateSchema({ schema: bodySchema, @@ -129,11 +134,12 @@ export const route = >( } reqClone = new NextRequest(reqClone.url, { - ...reqClone, method: reqClone.method, headers: reqClone.headers, body: JSON.stringify(data) }); + + reqClone.json = async () => data; } catch { return NextResponse.json( { @@ -150,7 +156,7 @@ export const route = >( FORM_DATA_CONTENT_TYPES.includes(contentType as FormDataContentType) ) { try { - const formData = await req.clone().formData(); + const formData = await reqClone.formData(); const { valid, errors, data } = validateSchema({ schema: bodySchema, @@ -171,7 +177,6 @@ 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) @@ -203,7 +208,9 @@ export const route = >( if (querySchema) { const { valid, errors, data } = validateSchema({ schema: querySchema, - obj: qs.parse(req.nextUrl.search, { ignoreQueryPrefix: true }) + obj: qs.parse(reqClone.nextUrl.search, { + ignoreQueryPrefix: true + }) }); if (!valid) { @@ -230,9 +237,38 @@ export const route = >( }); reqClone = new NextRequest(url, { - ...reqClone, method: reqClone.method, - headers: reqClone.headers + headers: reqClone.headers, + body: reqClone.body + }); + } + + if (paramsSchema) { + const { valid, errors, data } = validateSchema({ + schema: paramsSchema, + obj: context.params + }); + + if (!valid) { + return NextResponse.json( + { + message: DEFAULT_ERRORS.invalidPathParameters, + errors + }, + { + status: 400 + } + ); + } + + context.params = data; + const url = new URL(reqClone.url); + url.search = new URLSearchParams(context.params).toString(); + + reqClone = new NextRequest(url, { + method: reqClone.method, + headers: reqClone.headers, + body: reqClone.body }); } } diff --git a/packages/next-rest-framework/src/constants.ts b/packages/next-rest-framework/src/constants.ts index 7baf818..cb75f85 100644 --- a/packages/next-rest-framework/src/constants.ts +++ b/packages/next-rest-framework/src/constants.ts @@ -9,7 +9,8 @@ export const DEFAULT_ERRORS = { operationNotAllowed: 'Operation not allowed.', invalidRequestBody: 'Invalid request body.', missingRequestBody: 'Missing request body.', - invalidQueryParameters: 'Invalid query parameters.' + invalidQueryParameters: 'Invalid query parameters.', + invalidPathParameters: 'Invalid path parameters.' }; export enum ValidMethod {