Skip to content

Commit 7108c19

Browse files
authored
feat(serverless): add middleware support (#569)
1 parent a089fb1 commit 7108c19

12 files changed

Lines changed: 390 additions & 301 deletions

File tree

.changeset/rude-readers-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/serverless': minor
3+
---
4+
5+
Add middleware support

libs/ts-rest/serverless/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "3.41.2",
44
"dependencies": {
55
"@ts-rest/core": "3.41.2",
6-
"itty-router": "^4.2.2"
6+
"itty-router": "^5.0.9"
77
},
88
"peerDependencies": {
99
"@types/aws-lambda": "^8.10.115",

libs/ts-rest/serverless/src/lib/cors.ts

Lines changed: 0 additions & 135 deletions
This file was deleted.

libs/ts-rest/serverless/src/lib/handlers/ts-rest-fetch.spec.ts

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { initContract } from '@ts-rest/core';
22
import { parse as parseMultipart, getBoundary } from 'parse-multipart-data';
33
import { z } from 'zod';
4+
import { vi } from 'vitest';
45
import { fetchRequestHandler } from './ts-rest-fetch';
6+
import { TsRestRequest } from '../request';
57

68
const c = initContract();
79

@@ -54,8 +56,17 @@ const contract = c.router({
5456
}),
5557
},
5658
},
59+
throw: {
60+
method: 'GET',
61+
path: '/throw',
62+
responses: {
63+
500: c.noBody(),
64+
},
65+
},
5766
});
5867

68+
const mockFn = vi.fn();
69+
5970
const testFetchRequestHandler = (request: Request) => {
6071
return fetchRequestHandler({
6172
contract,
@@ -77,11 +88,14 @@ const testFetchRequestHandler = (request: Request) => {
7788
},
7889
};
7990
},
80-
noContent: async () => {
81-
return {
82-
status: 204,
83-
body: undefined,
84-
};
91+
noContent: {
92+
middleware: [(req) => mockFn(req.foo)],
93+
handler: async () => {
94+
return {
95+
status: 204,
96+
body: undefined,
97+
} as const;
98+
},
8599
},
86100
upload: async (_, { request }) => {
87101
const boundary = getBoundary(
@@ -97,20 +111,37 @@ const testFetchRequestHandler = (request: Request) => {
97111
body: blob,
98112
};
99113
},
114+
throw: async () => {
115+
throw new Error('Test error');
116+
},
100117
},
101118
options: {
102119
jsonQuery: true,
103120
responseValidation: true,
104121
cors: {
105-
origins: ['http://localhost'],
122+
origin: ['http://localhost'],
106123
credentials: true,
107124
},
125+
requestMiddleware: [
126+
(req: TsRestRequest & { foo: string }) => {
127+
req.foo = 'bar';
128+
},
129+
],
130+
responseHandlers: [
131+
(res, req) => {
132+
res.headers.set('x-foo', req.foo);
133+
},
134+
],
108135
},
109136
request,
110137
});
111138
};
112139

113140
describe('fetchRequestHandler', () => {
141+
afterEach(() => {
142+
vi.clearAllMocks();
143+
});
144+
114145
it('should handle GET request', async () => {
115146
const request = new Request('http://localhost/test?foo=bar', {
116147
headers: { origin: 'http://localhost' },
@@ -123,6 +154,7 @@ describe('fetchRequestHandler', () => {
123154
'access-control-allow-origin': 'http://localhost',
124155
'content-type': 'application/json',
125156
vary: 'Origin',
157+
'x-foo': 'bar',
126158
},
127159
});
128160

@@ -148,6 +180,7 @@ describe('fetchRequestHandler', () => {
148180
'access-control-allow-origin': 'http://localhost',
149181
'content-type': 'application/json',
150182
vary: 'Origin',
183+
'x-foo': 'bar',
151184
},
152185
});
153186

@@ -169,12 +202,14 @@ describe('fetchRequestHandler', () => {
169202
'access-control-allow-credentials': 'true',
170203
'access-control-allow-origin': 'http://localhost',
171204
vary: 'Origin',
205+
'x-foo': 'bar',
172206
},
173207
});
174208

175209
expect(response.status).toEqual(expectedResponse.status);
176210
expect(response.headers).toEqual(expectedResponse.headers);
177211
expect(await response.text()).toEqual('');
212+
expect(mockFn).toHaveBeenCalledWith('bar');
178213
});
179214

180215
it('should handle file upload', async () => {
@@ -203,6 +238,7 @@ describe('fetchRequestHandler', () => {
203238
'access-control-allow-origin': 'http://localhost',
204239
'content-type': 'text/html',
205240
vary: 'Origin',
241+
'x-foo': 'bar',
206242
},
207243
},
208244
);
@@ -211,4 +247,52 @@ describe('fetchRequestHandler', () => {
211247
expect(response.headers).toEqual(expectedResponse.headers);
212248
expect(await response.text()).toEqual(await expectedResponse.text());
213249
});
250+
251+
it('should handle validation error', async () => {
252+
const request = new Request('http://localhost/test', {
253+
headers: { origin: 'http://localhost' },
254+
});
255+
256+
const response = await testFetchRequestHandler(request);
257+
const expectedResponse = new Response(
258+
'{"message":"Request validation failed","pathParameterErrors":null,"headerErrors":null,"queryParameterErrors":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["foo"],"message":"Required"}],"name":"ZodError"},"bodyErrors":null}',
259+
{
260+
status: 400,
261+
headers: {
262+
'access-control-allow-credentials': 'true',
263+
'access-control-allow-origin': 'http://localhost',
264+
'content-type': 'application/json',
265+
vary: 'Origin',
266+
'x-foo': 'bar',
267+
},
268+
},
269+
);
270+
271+
expect(response.status).toEqual(expectedResponse.status);
272+
expect(response.headers).toEqual(expectedResponse.headers);
273+
expect(await response.json()).toEqual(await expectedResponse.json());
274+
});
275+
276+
it('should handle 500 response', async () => {
277+
const request = new Request('http://localhost/throw', {
278+
method: 'GET',
279+
headers: { origin: 'http://localhost' },
280+
});
281+
282+
const response = await testFetchRequestHandler(request);
283+
const expectedResponse = new Response(null, {
284+
status: 500,
285+
headers: {
286+
'access-control-allow-credentials': 'true',
287+
'access-control-allow-origin': 'http://localhost',
288+
'content-type': 'application/json',
289+
vary: 'Origin',
290+
'x-foo': 'bar',
291+
},
292+
});
293+
294+
expect(response.status).toEqual(expectedResponse.status);
295+
expect(response.headers).toEqual(expectedResponse.headers);
296+
expect(await response.json()).toEqual({ message: 'Server Error' });
297+
});
214298
});
Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,34 @@
11
import { AppRouter } from '@ts-rest/core';
2-
import { createServerlessRouter, serverlessErrorHandler } from '../router';
2+
import { createServerlessRouter } from '../router';
33
import { RecursiveRouterObj, ServerlessHandlerOptions } from '../types';
44
import { TsRestRequest } from '../request';
55

66
export const tsr = {
7-
router: <T extends AppRouter>(
7+
router: <T extends AppRouter, TRequestExtension>(
88
contract: T,
9-
router: RecursiveRouterObj<T, {}>,
9+
router: RecursiveRouterObj<T, {}, TRequestExtension>,
1010
) => router,
1111
};
1212

13-
export const fetchRequestHandler = <T extends AppRouter>({
13+
export type FetchHandlerOptions<TRequestExtension = {}> =
14+
ServerlessHandlerOptions<{}, TRequestExtension>;
15+
16+
export const fetchRequestHandler = <T extends AppRouter, TRequestExtension>({
1417
contract,
1518
router,
1619
options = {},
1720
request,
1821
}: {
1922
contract: T;
20-
router: RecursiveRouterObj<T, {}>;
21-
options: ServerlessHandlerOptions;
23+
router: RecursiveRouterObj<T, {}, TRequestExtension>;
24+
options: FetchHandlerOptions<TRequestExtension>;
2225
request: Request;
2326
}) => {
24-
const serverlessRouter = createServerlessRouter<T, {}>(
27+
const serverlessRouter = createServerlessRouter<T, {}, TRequestExtension>(
2528
contract,
2629
router,
27-
options,
30+
options as ServerlessHandlerOptions,
2831
);
2932
const tsRestRequest = new TsRestRequest(request);
30-
return serverlessRouter.handle(tsRestRequest, {}).catch(async (err) => {
31-
return serverlessErrorHandler(err, tsRestRequest, options);
32-
});
33+
return serverlessRouter.fetch(tsRestRequest, {});
3334
};

0 commit comments

Comments
 (0)