Skip to content

Commit e490cf3

Browse files
lukemoralesGabrola
andauthored
feat: add response validation functionality to express, next and nest (#121)
* feat: run `zod` validation on response interceptor * refactor: get app router from request context and throw server error * feat: make response parsing opt-in * feat: infer proper types for `initNestServer` * cleanup some code and add tests * feat: response validation for express and next * docs: add docs for response validation * chore: changeset * fix: failing tests * refactor: use @TsRest for everything, deprecate @Api * fix: unused import --------- Co-authored-by: Youssef Gaber <1728215+Gabrola@users.noreply.github.com>
1 parent 44dfe90 commit e490cf3

31 files changed

Lines changed: 695 additions & 169 deletions

.changeset/silent-foxes-rescue.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/express': minor
4+
'@ts-rest/nest': minor
5+
'@ts-rest/next': minor
6+
---
7+
8+
- Added server-side response validation feature
9+
- Deprecated `@Api` decorator, use `@TsRest` instead

apps/docs/docs/core/form-data.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ With Nest this is a pretty simple implementation, due to the extensible Decorato
113113
// nest
114114
@Controller()
115115
export class AppController implements NestControllerInterface<typeof c> {
116-
@Api(s.route.updateUserAvatar)
116+
@TsRest(s.route.updateUserAvatar)
117117
@UseInterceptors(FileInterceptor('avatar'))
118118
async updateUserAvatar(
119119
@TsRestRequest() { params: { id } }: RequestShapes['updateUserAvatar'],

apps/docs/docs/express.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,13 @@ To handle JSON query parameters, you can use the `jsonQuery` option.
2626
```typescript
2727
createExpressEndpoints(router, completeRouter, app, { jsonQuery: true });
2828
```
29+
30+
### Response Validation
31+
32+
To enable response parsing and validation, you can use the `validateResponses` option.
33+
If there is a corresponding response Zod schema defined in the contract for the returned status code, the response will be parsed and validated.
34+
If validation fails a `ResponseValidationError` will be thrown causing a 500 response to be returned.
35+
36+
```typescript
37+
createExpressEndpoints(router, completeRouter, app, { validateResponses: true });
38+
```

apps/docs/docs/nest.md

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ As opposed to the Express implementation, where we can infer types from a functi
44

55
```typescript
66
import {
7-
Api,
87
nestControllerContract,
98
NestControllerInterface,
109
NestRequestShapes,
10+
TsRest,
1111
TsRestRequest,
1212
} from '@ts-rest/nest';
1313

@@ -18,7 +18,7 @@ type RequestShapes = NestRequestShapes<typeof c>;
1818
export class PostController implements NestControllerInterface<typeof c> {
1919
constructor(private readonly postService: PostService) {}
2020

21-
@Api(c.getPost)
21+
@TsRest(c.getPost)
2222
async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
2323
const post = await this.postService.getPost(id);
2424

@@ -37,38 +37,56 @@ To implement a nested contract, you can simply call `nestControllerContract(cont
3737

3838
Having the controller class implement `NestControllerInterface` ensures that your controller implements all the routes defined in the contract. In addition, it ensures the type safety of the responses returned.
3939

40-
The `@Api` decorator takes the route, defines the path and method for the controller route.
40+
The `@TsRest` decorator takes the route, defines the path and method for the controller route and also configures ts-rest options.
4141

4242
The `@TsRestRequest` decorator takes the contract route defined in the `@Api` decorator, and returns the parsed and validated (if using Zod) request params, query and body.
4343

4444
As Typescript cannot infer class method parameter types from an implemented interface, we need to explicitly define the type for the request parameter using the `NestRequestShapes` type.
4545

46-
## JSON Query Parameters
46+
## Configuration
47+
48+
To configure certain ts-rest options you can use the `@TsRest` decorator on either your Controller or use the existing `@Api` decorator.
49+
50+
### JSON Query Parameters
4751

48-
To handle JSON query parameters, you can use the `@JsonQuery()` decorator on either your Controller classes or individual endpoint methods.
52+
To handle JSON query parameters, pass the `jsonQuery` option to the `@TsRest` decorator on your entire controller.
4953

5054
```typescript
5155
@Controller()
52-
@JsonQuery()
56+
@TsRest({ jsonQuery: true })
5357
export class PostController implements NestControllerInterface<typeof c> {}
5458
```
5559

56-
The method decorator can be useful to override the controller's behaviour on a per-endpoint basis.
60+
You can also use the method decorator to override or configure options for a specific route.
5761

5862
```typescript
5963
@Controller()
60-
@JsonQuery()
64+
@TsRest({ jsonQuery: true })
6165
export class PostController implements NestControllerInterface<typeof c> {
6266
constructor(private readonly postService: PostService) {}
6367

64-
@Api(s.route.getPost)
65-
@JsonQuery(false)
68+
@TsRest(s.route.getPost, { jsonQuery: false })
6669
async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
6770
// ...
6871
}
6972
}
7073
```
7174

75+
### Response Validation
76+
77+
You can enable response validation by passing the `validateResponses` option to the `@TsRest()` decorator.
78+
This will enable response parsing and validation for a returned status code, if there is a corresponding response Zod schema defined in the contract.
79+
This is useful for ensuring absolute safety that your controller is returning the correct response types as well as stripping any extra properties.
80+
81+
```typescript
82+
@Controller()
83+
@TsRest({ validateResponses: true })
84+
export class PostController implements NestControllerInterface<typeof c> {}
85+
```
86+
87+
If validation fails a `ResponseValidationError` will be thrown causing a 500 response to be returned.
88+
You can catch this error and handle it as you wish by using a [NestJS exception filter](https://docs.nestjs.com/exception-filters).
89+
7290
## Explicit Response Type Safety
7391

7492
In cases where you do not want to implement `NestControllerInterface`.
@@ -91,7 +109,7 @@ type ResponseShapes = NestResponseShapes<typeof c>;
91109
export class PostController {
92110
constructor(private readonly postService: PostService) {}
93111

94-
@Api(c.getPost)
112+
@TsRest(c.getPost)
95113
async getPost(
96114
@TsRestRequest() { params: { id } }: RequestShapes['getPost']
97115
): Promise<ResponseShapes['getPost']> {

apps/docs/docs/next.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,34 @@ To handle JSON query parameters, you can use the `jsonQuery` option.
3838
export default createNextRouter(api, router, { jsonQuery: true });
3939
```
4040

41+
### Response Validation
42+
43+
To enable response parsing and validation, you can use the `validateResponses` option.
44+
If there is a corresponding response Zod schema defined in the contract for the returned status code, the response will be parsed and validated.
45+
If validation fails a `ResponseValidationError` will be thrown causing a 500 response to be returned.
46+
47+
```typescript
48+
export default createNextRouter(api, router, { validateResponses: true });
49+
```
50+
51+
### Error Handling
52+
53+
You can create a global error handler to handle any thrown errors by using the `errorHandler` option.
54+
This includes response validation errors.
55+
56+
```typescript
57+
export default createNextRouter(api, router, {
58+
responseValidation: true,
59+
errorHandler: (error: unknown, req: NextApiRequest, res: NextApiResponse) => {
60+
if (error instanceof ResponseValidationError) {
61+
console.log(error.cause);
62+
return res.status(500).json({ message: 'Internal Server Error' });
63+
}
64+
},
65+
});
66+
```
67+
68+
4169
## Future Work
4270

4371
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.

apps/docs/docs/quickstart.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ type RequestShapes = NestRequestShapes<typeof c>;
111111
export class PostController implements NestControllerInterface<typeof c> {
112112
constructor(private readonly postService: PostService) {}
113113

114-
@Api(c.getPost)
114+
@TsRest(c.getPost)
115115
async getPost(@TsRestRequest() { params: { id } }: RequestShapes['getPost']) {
116116
const post = await this.postService.getPost(id);
117117

118118
return { status: 200 as const, body: post };
119119
}
120120

121-
@Api(c.createPost)
121+
@TsRest(c.createPost)
122122
async createPost(@TsRestRequest() { body }: RequestShapes['createPost']) {
123123
const post = await this.postService.createPost({
124124
title: body.title,

apps/example-express/src/main.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as express from 'express';
2+
import { initContract, ResponseValidationError } from '@ts-rest/core';
23
import { apiBlog, contractTs } from '@ts-rest/example-contracts';
34
import { createExpressEndpoints, initServer } from '@ts-rest/express';
5+
import { generateOpenApi } from '@ts-rest/open-api';
46
import * as bodyParser from 'body-parser';
7+
import { Request, Response, NextFunction } from 'express';
58
import { serve, setup } from 'swagger-ui-express';
6-
import { generateOpenApi } from '@ts-rest/open-api';
79
import { mockPostFixtureFactory } from './fixtures';
810
import cors = require('cors');
911
import { tsRouter } from './ts-router';
@@ -78,6 +80,13 @@ const completedRouter = s.router(apiBlog, {
7880
},
7981
});
8082

83+
const validateResponseContact = initContract().router({
84+
testPathParams: {
85+
...apiBlog.testPathParams,
86+
path: '/validate-response/:id/:name',
87+
},
88+
});
89+
8190
const openapi = generateOpenApi(apiBlog, {
8291
info: { title: 'Play API', version: '0.1' },
8392
});
@@ -95,6 +104,30 @@ app.get('/test', (req, res) => {
95104

96105
createExpressEndpoints(apiBlog, completedRouter, app);
97106
createExpressEndpoints(contractTs, tsRouter, app, { jsonQuery: true });
107+
createExpressEndpoints(
108+
validateResponseContact,
109+
s.router(validateResponseContact, {
110+
testPathParams: async ({ params, query }) => {
111+
return {
112+
status: 200,
113+
body: {
114+
...params,
115+
...query,
116+
},
117+
};
118+
},
119+
}),
120+
app,
121+
{ responseValidation: true }
122+
);
123+
124+
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
125+
if (err instanceof ResponseValidationError) {
126+
console.error(err.cause);
127+
}
128+
129+
next(err);
130+
});
98131

99132
const port = process.env.port || 3333;
100133
const server = app.listen(port, () => {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as supertest from 'supertest';
2+
import app from '../main';
3+
4+
const superTestApp = supertest(app);
5+
6+
describe('Posts Endpoints w/ Response Validation', () => {
7+
it('should include default value and removes extra field', async () => {
8+
const res = await superTestApp.get('/validate-response/123/name?field=foo');
9+
10+
expect(res.status).toStrictEqual(200);
11+
expect(res.body).toStrictEqual({
12+
id: 123,
13+
name: 'name',
14+
defaultValue: 'hello world',
15+
});
16+
});
17+
18+
it('fails with invalid field', async () => {
19+
const res = await superTestApp.get('/validate-response/2000/name');
20+
21+
expect(res.status).toStrictEqual(500);
22+
expect(res.body).toStrictEqual({});
23+
});
24+
});

apps/example-microservice/users-service/src/app/app.controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Controller, UploadedFile, UseInterceptors } from '@nestjs/common';
22
import { usersApi } from '@ts-rest/example-microservice/util-users-api';
33
import {
4-
Api,
54
TsRestRequest,
65
nestControllerContract,
76
NestControllerInterface,
87
NestRequestShapes,
8+
TsRest,
99
} from '@ts-rest/nest';
1010
import { AppService } from './app.service';
1111
import { FileInterceptor } from '@nestjs/platform-express';
@@ -18,7 +18,7 @@ type RequestShapes = NestRequestShapes<typeof c>;
1818
export class AppController implements NestControllerInterface<typeof c> {
1919
constructor(private readonly appService: AppService) {}
2020

21-
@Api(c.getUser)
21+
@TsRest(c.getUser)
2222
async getUser(@TsRestRequest() { params: { id } }: RequestShapes['getUser']) {
2323
return {
2424
status: 200 as const,
@@ -30,7 +30,7 @@ export class AppController implements NestControllerInterface<typeof c> {
3030
};
3131
}
3232

33-
@Api(c.updateUserAvatar)
33+
@TsRest(c.updateUserAvatar)
3434
@UseInterceptors(FileInterceptor('avatar'))
3535
async updateUserAvatar(
3636
@TsRestRequest() { params: { id } }: RequestShapes['updateUserAvatar'],

apps/example-nest/src/app/post-json-query.controller.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { Controller } from '@nestjs/common';
22
import { apiBlog } from '@ts-rest/example-contracts';
33
import {
4-
Api,
54
TsRestRequest,
6-
JsonQuery,
5+
TsRest,
76
nestControllerContract,
87
NestControllerInterface,
98
NestRequestShapes,
@@ -24,14 +23,14 @@ const c = nestControllerContract({
2423
});
2524
type RequestShapes = NestRequestShapes<typeof c>;
2625

27-
@JsonQuery()
26+
@TsRest({ jsonQuery: true })
2827
@Controller()
2928
export class PostJsonQueryController
3029
implements NestControllerInterface<typeof c>
3130
{
3231
constructor(private readonly postService: PostService) {}
3332

34-
@Api(c.getPosts)
33+
@TsRest(c.getPosts)
3534
async getPosts(
3635
@TsRestRequest()
3736
{ query: { take, skip, search } }: RequestShapes['getPosts']

0 commit comments

Comments
 (0)