Skip to content

Commit 08878f0

Browse files
authored
feat(serverless): create type helpers for route-level middleware context (#584)
1 parent d31e947 commit 08878f0

14 files changed

Lines changed: 183 additions & 114 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/serverless': patch
3+
---
4+
5+
Create type helpers for route-level middleware context

.github/workflows/build-test.yml

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
jobs:
99
main:
1010
name: Nx Cloud - Main Job
11-
uses: nrwl/ci/.github/workflows/nx-cloud-main.yml@v0.13.0
11+
uses: nrwl/ci/.github/workflows/nx-cloud-main.yml@v0.14.0
1212
with:
1313
main-branch-name: main
1414
number-of-agents: 3
@@ -21,7 +21,7 @@ jobs:
2121
2222
agents:
2323
name: Nx Cloud - Agents
24-
uses: nrwl/ci/.github/workflows/nx-cloud-agents.yml@v0.13.0
24+
uses: nrwl/ci/.github/workflows/nx-cloud-agents.yml@v0.14.0
2525
with:
2626
number-of-agents: 3
2727

@@ -34,14 +34,11 @@ jobs:
3434
uses: actions/setup-node@v3
3535
with:
3636
node-version: 20
37-
- uses: pnpm/action-setup@v2
37+
- uses: pnpm/action-setup@v3
3838
name: Install pnpm
39-
id: pnpm-install
40-
with:
41-
version: 8.8
4239

4340
- name: 'Install dependencies'
44-
run: pnpm install
41+
run: pnpm install --frozen-lockfile
4542

4643
- name: 'Build ts-rest libs'
4744
run: 'pnpm exec nx run-many --target=build --projects="ts-rest*"'
@@ -67,14 +64,12 @@ jobs:
6764
uses: actions/setup-node@v3
6865
with:
6966
node-version: 20
70-
- uses: pnpm/action-setup@v2
67+
68+
- uses: pnpm/action-setup@v3
7169
name: Install pnpm
72-
id: pnpm-install
73-
with:
74-
version: 8.8
7570

7671
- name: 'Install dependencies'
77-
run: pnpm install
72+
run: pnpm install --frozen-lockfile
7873

7974
- name: 'Build ts-rest libs'
8075
run: 'pnpm exec nx run-many --target=build --projects="ts-rest*"'

.github/workflows/prerelease.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ jobs:
1919
fetch-depth: 0
2020

2121
- name: Install pnpm
22-
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
23-
id: pnpm-install
24-
with:
25-
version: 8.8
26-
run_install: false
22+
uses: pnpm/action-setup@v3
2723

2824
- name: Get pnpm store directory
2925
id: pnpm-cache
@@ -33,7 +29,7 @@ jobs:
3329
name: Setup pnpm cache
3430
with:
3531
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
36-
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
32+
key: "${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}"
3733
restore-keys: |
3834
${{ runner.os }}-pnpm-store-
3935

.github/workflows/release.yml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ jobs:
1919
fetch-depth: 0
2020

2121
- name: Install pnpm
22-
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
23-
id: pnpm-install
24-
with:
25-
version: 8.8
26-
run_install: false
22+
uses: pnpm/action-setup@v3
2723

2824
- name: Get pnpm store directory
2925
id: pnpm-cache
@@ -33,12 +29,12 @@ jobs:
3329
name: Setup pnpm cache
3430
with:
3531
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
36-
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
32+
key: "${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}"
3733
restore-keys: |
3834
${{ runner.os }}-pnpm-store-
3935
4036
- name: Install dependencies
41-
run: pnpm install
37+
run: pnpm install --frozen-lockfile
4238

4339
- name: Get release version
4440
id: release-version

apps/docs/docs/serverless/options.md

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ You can optionally return a response object from the middleware to short-circuit
5252
You can also extend the request object with additional properties that can be accessed in the router handlers in a type-safe manner.
5353

5454
```typescript
55-
import { TsRestRequest, TsRestResponse } from '@ts-rest/serverless';
56-
import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
55+
import { TsRestResponse } from '@ts-rest/serverless';
56+
import { fetchRequestHandler, tsr } from '@ts-rest/serverless/fetch';
5757
import { contract } from './contract';
5858

5959
export default async (request: Request) => {
@@ -62,7 +62,9 @@ export default async (request: Request) => {
6262
contract,
6363
router: {
6464
getPost: async ({ params: { id } }, { request }) => {
65-
const post = prisma.post.findUniqueOrThrow({ where: { id, ownerId: request.userId } });
65+
const post = prisma.post.findUniqueOrThrow({
66+
where: { id, ownerId: request.userId },
67+
});
6668

6769
return {
6870
status: 200,
@@ -72,19 +74,22 @@ export default async (request: Request) => {
7274
},
7375
options: {
7476
requestMiddleware: [
75-
(request: TsRestRequest & { userId: string }) => {
77+
tsr.middleware<{ userId: string }>((request) => {
7678
if (request.headers.get('Authorization')) {
7779
const userId = authenticate(request.headers.get('Authorization'));
7880
if (!userId) {
79-
return TsRestResponse.fromJson({ message: 'Unauthorized' }, { status: 401 });
81+
return TsRestResponse.fromJson(
82+
{ message: 'Unauthorized' },
83+
{ status: 401 },
84+
);
8085
}
8186
request.userId = userId;
8287
}
83-
},
88+
}),
8489
],
8590
},
8691
});
87-
}
92+
};
8893
```
8994

9095
### Global Response Handlers
@@ -93,8 +98,7 @@ You can set global response handlers by using the `responseHandlers` option. Thi
9398
This can be useful for logging, adding headers, etc.
9499

95100
```typescript
96-
import { TsRestRequest } from '@ts-rest/serverless';
97-
import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
101+
import { fetchRequestHandler, tsr } from '@ts-rest/serverless/fetch';
98102
import { contract } from './contract';
99103
import { router } from './router';
100104

@@ -105,9 +109,9 @@ export default async (request: Request) => {
105109
router,
106110
options: {
107111
requestMiddleware: [
108-
(request: TsRestRequest & { time: Date }) => {
112+
tsr.middleware<{ time: Date }>((request) => {
109113
request.time = new Date();
110-
},
114+
}),
111115
],
112116
responseHandlers: [
113117
(response, request) => {
@@ -133,7 +137,7 @@ export default async (request: Request) => {
133137
router: {
134138
getPost: {
135139
middleware: [authenticationMiddleware],
136-
handler: async ({ params: { id } }) => {
140+
handler: async ({ params: { id } }, { request }) => {
137141
const post = prisma.post.findUniqueOrThrow({ where: { id, ownerId: request.userId } });
138142

139143
return {
@@ -146,3 +150,54 @@ export default async (request: Request) => {
146150
});
147151
}
148152
```
153+
154+
If you would like to have different request contexts defined in your global middleware and route-specific middleware,
155+
you can use the `tsr.routeWithMiddleware()` helper function.
156+
Please note, that you will need to manually pass the global request middleware context type,
157+
so it would be a good idea to define it outside your router definition and use that everywhere.
158+
159+
```typescript
160+
import { fetchRequestHandler, tsr } from '@ts-rest/serverless/fetch';
161+
import { contract } from './contract';
162+
163+
type GlobalRequestContext = {
164+
time: Date;
165+
};
166+
167+
export default async (request: Request) => {
168+
return fetchRequestHandler({
169+
request,
170+
contract,
171+
router: {
172+
getPost: tsr.routeWithMiddleware(contract.getPost)<
173+
GlobalRequestContext, // <--- this is the global context
174+
{ userId: string } // <--- this is the route-level context
175+
>({
176+
middleware: [
177+
(request) => {
178+
// do authentication
179+
request.userId = '123';
180+
},
181+
],
182+
handler: async ({ params: { id } }, { request }) => {
183+
const post = prisma.post.findUniqueOrThrow({
184+
where: { id, ownerId: request.userId },
185+
});
186+
187+
return {
188+
status: 200,
189+
body: post,
190+
};
191+
},
192+
}),
193+
},
194+
options: {
195+
requestMiddleware: [
196+
tsr.middleware<GlobalRequestContext>((request) => {
197+
request.time = new Date();
198+
}),
199+
],
200+
},
201+
});
202+
};
203+
```

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

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,15 @@
1-
import { AppRoute, AppRouter } from '@ts-rest/core';
1+
import { AppRouter } from '@ts-rest/core';
22
import { createServerlessRouter } from '../router';
33
import {
4-
AppRouteImplementationOrOptions,
4+
createTsr,
55
RecursiveRouterObj,
66
ServerlessHandlerOptions,
77
} from '../types';
88
import { TsRestRequest } from '../request';
99

1010
export const tsr = {
11-
router: <T extends AppRouter, TPlatformContext = {}, TRequestExtension = {}>(
12-
contract: T,
13-
router: RecursiveRouterObj<T, TPlatformContext, TRequestExtension>,
14-
) => router,
15-
route: <T extends AppRoute, TPlatformContext = {}, TRequestExtension = {}>(
16-
contractEndpoint: T,
17-
route: AppRouteImplementationOrOptions<
18-
T,
19-
TPlatformContext,
20-
TRequestExtension
21-
>,
22-
) => route,
23-
platformContext: <TPlatformContext>() => ({
24-
router: <T extends AppRouter, TRequestExtension = {}>(
25-
contract: T,
26-
router: RecursiveRouterObj<T, TPlatformContext, TRequestExtension>,
27-
) => router,
28-
route: <T extends AppRoute, TRequestExtension = {}>(
29-
contractEndpoint: T,
30-
route: AppRouteImplementationOrOptions<
31-
T,
32-
TPlatformContext,
33-
TRequestExtension
34-
>,
35-
) => route,
36-
}),
11+
...createTsr(),
12+
platformContext: <TPlatformContext>() => createTsr<TPlatformContext>(),
3713
};
3814

3915
export type FetchHandlerOptions<

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

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { initContract } from '@ts-rest/core';
2-
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2 } from 'aws-lambda';
2+
import type {
3+
APIGatewayProxyEvent,
4+
APIGatewayProxyEventV2,
5+
Context,
6+
} from 'aws-lambda';
37
import { parse as parseMultipart, getBoundary } from 'parse-multipart-data';
48
import merge from 'ts-deepmerge';
59
import { PartialDeep } from 'type-fest';
6-
import { createLambdaHandler } from './ts-rest-lambda';
10+
import { createLambdaHandler, tsr } from './ts-rest-lambda';
711
import { z } from 'zod';
812
import * as apiGatewayEventV1 from '../mappers/aws/test-data/api-gateway-event-v1.json';
913
import * as apiGatewayEventV2 from '../mappers/aws/test-data/api-gateway-event-v2.json';
1014
import { TsRestResponse } from '../response';
1115
import { TsRestResponseError } from '../http-error';
16+
import { ApiGatewayEvent } from '../mappers/aws/api-gateway';
1217

1318
const c = initContract();
1419

@@ -112,6 +117,13 @@ const createV2LambdaRequest = (
112117
};
113118

114119
describe('tsRestLambda', () => {
120+
type GlobalRequestExtension = {
121+
context: {
122+
rawEvent: ApiGatewayEvent;
123+
lambdaContext: Context;
124+
};
125+
};
126+
115127
const lambdaHandler = createLambdaHandler(
116128
contract,
117129
{
@@ -187,20 +199,40 @@ describe('tsRestLambda', () => {
187199
: new Blob([new Uint8Array([4, 5, 6, 7])], { type: 'image/gif' }),
188200
};
189201
},
190-
upload: async (_, { request }) => {
191-
const boundary = getBoundary(
192-
request.headers.get('content-type') as string,
193-
);
202+
upload: tsr.routeWithMiddleware(contract.upload)<
203+
GlobalRequestExtension,
204+
{ contentType: string }
205+
>({
206+
middleware: [
207+
async (request, args) => {
208+
request.contentType = request.headers.get('content-type')!;
209+
},
210+
],
211+
handler: async (_, { request, responseHeaders }) => {
212+
const boundary = getBoundary(
213+
request.headers.get('content-type') as string,
214+
);
194215

195-
const bodyBuffer = await request.arrayBuffer();
196-
const parts = parseMultipart(Buffer.from(bodyBuffer), boundary);
197-
const blob = new Blob([parts[0].data], { type: parts[0].type });
216+
const bodyBuffer = await request.arrayBuffer();
217+
const parts = parseMultipart(Buffer.from(bodyBuffer), boundary);
218+
const blob = new Blob([parts[0].data], { type: parts[0].type });
198219

199-
return {
200-
status: 200,
201-
body: blob,
202-
};
203-
},
220+
responseHeaders.set(
221+
'x-content-type-echo',
222+
request.contentType.toString(),
223+
);
224+
225+
responseHeaders.set(
226+
'x-is-base64-encoded-echo',
227+
request.context.rawEvent.isBase64Encoded.toString(),
228+
);
229+
230+
return {
231+
status: 200,
232+
body: blob,
233+
};
234+
},
235+
}),
204236
},
205237
{
206238
jsonQuery: true,
@@ -209,6 +241,11 @@ describe('tsRestLambda', () => {
209241
origin: ['http://localhost'],
210242
credentials: true,
211243
},
244+
requestMiddleware: [
245+
tsr.middleware<GlobalRequestExtension>(async (request, lambdaArgs) => {
246+
request.context = lambdaArgs;
247+
}),
248+
],
212249
errorHandler: (error) => {
213250
if (error instanceof Error) {
214251
if (error.message === 'custom-json') {
@@ -670,6 +707,9 @@ describe('tsRestLambda', () => {
670707
'access-control-allow-origin': 'http://localhost',
671708
'content-type': 'text/html',
672709
vary: 'Origin',
710+
'x-content-type-echo':
711+
'multipart/form-data; boundary=---WebKitFormBoundary7MA4YWxkTrZu0gW',
712+
'x-is-base64-encoded-echo': 'true',
673713
},
674714
body: '<html><body><h1>Hello ts-rest!</h1></body></html>',
675715
isBase64Encoded: false,

0 commit comments

Comments
 (0)