Skip to content

Commit cb7aa3d

Browse files
feat: Allow Next.js request validation through error handler (#444)
Co-authored-by: michaelangeloio <email@michaelangelo.io>
1 parent 89941a2 commit cb7aa3d

4 files changed

Lines changed: 134 additions & 18 deletions

File tree

‎.changeset/purple-rules-shake.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/next': minor
3+
---
4+
5+
feat: `ts-rest/next` allow customization for zod validation errors

‎apps/docs/docs/next.mdx‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,42 @@ export default createNextRouter(api, router, {
7474
});
7575
```
7676

77+
#### Request Validation Error Handling
78+
79+
The default behavior when validating request schema is to return a 400 status with the corresponding ZodError.
80+
To customize handling of the request validation errors enable the `throwRequestValidation` option.
81+
This option allows you to handle the request validation error in the global `errorHandler` function.
82+
83+
```typescript
84+
export default createNextRouter(api, router, {
85+
throwRequestValidation: true,
86+
errorHandler: (error: unknown, req: NextApiRequest, res: NextApiResponse) => {
87+
if (error instanceof RequestValidationError) {
88+
if (error.body !== null) {
89+
return res
90+
.status(400)
91+
.json({ message: 'Malformed Body', errors: error.body.flatten() });
92+
}
93+
94+
return res.status(400).json({ message: 'Bad Request' });
95+
}
96+
},
97+
});
98+
```
99+
100+
```typescript
101+
export class RequestValidationError extends Error {
102+
constructor(
103+
public pathParams: z.ZodError | null,
104+
public headers: z.ZodError | null,
105+
public query: z.ZodError | null,
106+
public body: z.ZodError | null,
107+
) {
108+
super('[ts-rest] request validation failed');
109+
}
110+
}
111+
```
112+
77113
## Future Work
78114

79115
As this pattern doesn't support a lambda per endpoint, it is planned to provide a helper utility to allow individual endpoints to be created.

‎libs/ts-rest/next/src/lib/ts-rest-next.spec.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { initContract, ResponseValidationError } from '@ts-rest/core';
22
import { NextApiRequest, NextApiResponse } from 'next';
3-
import { createNextRoute, createNextRouter } from './ts-rest-next';
3+
import {
4+
createNextRoute,
5+
createNextRouter,
6+
RequestValidationError,
7+
} from './ts-rest-next';
48
import { z } from 'zod';
59

610
const c = initContract();
@@ -286,6 +290,47 @@ describe('createNextRouter', () => {
286290
});
287291
});
288292

293+
describe('request validation', () => {
294+
it('fails with invalid query type', async () => {
295+
const errorHandler = jest.fn();
296+
const resultingRouter = createNextRouter(contract, nextEndpoint, {
297+
throwRequestValidation: true,
298+
errorHandler,
299+
});
300+
301+
const req = mockReq('/test/100/throw', {
302+
method: 'GET',
303+
query: { field: 42 },
304+
});
305+
306+
await resultingRouter(req, mockRes);
307+
308+
expect(errorHandler).toHaveBeenCalledWith(
309+
expect.any(RequestValidationError),
310+
expect.anything(),
311+
expect.anything(),
312+
);
313+
});
314+
315+
it('does not throw with invalid query type', async () => {
316+
const errorHandler = jest.fn();
317+
const resultingRouter = createNextRouter(contract, nextEndpoint, {
318+
throwRequestValidation: false,
319+
errorHandler,
320+
});
321+
322+
const req = mockReq('/test/100/throw', {
323+
method: 'GET',
324+
query: { field: 42 },
325+
});
326+
327+
await resultingRouter(req, mockRes);
328+
329+
expect(errorHandler).not.toHaveBeenCalled();
330+
expect(mockRes.status).toHaveBeenCalledWith(400);
331+
});
332+
});
333+
289334
it('should handle non-json response types from contract', async () => {
290335
const c = initContract();
291336

‎libs/ts-rest/next/src/lib/ts-rest-next.ts‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ import {
1818
validateResponse,
1919
} from '@ts-rest/core';
2020
import { getPathParamsFromArray } from './path-utils';
21+
import { z } from 'zod';
22+
23+
export class RequestValidationError extends Error {
24+
constructor(
25+
public pathParams: z.ZodError | null,
26+
public headers: z.ZodError | null,
27+
public query: z.ZodError | null,
28+
public body: z.ZodError | null,
29+
) {
30+
super('[ts-rest] request validation failed');
31+
}
32+
}
2133

2234
type AppRouteImplementation<T extends AppRoute> = (
2335
args: ServerInferRequest<T, NextApiRequest['headers']> & {
@@ -177,14 +189,19 @@ export const createNextRouter = <T extends AppRouter>(
177189
options?: {
178190
jsonQuery?: boolean;
179191
responseValidation?: boolean;
192+
throwRequestValidation?: boolean;
180193
errorHandler?: (
181194
err: unknown,
182195
req: NextApiRequest,
183196
res: NextApiResponse
184197
) => void;
185198
}
186199
) => {
187-
const { jsonQuery = false, responseValidation = false } = options || {};
200+
const {
201+
jsonQuery = false,
202+
responseValidation = false,
203+
throwRequestValidation = false
204+
} = options || {};
188205

189206
const combinedRouter = mergeRouterAndImplementation(routes, obj);
190207

@@ -209,35 +226,48 @@ export const createNextRouter = <T extends AppRouter>(
209226
passThroughExtraKeys: true,
210227
});
211228

212-
if (!pathParamsResult.success) {
213-
return res.status(400).json(pathParamsResult.error);
214-
}
215-
216229
const headersResult = checkZodSchema(req.headers, route.headers, {
217230
passThroughExtraKeys: true,
218231
});
219232

220-
if (!headersResult.success) {
221-
return res.status(400).send(headersResult.error);
222-
}
223-
224233
query = jsonQuery
225234
? parseJsonQueryObject(query as Record<string, string>)
226235
: req.query;
227236

228237
const queryResult = checkZodSchema(query, route.query);
229238

230-
if (!queryResult.success) {
231-
return res.status(400).json(queryResult.error);
232-
}
233-
234239
const bodyResult = checkZodSchema(req.body, route.body);
235240

236-
if (!bodyResult.success) {
237-
return res.status(400).json(bodyResult.error);
238-
}
239-
240241
try {
242+
if (
243+
!pathParamsResult.success ||
244+
!headersResult.success ||
245+
!queryResult.success ||
246+
!bodyResult.success
247+
) {
248+
if (throwRequestValidation) {
249+
throw new RequestValidationError(
250+
pathParamsResult.success ? null : pathParamsResult.error,
251+
headersResult.success ? null : headersResult.error,
252+
queryResult.success ? null : queryResult.error,
253+
bodyResult.success ? null : bodyResult.error,
254+
);
255+
}
256+
257+
if (!pathParamsResult.success) {
258+
return res.status(400).json(pathParamsResult.error);
259+
}
260+
if (!headersResult.success) {
261+
return res.status(400).send(headersResult.error);
262+
}
263+
if (!queryResult.success) {
264+
return res.status(400).json(queryResult.error);
265+
}
266+
if (!bodyResult.success) {
267+
return res.status(400).json(bodyResult.error);
268+
}
269+
}
270+
241271
const { body, status } = await route.implementation({
242272
body: bodyResult.data,
243273
query: queryResult.data,

0 commit comments

Comments
 (0)