Skip to content

Commit

Permalink
Improve path parameter validation
Browse files Browse the repository at this point in the history
This change adds support for validating path
parameters and query parameters separately
also on pages router using the `params` and
´query´ schemas similarly as with app router.

This also adds support for Zod's `describe` method
so that the schema descriptions are included the
OpenAPI spec.
  • Loading branch information
blomqma committed Apr 13, 2024
1 parent 586205f commit 8de41d1
Show file tree
Hide file tree
Showing 23 changed files with 567 additions and 244 deletions.
188 changes: 112 additions & 76 deletions apps/example/public/openapi.json

Large diffs are not rendered by default.

58 changes: 44 additions & 14 deletions apps/example/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { z } from 'zod';
export const getTodos = rpcOperation()
.outputs([
{
body: z.array(todoSchema),
body: z.array(todoSchema).describe('List of TODOs.'),
contentType: 'application/json'
}
])
Expand All @@ -25,17 +25,19 @@ export const getTodos = rpcOperation()
export const getTodoById = rpcOperation()
.input({
contentType: 'application/json',
body: z.string()
body: z.string().describe('TODO name.')
})
.outputs([
{
body: z.object({
error: z.string()
}),
body: z
.object({
error: z.string()
})
.describe('TODO not found.'),
contentType: 'application/json'
},
{
body: todoSchema,
body: todoSchema.describe('TODO response.'),
contentType: 'application/json'
}
])
Expand All @@ -52,9 +54,11 @@ export const getTodoById = rpcOperation()
export const createTodo = rpcOperation()
.input({
contentType: 'application/json',
body: z.object({
name: z.string()
})
body: z
.object({
name: z.string()
})
.describe("New TODO's name.")
})
.outputs([{ body: todoSchema, contentType: 'application/json' }])
.handler(async ({ name }) => {
Expand All @@ -68,8 +72,14 @@ export const deleteTodo = rpcOperation()
body: z.string()
})
.outputs([
{ body: z.object({ error: z.string() }), contentType: 'application/json' },
{ body: z.object({ message: z.string() }), contentType: 'application/json' }
{
body: z.object({ error: z.string() }).describe('TODO not found.'),
contentType: 'application/json'
},
{
body: z.object({ message: z.string() }).describe('TODO deleted message.'),
contentType: 'application/json'
}
])
.handler((id) => {
const todo = MOCK_TODOS.find((t) => t.id === Number(id));
Expand All @@ -86,9 +96,14 @@ export const deleteTodo = rpcOperation()
export const formDataUrlEncoded = rpcOperation()
.input({
contentType: 'application/x-www-form-urlencoded',
body: formSchema // A zod-form-data schema is required.
body: formSchema.describe('Test form description.') // A zod-form-data schema is required.
})
.outputs([{ body: formSchema, contentType: 'application/json' }])
.outputs([
{
body: formSchema.describe('Test form response.'),
contentType: 'application/json'
}
])
.handler((formData) => {
return {
text: formData.get('text')
Expand All @@ -98,13 +113,28 @@ export const formDataUrlEncoded = rpcOperation()
export const formDataMultipart = rpcOperation()
.input({
contentType: 'multipart/form-data',
body: multipartFormSchema // A zod-form-data schema is required.
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: {
description: 'Test form description.',
type: 'object',
properties: {
text: {
type: 'string'
},
file: {
type: 'string',
format: 'binary'
}
}
}
})
.outputs([
{
body: z.custom<File>(),
// The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec.
bodySchema: {
description: 'File response.',
type: 'string',
format: 'binary'
},
Expand Down
4 changes: 3 additions & 1 deletion apps/example/src/app/api/v2/form-data/multipart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const { POST } = route({
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: {
description: 'Test form description.',
type: 'object',
properties: {
text: {
Expand All @@ -29,9 +30,10 @@ export const { POST } = route({
{
status: 200,
contentType: 'application/octet-stream',
body: z.unknown(),
body: z.custom<File>(),
// The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec.
bodySchema: {
description: 'File response.',
type: 'string',
format: 'binary'
}
Expand Down
4 changes: 2 additions & 2 deletions apps/example/src/app/api/v2/form-data/url-encoded/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ export const { POST } = route({
})
.input({
contentType: 'application/x-www-form-urlencoded',
body: formSchema // A zod-form-data schema is required.
body: formSchema.describe('Test form description.') // A zod-form-data schema is required.
})
.outputs([
{
status: 200,
contentType: 'application/octet-stream',
body: formSchema
body: formSchema.describe('Test form response.') // A zod-form-data schema is required.
}
])
.handler(async (req) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
import { TypedNextResponse, route, routeOperation } from 'next-rest-framework';
import { z } from 'zod';

const paramsSchema = z.object({
slug: z.enum(['foo', 'bar', 'baz'])
});

const querySchema = z.object({
total: z.string()
});

export const runtime = 'edge';

export const { GET } = route({
getQueryParams: routeOperation({
getPathParams: routeOperation({
method: 'GET'
})
.input({
contentType: 'application/json',
query: querySchema
params: paramsSchema.describe('Path parameters input.'),
query: querySchema.describe('Query parameters input.')
})
.outputs([
{
status: 200,
contentType: 'application/json',
body: querySchema
body: paramsSchema.merge(querySchema).describe('Parameters response.')
}
])
.handler((req) => {
.handler((req, { params: { slug } }) => {
const query = req.nextUrl.searchParams;

return TypedNextResponse.json({
slug,
total: query.get('total') ?? ''
});
})
Expand Down
30 changes: 0 additions & 30 deletions apps/example/src/app/api/v2/route-with-path-params/[slug]/route.ts

This file was deleted.

20 changes: 16 additions & 4 deletions apps/example/src/app/api/v2/todos/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@ import { z } from 'zod';

export const runtime = 'edge';

const paramsSchema = z
.object({
id: z.string()
})
.describe('TODO ID path parameter.');

export const { GET, DELETE } = route({
getTodoById: routeOperation({
method: 'GET'
})
.input({
params: paramsSchema
})
.outputs([
{
body: todoSchema,
body: todoSchema.describe('TODO response.'),
status: 200,
contentType: 'application/json'
},
{
body: z.string(),
body: z.string().describe('TODO not found.'),
status: 404,
contentType: 'application/json'
}
Expand All @@ -37,14 +46,17 @@ export const { GET, DELETE } = route({
deleteTodo: routeOperation({
method: 'DELETE'
})
.input({
params: paramsSchema
})
.outputs([
{
body: z.string(),
body: z.string().describe('TODO deleted.'),
status: 204,
contentType: 'application/json'
},
{
body: z.string(),
body: z.string().describe('TODO not found.'),
status: 404,
contentType: 'application/json'
}
Expand Down
16 changes: 9 additions & 7 deletions apps/example/src/app/api/v2/todos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const { GET, POST } = route({
{
status: 200,
contentType: 'application/json',
body: z.array(todoSchema)
body: z.array(todoSchema).describe('List of TODOs.')
}
])
.handler(() => {
Expand All @@ -26,26 +26,28 @@ export const { GET, POST } = route({
})
.input({
contentType: 'application/json',
body: z.object({
name: z.string()
})
body: z
.object({
name: z.string()
})
.describe("New TODO's name.")
})
.outputs([
{
status: 201,
contentType: 'application/json',
body: z.string()
body: z.string().describe('New TODO created message.')
},
{
status: 401,
contentType: 'application/json',
body: z.string()
body: z.string().describe('Unauthorized.')
}
])
// Optional middleware logic executed before request validation.
.middleware((req) => {
if (!req.headers.get('very-secure')) {
return TypedNextResponse.json('Unauthorized', {
return TypedNextResponse.json('Unauthorized.', {
status: 401
});
}
Expand Down
4 changes: 3 additions & 1 deletion apps/example/src/pages/api/v1/form-data/multipart/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default apiRoute({
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: {
description: 'Test form description.',
type: 'object',
properties: {
text: {
Expand All @@ -34,9 +35,10 @@ export default apiRoute({
{
status: 200,
contentType: 'application/octet-stream',
body: z.unknown(),
body: z.custom<File>(),
// The binary file cannot described with a Zod schema so we define it by hand for the OpenAPI spec.
bodySchema: {
description: 'File response.',
type: 'string',
format: 'binary'
}
Expand Down
4 changes: 2 additions & 2 deletions apps/example/src/pages/api/v1/form-data/url-encoded/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ export default apiRoute({
})
.input({
contentType: 'application/x-www-form-urlencoded',
body: formSchema // A zod-form-data schema is required.
body: formSchema.describe('Test form description.') // A zod-form-data schema is required.
})
.outputs([
{
status: 200,
contentType: 'application/json',
body: formSchema
body: formSchema.describe('Test form response.')
}
])
.handler((req, res) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@ const paramsSchema = z.object({
slug: z.enum(['foo', 'bar', 'baz'])
});

const querySchema = z.object({
total: z.string()
});

export default apiRoute({
getQueryParams: apiRouteOperation({
getParams: apiRouteOperation({
method: 'GET'
})
.input({
contentType: 'application/json',
query: paramsSchema
params: paramsSchema.describe('Path parameters input.'),
query: querySchema.describe('Query parameters input.')
})
.outputs([
{
status: 200,
contentType: 'application/json',
body: paramsSchema
body: paramsSchema.merge(querySchema).describe('Parameters response.')
}
])
.handler((req, res) => {
Expand Down
Loading

0 comments on commit 8de41d1

Please sign in to comment.