Skip to content

Commit

Permalink
Validation & response serialization improvements (#152)
Browse files Browse the repository at this point in the history
* 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.

* Add 10s timeout for form parsing

* Improve RPC route response serialization

This change serializes Blob data for API routes
and creates a JSON object from form responses,
although these are rare cases.
  • Loading branch information
blomqma authored Mar 26, 2024
1 parent c35d411 commit ec00287
Show file tree
Hide file tree
Showing 15 changed files with 266 additions and 52 deletions.
30 changes: 30 additions & 0 deletions apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -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
});
})
});
8 changes: 2 additions & 6 deletions apps/example/src/app/api/v2/route-with-query-params/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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') ?? ''
});
})
});
Original file line number Diff line number Diff line change
@@ -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);
})
});
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
8 changes: 8 additions & 0 deletions apps/example/src/pages/api/v1/rpc/[operationId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import {
} from '@/actions';
import { rpcApiRoute } from 'next-rest-framework';

// Body parser must be disabled when parsing multipart/form-data requests with pages router.
// A recommended way is to create a separate RPC API route for multipart/form-data requests.
// export const config = {
// api: {
// bodyParser: false
// }
// };

const handler = rpcApiRoute({
getTodos,
getTodoById,
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/next-rest-framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
67 changes: 55 additions & 12 deletions packages/next-rest-framework/src/app-router/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
openApiPath?: OpenApiPathItem;
}
) => {
const handler = async (req: NextRequest, context: { params: BaseParams }) => {
const handler = async (
_req: NextRequest,
context: { params: BaseParams }
) => {
try {
const operation = Object.entries(operations).find(
([_operationId, operation]) => operation.method === req.method
([_operationId, operation]) => operation.method === _req.method
)?.[1];

if (!operation) {
Expand All @@ -49,7 +52,15 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
const { input, handler, middleware1, middleware2, middleware3 } =
operation;

let reqClone = req.clone() as NextRequest;
const _reqClone = _req.clone() as NextRequest;

let reqClone = new NextRequest(_reqClone.url, {
method: _reqClone.method,
headers: _reqClone.headers
});

reqClone.json = async () => await _req.clone().json();
reqClone.formData = async () => await _req.clone().formData();

let middlewareOptions: BaseOptions = {};

Expand Down Expand Up @@ -94,10 +105,11 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
const {
body: bodySchema,
query: querySchema,
contentType: contentTypeSchema
contentType: contentTypeSchema,
params: paramsSchema
} = input;

const contentType = req.headers.get('content-type')?.split(';')[0];
const contentType = reqClone.headers.get('content-type')?.split(';')[0];

if (contentTypeSchema && contentType !== contentTypeSchema) {
return NextResponse.json(
Expand All @@ -109,7 +121,7 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
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,
Expand All @@ -129,11 +141,12 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
}

reqClone = new NextRequest(reqClone.url, {
...reqClone,
method: reqClone.method,
headers: reqClone.headers,
body: JSON.stringify(data)
});

reqClone.json = async () => data;
} catch {
return NextResponse.json(
{
Expand All @@ -150,7 +163,7 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
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,
Expand All @@ -171,7 +184,6 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(

// Inject parsed for data to JSON body.
reqClone = new NextRequest(reqClone.url, {
...reqClone,
method: reqClone.method,
headers: reqClone.headers,
body: JSON.stringify(data)
Expand Down Expand Up @@ -203,7 +215,9 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
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) {
Expand All @@ -230,9 +244,38 @@ export const route = <T extends Record<string, RouteOperationDefinition>>(
});

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
});
}
}
Expand Down
20 changes: 9 additions & 11 deletions packages/next-rest-framework/src/app-router/rpc-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
import {
validateSchema,
logNextRestFrameworkError,
type RpcOperationDefinition
type RpcOperationDefinition,
parseRpcOperationResponseJson
} from '../shared';
import {
type FormDataContentType,
Expand Down Expand Up @@ -194,21 +195,18 @@ export const rpcRoute = <
);
}

const parseRes = (res: unknown): BodyInit => {
if (
res instanceof ReadableStream ||
res instanceof ArrayBuffer ||
res instanceof Blob ||
res instanceof FormData ||
res instanceof URLSearchParams
) {
const parseRes = async (res: unknown): Promise<BodyInit> => {
if (res instanceof ReadableStream || res instanceof Blob) {
return res;
}

return JSON.stringify(res);
const parsed = await parseRpcOperationResponseJson(res);
return JSON.stringify(parsed);
};

return new NextResponse(parseRes(res), {
const json = await parseRes(res);

return new NextResponse(json, {
status: 200,
headers: {
'Content-Type': 'application/json'
Expand Down
3 changes: 2 additions & 1 deletion packages/next-rest-framework/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 29 additions & 3 deletions packages/next-rest-framework/src/pages-router/rpc-api-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
validateSchema,
logNextRestFrameworkError,
type RpcOperationDefinition,
logPagesEdgeRuntimeErrorForRoute
logPagesEdgeRuntimeErrorForRoute,
parseRpcOperationResponseJson
} from '../shared';
import { type NextApiRequest, type NextApiResponse } from 'next/types';
import {
Expand Down Expand Up @@ -117,7 +118,7 @@ export const rpcApiRoute = <
// Parse multipart/form-data into a FormData object.
try {
req.body = await parseMultiPartFormData(req);
} catch (e) {
} catch {
res.status(400).json({
message: `${DEFAULT_ERRORS.invalidRequestBody} Failed to parse form data.`
});
Expand Down Expand Up @@ -166,7 +167,32 @@ export const rpcApiRoute = <
return;
}

res.status(200).json(_res);
if (_res instanceof Blob) {
const reader = _res.stream().getReader();
res.setHeader('Content-Type', 'application/octet-stream');

res.setHeader(
'Content-Disposition',
`attachment; filename="${_res.name}"`
);

const pump = async () => {
await reader.read().then(async ({ done, value }) => {
if (done) {
res.end();
return;
}

res.write(value);
await pump();
});
};

await pump();
}

const json = await parseRpcOperationResponseJson(_res);
res.status(200).json(json);
} catch (error) {
logNextRestFrameworkError(error);
res.status(400).json({ message: DEFAULT_ERRORS.unexpectedError });
Expand Down
4 changes: 4 additions & 0 deletions packages/next-rest-framework/src/shared/form-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const parseMultiPartFormData = async (req: NextApiRequest) =>
await new Promise<FormData>((resolve, reject) => {
const form = new Formidable();

setTimeout(() => {
reject(new Error('Form parsing timeout.'));
}, 10000);

form.parse(req, (err, fields, files) => {
if (err) {
reject(err);
Expand Down
14 changes: 14 additions & 0 deletions packages/next-rest-framework/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,17 @@ export const isValidMethod = (x: unknown): x is ValidMethod =>

export const capitalizeFirstLetter = (str: string) =>
str[0]?.toUpperCase() + str.slice(1);

export const parseRpcOperationResponseJson = async (res: unknown) => {
if (res instanceof FormData || res instanceof URLSearchParams) {
const body: Record<string, FormDataEntryValue> = {};

for (const [key, value] of res.entries()) {
body[key] = value;
}

return body;
}

return res;
};
Loading

0 comments on commit ec00287

Please sign in to comment.