Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validation & response serialization improvements #152

Merged
merged 3 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading