Skip to content

Commit d763d45

Browse files
lrwlflrwlfdevnet
andauthored
feat: export a Zod schema that matches the request validation error responses (#601)
Co-authored-by: lrwlfdevnet <lrwlfyang@tencent.com>
1 parent 5c257fe commit d763d45

12 files changed

Lines changed: 172 additions & 4 deletions

File tree

.changeset/sharp-kings-listen.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@ts-rest/serverless': minor
3+
'@ts-rest/express': minor
4+
'@ts-rest/fastify': minor
5+
'@ts-rest/core': minor
6+
'@ts-rest/nest': minor
7+
'@ts-rest/next': minor
8+
---
9+
10+
Export `RequestValidationErrorSchema` for default request validation error responses.

libs/ts-rest/core/src/lib/zod-utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,17 @@ export const zodErrorResponse = (
101101
issues: error.issues,
102102
};
103103
};
104+
105+
export const ZodErrorSchema = z.object({
106+
name: z.literal('ZodError'),
107+
issues: z.array(
108+
z
109+
.object({
110+
path: z.array(z.union([z.string(), z.number()])),
111+
message: z.string().optional(),
112+
code: z.nativeEnum(z.ZodIssueCode),
113+
})
114+
// ZodIssuse type are complex and potentially unstable. So we don’t deal with his specific fields other than the common.
115+
.catchall(z.any()),
116+
),
117+
});

libs/ts-rest/express/src/lib/request-validation-error.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ZodErrorSchema } from '@ts-rest/core';
12
import { z } from 'zod';
23

34
export class RequestValidationError extends Error {
@@ -10,3 +11,12 @@ export class RequestValidationError extends Error {
1011
super('[ts-rest] request validation failed');
1112
}
1213
}
14+
15+
export const DefaultRequestValidationErrorSchema = ZodErrorSchema;
16+
17+
export const CombinedRequestValidationErrorSchema = z.object({
18+
pathParameterErrors: ZodErrorSchema.nullable(),
19+
headerErrors: ZodErrorSchema.nullable(),
20+
queryParameterErrors: ZodErrorSchema.nullable(),
21+
bodyErrors: ZodErrorSchema.nullable(),
22+
});

libs/ts-rest/express/src/lib/ts-rest-express.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import * as express from 'express';
1010
import { z } from 'zod';
1111
import { createExpressEndpoints, initServer } from './ts-rest-express';
1212
import * as multer from 'multer';
13+
import {
14+
CombinedRequestValidationErrorSchema,
15+
DefaultRequestValidationErrorSchema,
16+
} from './request-validation-error';
1317

1418
const upload = multer();
1519

@@ -468,4 +472,88 @@ describe('ts-rest-express', () => {
468472
expect(res.body).toEqual({ message: 'Not found' });
469473
});
470474
});
475+
476+
it("should throw default requestValidation error if body doesn't match", async () => {
477+
const contract = c.router({
478+
createPost: {
479+
method: 'POST',
480+
path: '/posts',
481+
body: z.object({
482+
id: z.string(),
483+
content: z.string(),
484+
}),
485+
responses: {
486+
200: c.noBody(),
487+
400: DefaultRequestValidationErrorSchema,
488+
},
489+
},
490+
});
491+
492+
const router = s.router(contract, {
493+
createPost: async () => {
494+
return {
495+
status: 200,
496+
body: undefined,
497+
};
498+
},
499+
});
500+
501+
const app = express();
502+
app.use(express.json());
503+
app.use(express.urlencoded({ extended: true }));
504+
createExpressEndpoints(contract, router, app, {
505+
requestValidationErrorHandler: 'default',
506+
});
507+
508+
await supertest(app)
509+
.post('/posts')
510+
.expect((res) => {
511+
expect(res.status).toEqual(400);
512+
expect(() =>
513+
DefaultRequestValidationErrorSchema.parse(res.body),
514+
).not.toThrowError();
515+
});
516+
});
517+
518+
it("should throw combined requestValidation error if body doesn't match", async () => {
519+
const contract = c.router({
520+
createPost: {
521+
method: 'POST',
522+
path: '/posts',
523+
body: z.object({
524+
id: z.string(),
525+
content: z.string(),
526+
}),
527+
responses: {
528+
200: c.noBody(),
529+
400: DefaultRequestValidationErrorSchema,
530+
},
531+
},
532+
});
533+
534+
const router = s.router(contract, {
535+
createPost: async () => {
536+
return {
537+
status: 200,
538+
body: undefined,
539+
};
540+
},
541+
});
542+
543+
const app = express();
544+
app.use(express.json());
545+
app.use(express.urlencoded({ extended: true }));
546+
createExpressEndpoints(contract, router, app, {
547+
requestValidationErrorHandler: 'combined',
548+
});
549+
550+
await supertest(app)
551+
.post('/posts')
552+
.expect((res) => {
553+
expect(res.status).toEqual(400);
554+
expect(() =>
555+
CombinedRequestValidationErrorSchema.parse(res.body),
556+
).not.toThrowError();
557+
});
558+
});
471559
});

libs/ts-rest/fastify/src/lib/ts-rest-fastify.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { initContract, TsRestResponseError } from '@ts-rest/core';
2-
import { initServer } from './ts-rest-fastify';
2+
import { initServer, RequestValidationErrorSchema } from './ts-rest-fastify';
33
import { z } from 'zod';
44
import fastify from 'fastify';
55
import * as supertest from 'supertest';
@@ -26,6 +26,7 @@ const contract = c.router({
2626
200: z.object({
2727
pong: z.string(),
2828
}),
29+
400: RequestValidationErrorSchema,
2930
},
3031
},
3132
noContent: {
@@ -199,6 +200,9 @@ describe('ts-rest-fastify', () => {
199200
pathParameterErrors: null,
200201
queryParameterErrors: null,
201202
});
203+
expect(() =>
204+
RequestValidationErrorSchema.parse(response.body),
205+
).not.toThrowError();
202206
});
203207

204208
it('should handle no content response', async () => {

libs/ts-rest/fastify/src/lib/ts-rest-fastify.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ServerInferResponses,
1313
TsRestResponseError,
1414
validateResponse,
15+
ZodErrorSchema,
1516
} from '@ts-rest/core';
1617
import * as fastify from 'fastify';
1718
import { z } from 'zod';
@@ -27,6 +28,13 @@ export class RequestValidationError extends Error {
2728
}
2829
}
2930

31+
export const RequestValidationErrorSchema = z.object({
32+
pathParameterErrors: ZodErrorSchema.nullable(),
33+
headerErrors: ZodErrorSchema.nullable(),
34+
queryParameterErrors: ZodErrorSchema.nullable(),
35+
bodyErrors: ZodErrorSchema.nullable(),
36+
});
37+
3038
type AppRouteImplementation<T extends AppRoute> = (
3139
input: ServerInferRequest<T, fastify.FastifyRequest['headers']> & {
3240
request: fastify.FastifyRequest<

libs/ts-rest/nest/src/lib/ts-rest-nest-handler.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { initContract } from '@ts-rest/core';
22
import {
33
doesUrlMatchContractPath,
4+
RequestValidationErrorSchema,
45
TsRestException,
56
tsRestHandler,
67
TsRestHandler,
@@ -201,6 +202,9 @@ describe('ts-rest-nest-handler', () => {
201202
queryResult: null,
202203
paramsResult: null,
203204
});
205+
expect(() =>
206+
RequestValidationErrorSchema.parse(responsePost.body),
207+
).not.toThrowError();
204208
});
205209

206210
it("shouldn't validate body", async () => {

libs/ts-rest/nest/src/lib/ts-rest-nest-handler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
parseJsonQueryObject,
3434
ServerInferResponses,
3535
TsRestResponseError,
36+
ZodErrorSchema,
3637
} from '@ts-rest/core';
3738
import {
3839
TsRestAppRouteMetadataKey,
@@ -63,6 +64,13 @@ export class RequestValidationError extends BadRequestException {
6364
}
6465
}
6566

67+
export const RequestValidationErrorSchema = z.object({
68+
paramsResult: ZodErrorSchema.nullable(),
69+
headersResult: ZodErrorSchema.nullable(),
70+
queryResult: ZodErrorSchema.nullable(),
71+
bodyResult: ZodErrorSchema.nullable(),
72+
});
73+
6674
export class ResponseValidationError extends InternalServerErrorException {
6775
constructor(
6876
public appRoute: AppRoute,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
createNextRouter,
1010
createSingleRouteHandler,
1111
RequestValidationError,
12+
RequestValidationErrorSchema,
1213
} from './ts-rest-next';
1314
import { z } from 'zod';
1415

@@ -29,6 +30,7 @@ const contract = c.router({
2930
query: null,
3031
responses: {
3132
200: c.type<{ id: string }>(),
33+
400: RequestValidationErrorSchema,
3234
404: z.object({
3335
message: z.literal('Not Found'),
3436
}),
@@ -417,6 +419,9 @@ describe('createNextRouter', () => {
417419

418420
expect(errorHandler).not.toHaveBeenCalled();
419421
expect(mockRes.status).toHaveBeenCalledWith(400);
422+
expect(() =>
423+
RequestValidationErrorSchema.parse(jsonMock.mock.calls[0][0]),
424+
).not.toThrowError();
420425
});
421426
});
422427

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ServerInferResponses,
1414
TsRestResponseError,
1515
validateResponse,
16+
ZodErrorSchema,
1617
} from '@ts-rest/core';
1718
import { getPathParamsFromArray } from './path-utils';
1819
import { z } from 'zod';
@@ -28,6 +29,8 @@ export class RequestValidationError extends Error {
2829
}
2930
}
3031

32+
export const RequestValidationErrorSchema = ZodErrorSchema;
33+
3134
type AppRouteImplementation<T extends AppRoute> = (
3235
args: ServerInferRequest<T, NextApiRequest['headers']> & {
3336
req: NextApiRequest;

0 commit comments

Comments
 (0)