Skip to content

Commit dcf40a6

Browse files
reecefenwickReece Fenwickmichaelangeloio
authored
feat(ts-rest/nest): allow TsRestException to be handled in Nest exception filters (#451)
Co-authored-by: Reece Fenwick <reecefenwick@Reeces-MacBook-Pro.local> Co-authored-by: Michael Angelo Rivera <55844504+michaelangeloio@users.noreply.github.com>
1 parent cb7aa3d commit dcf40a6

3 files changed

Lines changed: 130 additions & 27 deletions

File tree

.changeset/funny-taxis-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/nest': minor
3+
---
4+
5+
feat: `@ts-rest/nest` allow TsRestException to be handled by NestJS exception filters

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import {
77
} from './ts-rest-nest-handler';
88
import { z } from 'zod';
99
import {
10+
ArgumentsHost,
1011
Body,
12+
Catch,
1113
Controller,
14+
ExceptionFilter,
1215
Get,
16+
HttpException,
1317
Post,
1418
UploadedFile,
1519
UseInterceptors,
@@ -20,6 +24,7 @@ import { TsRest } from './ts-rest.decorator';
2024
import path = require('path');
2125
import { FileInterceptor } from '@nestjs/platform-express';
2226
import { FastifyAdapter } from '@nestjs/platform-fastify';
27+
import { Response } from 'express';
2328

2429
export type Equal<a, b> = (<T>() => T extends a ? 1 : 2) extends <
2530
T
@@ -1046,6 +1051,87 @@ describe('ts-rest-nest-handler', () => {
10461051
}
10471052
}
10481053
});
1054+
1055+
it('should be able to throw a TsRestException to be handled by an exception filter', async () => {
1056+
const c = initContract();
1057+
1058+
const contract = c.router({
1059+
test: {
1060+
path: '/test',
1061+
method: 'GET',
1062+
responses: {
1063+
200: z.object({
1064+
message: z.string(),
1065+
}),
1066+
400: z.object({
1067+
myError: z.string(),
1068+
}),
1069+
},
1070+
},
1071+
});
1072+
1073+
const httpExceptionObserver = jest.fn();
1074+
1075+
@Catch()
1076+
class HttpExceptionFilter implements ExceptionFilter {
1077+
catch(exception: unknown, host: ArgumentsHost): void {
1078+
httpExceptionObserver(exception);
1079+
1080+
const ctx = host.switchToHttp();
1081+
1082+
const response = ctx.getResponse<Response>();
1083+
1084+
if (exception instanceof HttpException) {
1085+
response
1086+
.status(exception.getStatus())
1087+
.send(exception.getResponse());
1088+
} else {
1089+
response.status(500).send('something went wrong');
1090+
}
1091+
}
1092+
}
1093+
1094+
@Controller()
1095+
class SingleHandlerTestController {
1096+
@TsRestHandler(contract, { validateResponses: true })
1097+
async postRequest() {
1098+
return tsRestHandler(contract, {
1099+
test: async () => {
1100+
throw new TsRestException(contract.test, {
1101+
status: 400,
1102+
body: {
1103+
myError: 'my error',
1104+
},
1105+
});
1106+
},
1107+
});
1108+
}
1109+
}
1110+
1111+
const moduleRef = await Test.createTestingModule({
1112+
controllers: [SingleHandlerTestController],
1113+
}).compile();
1114+
1115+
const app = moduleRef.createNestApplication();
1116+
app.useGlobalFilters(new HttpExceptionFilter());
1117+
1118+
await app.init();
1119+
1120+
const response = await supertest(app.getHttpServer()).get('/test').send();
1121+
1122+
expect(response.status).toBe(400);
1123+
expect(response.body).toEqual({ myError: 'my error' });
1124+
1125+
// verify exception filter received error from ts-rest handler
1126+
expect(httpExceptionObserver).toHaveBeenCalledTimes(1);
1127+
expect(httpExceptionObserver.mock.calls[0][0]).toMatchObject({
1128+
status: 400,
1129+
response: { myError: 'my error' },
1130+
options: {
1131+
cause: expect.any(TsRestException),
1132+
},
1133+
});
1134+
});
10491135
});
10501136

10511137
it('should be able to combine single-handler, multi-handler and vanilla nest controllers', async () => {

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

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class RequestValidationError extends BadRequestException {
4949
public pathParams: z.ZodError | null,
5050
public headers: z.ZodError | null,
5151
public query: z.ZodError | null,
52-
public body: z.ZodError | null
52+
public body: z.ZodError | null,
5353
) {
5454
super({
5555
paramsResult: pathParams,
@@ -61,16 +61,19 @@ export class RequestValidationError extends BadRequestException {
6161
}
6262

6363
export class ResponseValidationError extends InternalServerErrorException {
64-
constructor(public appRoute: AppRoute, public error: z.ZodError) {
64+
constructor(
65+
public appRoute: AppRoute,
66+
public error: z.ZodError,
67+
) {
6568
super(
66-
`[ts-rest] Response validation failed for ${appRoute.method} ${appRoute.path}: ${error.message}`
69+
`[ts-rest] Response validation failed for ${appRoute.method} ${appRoute.path}: ${error.message}`,
6770
);
6871
}
6972
}
7073

7174
export const TsRestHandler = (
7275
appRouterOrRoute: AppRouter | AppRoute,
73-
options: TsRestOptions = {}
76+
options: TsRestOptions = {},
7477
): MethodDecorator => {
7578
const decorators = [];
7679

@@ -80,20 +83,20 @@ export const TsRestHandler = (
8083

8184
if (options.validateResponses !== undefined) {
8285
decorators.push(
83-
SetMetadata(ValidateResponsesSymbol, options.validateResponses)
86+
SetMetadata(ValidateResponsesSymbol, options.validateResponses),
8487
);
8588
}
8689

8790
decorators.push(
8891
SetMetadata(
8992
ValidateRequestHeadersSymbol,
90-
options.validateRequestHeaders ?? true
93+
options.validateRequestHeaders ?? true,
9194
),
9295
SetMetadata(
9396
ValidateRequestQuerySymbol,
94-
options.validateRequestQuery ?? true
97+
options.validateRequestQuery ?? true,
9598
),
96-
SetMetadata(ValidateRequestBodySymbol, options.validateRequestBody ?? true)
99+
SetMetadata(ValidateRequestBodySymbol, options.validateRequestBody ?? true),
97100
);
98101

99102
const isMultiHandler = !isAppRoute(appRouterOrRoute);
@@ -112,7 +115,7 @@ export const TsRestHandler = (
112115
decorators.push(
113116
All(routerPathsArray),
114117
SetMetadata(TsRestAppRouteMetadataKey, appRouterOrRoute),
115-
UseInterceptors(TsRestHandlerInterceptor)
118+
UseInterceptors(TsRestHandlerInterceptor),
116119
);
117120
} else {
118121
const apiDecorator = (() => {
@@ -133,7 +136,7 @@ export const TsRestHandler = (
133136
decorators.push(
134137
apiDecorator,
135138
SetMetadata(TsRestAppRouteMetadataKey, appRouterOrRoute),
136-
UseInterceptors(TsRestHandlerInterceptor)
139+
UseInterceptors(TsRestHandlerInterceptor),
137140
);
138141
}
139142

@@ -146,7 +149,7 @@ type NestHandlerImplementation<T extends AppRouter | AppRoute> =
146149
: {
147150
[K in keyof T]: T[K] extends AppRoute
148151
? (
149-
args: TsRestRequestShape<T[K]>
152+
args: TsRestRequestShape<T[K]>,
150153
) => Promise<ServerInferResponses<T[K]>>
151154
: never;
152155
};
@@ -159,7 +162,7 @@ type NestHandlerImplementation<T extends AppRouter | AppRoute> =
159162
*/
160163
export const tsRestHandler = <T extends AppRouter | AppRoute>(
161164
contract: T,
162-
implementation: NestHandlerImplementation<T>
165+
implementation: NestHandlerImplementation<T>,
163166
) => implementation;
164167

165168
/**
@@ -179,7 +182,7 @@ export const doesUrlMatchContractPath = (
179182
/**
180183
* @example '/posts/1'
181184
*/
182-
url: string
185+
url: string,
183186
): boolean => {
184187
// strip trailing slash
185188
if (contractPath !== '/' && contractPath.endsWith('/')) {
@@ -223,12 +226,12 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
223226

224227
const appRoute = this.reflector.get<AppRoute | AppRouter | undefined>(
225228
TsRestAppRouteMetadataKey,
226-
ctx.getHandler()
229+
ctx.getHandler(),
227230
);
228231

229232
if (!appRoute) {
230233
throw new Error(
231-
'Could not find app router or app route, ensure you are using the @TsRestHandler decorator on your method'
234+
'Could not find app router or app route, ensure you are using the @TsRestHandler decorator on your method',
232235
);
233236
}
234237

@@ -246,7 +249,7 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
246249
return (
247250
doesUrlMatchContractPath(
248251
value.path,
249-
'path' in req ? req.path : req.routeOptions.url
252+
'path' in req ? req.path : req.routeOptions.url,
250253
) && req.method === value.method
251254
);
252255
}
@@ -280,13 +283,13 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
280283
| typeof ValidateResponsesSymbol
281284
| typeof ValidateRequestHeadersSymbol
282285
| typeof ValidateRequestQuerySymbol
283-
| typeof ValidateRequestBodySymbol
286+
| typeof ValidateRequestBodySymbol,
284287
) =>
285288
Boolean(
286289
this.reflector.getAllAndOverride<boolean | undefined>(key, [
287290
ctx.getHandler(),
288291
ctx.getClass(),
289-
])
292+
]),
290293
);
291294

292295
const paramsResult = checkZodSchema(req.params, appRoute.pathParams, {
@@ -305,7 +308,7 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
305308

306309
const bodyResult = checkZodSchema(
307310
req.body,
308-
'body' in appRoute ? appRoute.body : null
311+
'body' in appRoute ? appRoute.body : null,
309312
);
310313

311314
const isValidationEnabled = getMetadataValue(ValidateResponsesSymbol);
@@ -329,7 +332,7 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
329332
!paramsResult.success ? paramsResult.error : null,
330333
isHeadersInvalid ? headersResult.error : null,
331334
isQueryInvalid ? queryResult.error : null,
332-
isBodyInvalid ? bodyResult.error : null
335+
isBodyInvalid ? bodyResult.error : null,
333336
);
334337
}
335338

@@ -350,7 +353,7 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
350353
result = {
351354
status: e.getStatus(),
352355
body: e.getResponse(),
353-
error: true,
356+
error: e,
354357
};
355358
} else {
356359
throw e;
@@ -362,7 +365,16 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
362365
: result;
363366

364367
const responseType = appRoute.responses[result.status];
365-
if (!result.error && isAppRouteOtherResponse(responseType)) {
368+
369+
if (result.error) {
370+
throw new HttpException(
371+
responseAfterValidation.body,
372+
responseAfterValidation.status,
373+
{
374+
cause: result.error,
375+
},
376+
);
377+
} else if (!result.error && isAppRouteOtherResponse(responseType)) {
366378
if ('setHeader' in res) {
367379
res.setHeader('content-type', responseType.contentType);
368380
} else {
@@ -372,15 +384,15 @@ export class TsRestHandlerInterceptor implements NestInterceptor {
372384

373385
res.status(responseAfterValidation.status);
374386
return responseAfterValidation.body;
375-
})
387+
}),
376388
);
377389
}
378390
}
379391

380392
const validateResponse = (
381393
appRoute: AppRoute,
382-
response: { status: number; body?: unknown }
383-
) => {
394+
response: { status: number; body?: unknown },
395+
): { body: unknown; status: number } => {
384396
const { body } = response;
385397

386398
const responseType = appRoute.responses[response.status];
@@ -390,13 +402,13 @@ const validateResponse = (
390402

391403
if (!responseSchema) {
392404
throw new InternalServerErrorException(
393-
`[ts-rest] Couldn't find a response schema for ${response.status} on route ${appRoute.path}`
405+
`[ts-rest] Couldn't find a response schema for ${response.status} on route ${appRoute.path}`,
394406
);
395407
}
396408

397409
const responseValidation = checkZodSchema(
398410
body,
399-
appRoute.responses[response.status]
411+
appRoute.responses[response.status],
400412
);
401413

402414
if (!responseValidation.success) {

0 commit comments

Comments
 (0)