Skip to content

Commit 308b966

Browse files
authored
feat: TsRestResponseError can be thrown from any server package (#578)
1 parent 77f23a8 commit 308b966

18 files changed

Lines changed: 678 additions & 228 deletions

File tree

.changeset/smooth-ants-admire.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+
`TsRestResponseError` can be thrown from any server package
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Server Errors
2+
3+
To return errors that are defined in your contract from your server, you can throw a `TsRestResponseError` from anywhere within your code,
4+
and it will be caught by ts-rest and served as a response. It will still go through any response validations that are enabled.
5+
6+
```typescript
7+
import { TsRestResponseError } from '@ts-rest/core';
8+
import { contract } from './contract';
9+
10+
// anywhere in your code
11+
throw new TsRestResponseError(contract.getPost, {
12+
status: 404,
13+
body: { message: 'Not Found' },
14+
});
15+
```
16+
17+
:::caution
18+
19+
Any thrown `TsRestResponseError` will NOT be caught by any error handlers, and will be served as a response straight away.
20+
21+
:::

apps/docs/docs/serverless/options.md

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,33 +20,6 @@ For example, for a Vercel Function that lives inside `api/posts.ts`, you would s
2020
The `cors` option allows you to configure CORS for your handler. The `cors` option can be a boolean (setting it to `false` disables CORS) or an options object.
2121
We use `itty-router` under the hood for routing and CORS handling, so the options are the same as the `itty-router` CORS options. You can find the full list of options [here](https://itty.dev/itty-router/cors#corsoptions).
2222

23-
## Error Handling
24-
25-
To throw HTTP errors from anywhere within your code. You can use the `TsRestHttpError` class. This class takes a status code, a body and optionally a content-type header.
26-
27-
```typescript
28-
import { TsRestHttpError } from '@ts-rest/serverless';
29-
30-
// anywhere in your code
31-
throw new TsRestHttpError(404, { message: 'Not Found' });
32-
33-
// or with a content-type header
34-
throw new TsRestHttpError(404, 'Not Found', 'text/plain');
35-
```
36-
37-
You can also use the `TsRestRouteError` to return a response that is defined in your contract.
38-
39-
```typescript
40-
import { TsRestRouteError } from '@ts-rest/serverless';
41-
import { contract } from './contract';
42-
43-
// anywhere in your code
44-
throw new TsRestRouteError(contract.getPost, {
45-
status: 404,
46-
body: { message: 'Not Found' },
47-
});
48-
```
49-
5023
### Custom Error Handler
5124

5225
The `errorHandler` option allows you to define a custom error handler to handle any uncaught exceptions. The error handler is a function that takes an error and request and optionally returns a response object.

libs/ts-rest/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './lib/server';
99
export * from './lib/response-validation-error';
1010
export * from './lib/unknown-status-error';
1111
export * from './lib/infer-types';
12+
export * from './lib/response-error';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { AppRoute } from './dsl';
2+
import { ServerInferResponses } from './infer-types';
3+
import { HTTPStatusCode } from './status-codes';
4+
5+
export class TsRestResponseError<T extends AppRoute> extends Error {
6+
public statusCode: HTTPStatusCode;
7+
public body: any;
8+
9+
constructor(route: T, response: ServerInferResponses<T>) {
10+
super();
11+
12+
this.statusCode = response.status;
13+
this.body = response.body;
14+
this.name = this.constructor.name;
15+
16+
if (typeof response.body === 'string') {
17+
this.message = response.body;
18+
} else if (
19+
typeof response.body === 'object' &&
20+
response.body !== null &&
21+
'message' in response.body &&
22+
typeof response.body.message === 'string'
23+
) {
24+
this.message = response.body['message'];
25+
} else {
26+
this.message = 'Error';
27+
}
28+
}
29+
}

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

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
3-
import { initContract, ResponseValidationError } from '@ts-rest/core';
3+
import {
4+
initContract,
5+
ResponseValidationError,
6+
TsRestResponseError,
7+
} from '@ts-rest/core';
48
import * as supertest from 'supertest';
59
import * as express from 'express';
610
import { z } from 'zod';
@@ -10,6 +14,8 @@ import * as multer from 'multer';
1014
const upload = multer();
1115

1216
const c = initContract();
17+
const s = initServer();
18+
1319
const postsRouter = c.router({
1420
getPost: {
1521
method: 'GET',
@@ -23,7 +29,6 @@ const postsRouter = c.router({
2329
describe('strict mode', () => {
2430
it('allows unknown responses when not in strict mode', () => {
2531
const cLoose = c.router({ posts: postsRouter });
26-
const s = initServer();
2732

2833
s.router(cLoose, {
2934
posts: {
@@ -42,7 +47,6 @@ describe('strict mode', () => {
4247
{ posts: postsRouter },
4348
{ strictStatusCodes: true },
4449
);
45-
const s = initServer();
4650

4751
s.router(cStrict, {
4852
posts: {
@@ -60,8 +64,6 @@ describe('strict mode', () => {
6064

6165
describe('ts-rest-express', () => {
6266
it('should handle non-json response types from contract', async () => {
63-
const c = initContract();
64-
6567
const contract = c.router({
6668
postIndex: {
6769
method: 'POST',
@@ -98,9 +100,7 @@ describe('ts-rest-express', () => {
98100
},
99101
});
100102

101-
const server = initServer();
102-
103-
const postIndex = server.route(
103+
const postIndex = s.route(
104104
contract.postIndex,
105105
async ({ body: { echoHtml } }) => {
106106
return {
@@ -110,7 +110,7 @@ describe('ts-rest-express', () => {
110110
},
111111
);
112112

113-
const router = server.router(contract, {
113+
const router = s.router(contract, {
114114
postIndex,
115115
getRobots: async () => {
116116
return {
@@ -185,8 +185,6 @@ describe('ts-rest-express', () => {
185185
});
186186

187187
it('should handle no content body', async () => {
188-
const c = initContract();
189-
190188
const contract = c.router({
191189
noContent: {
192190
method: 'POST',
@@ -204,8 +202,7 @@ describe('ts-rest-express', () => {
204202
},
205203
});
206204

207-
const server = initServer();
208-
const router = server.router(contract, {
205+
const router = s.router(contract, {
209206
noContent: async ({ params }) => {
210207
return {
211208
status: params.status,
@@ -239,8 +236,6 @@ describe('ts-rest-express', () => {
239236
});
240237

241238
it('should handle optional url params', async () => {
242-
const c = initContract();
243-
244239
const contract = c.router({
245240
getPosts: {
246241
method: 'GET',
@@ -256,8 +251,7 @@ describe('ts-rest-express', () => {
256251
},
257252
});
258253

259-
const server = initServer();
260-
const router = server.router(contract, {
254+
const router = s.router(contract, {
261255
getPosts: async ({ params }) => {
262256
return {
263257
status: 200,
@@ -289,8 +283,6 @@ describe('ts-rest-express', () => {
289283
});
290284

291285
it('should handle multipart/form-data', async () => {
292-
const c = initContract();
293-
294286
const contract = c.router({
295287
uploadFiles: {
296288
method: 'POST',
@@ -312,8 +304,7 @@ describe('ts-rest-express', () => {
312304
},
313305
});
314306

315-
const server = initServer();
316-
const router = server.router(contract, {
307+
const router = s.router(contract, {
317308
uploadFiles: {
318309
middleware: [upload.any()],
319310
handler: async ({ files, body }) => {
@@ -362,8 +353,6 @@ describe('ts-rest-express', () => {
362353
});
363354

364355
it('allows download image', async () => {
365-
const c = initContract();
366-
367356
const contract = c.router({
368357
getFile: {
369358
method: 'GET',
@@ -379,7 +368,6 @@ describe('ts-rest-express', () => {
379368
},
380369
});
381370

382-
const s = initServer();
383371
const originalFilePath = path.join(__dirname, 'assets/logo.png');
384372

385373
const router = s.router(contract, {
@@ -425,4 +413,59 @@ describe('ts-rest-express', () => {
425413
);
426414
expect(responseImage.headers['content-type']).toEqual('image/png');
427415
});
416+
417+
it('should handle thrown TsRestResponseError', async () => {
418+
const contract = c.router({
419+
getPost: {
420+
method: 'GET',
421+
path: '/posts/:id',
422+
responses: {
423+
200: z.object({
424+
id: z.string(),
425+
}),
426+
404: z.object({
427+
message: z.literal('Not found'),
428+
}),
429+
500: c.noBody(),
430+
},
431+
},
432+
});
433+
434+
const router = s.router(contract, {
435+
getPost: async ({ params: { id } }) => {
436+
if (id === '500') {
437+
throw new TsRestResponseError(contract.getPost, {
438+
status: 500,
439+
body: undefined,
440+
});
441+
}
442+
443+
throw new TsRestResponseError(contract.getPost, {
444+
status: 404,
445+
body: {
446+
message: 'Not found',
447+
},
448+
});
449+
},
450+
});
451+
452+
const app = express();
453+
app.use(express.json());
454+
app.use(express.urlencoded({ extended: true }));
455+
createExpressEndpoints(contract, router, app);
456+
457+
await supertest(app)
458+
.get('/posts/500')
459+
.expect((res) => {
460+
expect(res.status).toEqual(500);
461+
expect(res.text).toEqual('');
462+
});
463+
464+
await supertest(app)
465+
.get('/posts/10')
466+
.expect((res) => {
467+
expect(res.status).toEqual(404);
468+
expect(res.body).toEqual({ message: 'Not found' });
469+
});
470+
});
428471
});

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
AppRouteQuery,
55
AppRouter,
66
checkZodSchema,
7+
HTTPStatusCode,
78
isAppRoute,
89
isAppRouteNoBody,
910
isAppRouteOtherResponse,
1011
parseJsonQueryObject,
12+
TsRestResponseError,
1113
validateResponse,
1214
} from '@ts-rest/core';
1315
import type {
@@ -144,20 +146,32 @@ const initializeExpressRoute = ({
144146
try {
145147
const validationResults = validateRequest(req, res, schema, options);
146148

147-
const result = await handler({
148-
params: validationResults.paramsResult.data as any,
149-
body: validationResults.bodyResult.data as any,
150-
query: validationResults.queryResult.data,
151-
headers: validationResults.headersResult.data as any,
152-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
153-
// @ts-ignore
154-
files: req.files,
155-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
156-
// @ts-ignore
157-
file: req.file,
158-
req: req as any,
159-
res: res,
160-
});
149+
let result: { status: HTTPStatusCode; body: any };
150+
try {
151+
result = await handler({
152+
params: validationResults.paramsResult.data as any,
153+
body: validationResults.bodyResult.data as any,
154+
query: validationResults.queryResult.data,
155+
headers: validationResults.headersResult.data as any,
156+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
157+
// @ts-ignore
158+
files: req.files,
159+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
160+
// @ts-ignore
161+
file: req.file,
162+
req: req as any,
163+
res: res,
164+
});
165+
} catch (e) {
166+
if (e instanceof TsRestResponseError) {
167+
result = {
168+
status: e.statusCode,
169+
body: e.body,
170+
};
171+
} else {
172+
throw e;
173+
}
174+
}
161175

162176
const statusCode = Number(result.status);
163177

0 commit comments

Comments
 (0)