Skip to content

Commit bacc9d1

Browse files
authored
feat: @ts-rest/serverless package (#273)
1 parent 69bafaa commit bacc9d1

61 files changed

Lines changed: 4267 additions & 236 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/metal-crabs-pull.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+
New serverless library for AWS Lambda, Edge runtimes and Next.js-specific Edge runtime
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"extends": ["../../.eslintrc.json"],
3+
"ignorePatterns": ["!**/*"],
4+
"overrides": [
5+
{
6+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7+
"rules": {
8+
"@typescript-eslint/no-unused-vars": ["warn"],
9+
"@typescript-eslint/no-explicit-any": ["warn"]
10+
}
11+
},
12+
{
13+
"files": ["*.ts", "*.tsx"],
14+
"rules": {}
15+
},
16+
{
17+
"files": ["*.js", "*.jsx"],
18+
"rules": {}
19+
}
20+
]
21+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/* eslint-disable */
2+
export default {
3+
displayName: 'example-cloudflare-worker',
4+
preset: '../../jest.preset.js',
5+
globals: {},
6+
testEnvironment: 'node',
7+
transform: {
8+
'^.+\\.[tj]s$': [
9+
'ts-jest',
10+
{
11+
tsconfig: '<rootDir>/tsconfig.spec.json',
12+
},
13+
],
14+
},
15+
moduleFileExtensions: ['ts', 'js', 'html'],
16+
coverageDirectory: '../../coverage/apps/example-cloudflare-worker',
17+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "example-cloudflare-worker",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "apps/example-cloudflare-worker/src",
5+
"projectType": "application",
6+
"targets": {
7+
"build": {
8+
"executor": "nx:run-commands",
9+
"options": {
10+
"command": "tsc -p apps/example-cloudflare-worker/tsconfig.json --noEmit"
11+
}
12+
},
13+
"serve": {
14+
"executor": "nx:run-commands",
15+
"options": {
16+
"command": "wrangler dev apps/example-cloudflare-worker/src/index.ts"
17+
}
18+
},
19+
"lint": {
20+
"executor": "@nx/eslint:lint",
21+
"outputs": ["{options.outputFile}"],
22+
"options": {
23+
"lintFilePatterns": ["apps/example-cloudflare-worker/**/*.ts"]
24+
}
25+
},
26+
"test": {
27+
"executor": "@nrwl/jest:jest",
28+
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
29+
"options": {
30+
"jestConfig": "apps/example-cloudflare-worker/jest.config.ts"
31+
}
32+
}
33+
},
34+
"tags": []
35+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import * as path from 'path';
2+
import concurrently, { ConcurrentlyResult } from 'concurrently';
3+
import * as waitOn from 'wait-on';
4+
5+
jest.setTimeout(30000);
6+
7+
describe('example-cloudflare-worker', () => {
8+
let proc: ConcurrentlyResult;
9+
10+
beforeAll(async () => {
11+
proc = concurrently(
12+
[
13+
{
14+
cwd: path.resolve(__dirname, '../..'),
15+
command: 'pnpm nx serve example-cloudflare-worker',
16+
},
17+
],
18+
{
19+
killOthers: ['failure', 'success'],
20+
},
21+
);
22+
23+
await waitOn({
24+
resources: ['tcp:127.0.0.1:8787'],
25+
});
26+
});
27+
28+
afterAll(() => {
29+
proc.commands[0].kill();
30+
});
31+
32+
it('GET /posts should return an array of posts', async () => {
33+
const response = await fetch('http://127.0.0.1:8787/posts?skip=0&take=10', {
34+
headers: {
35+
'x-api-key': 'foo',
36+
'x-pagination': '5',
37+
},
38+
});
39+
const body = await response.json();
40+
41+
expect(response.status).toBe(200);
42+
expect(body.posts).toHaveLength(2);
43+
expect(body.skip).toStrictEqual(0);
44+
expect(body.take).toStrictEqual(10);
45+
});
46+
47+
it('should error on invalid pagination header', async () => {
48+
const response = await fetch('http://127.0.0.1:8787/posts?skip=0&take=10', {
49+
headers: {
50+
'x-api-key': 'foo',
51+
'x-pagination': 'not a number',
52+
},
53+
});
54+
const body = await response.json();
55+
56+
expect(response.status).toStrictEqual(400);
57+
expect(body).toEqual({
58+
message: 'Request validation failed',
59+
bodyErrors: null,
60+
headerErrors: {
61+
issues: [
62+
{
63+
code: 'invalid_type',
64+
expected: 'number',
65+
message: 'Expected number, received nan',
66+
path: ['x-pagination'],
67+
received: 'nan',
68+
},
69+
],
70+
name: 'ZodError',
71+
},
72+
pathParameterErrors: null,
73+
queryParameterErrors: null,
74+
});
75+
});
76+
77+
it('should error if a required query param is missing', async () => {
78+
const response = await fetch('http://127.0.0.1:8787/posts?skip=0', {
79+
headers: {
80+
'x-api-key': 'foo',
81+
'x-pagination': '5',
82+
},
83+
});
84+
const body = await response.json();
85+
86+
expect(response.status).toStrictEqual(400);
87+
expect(body).toEqual({
88+
message: 'Request validation failed',
89+
bodyErrors: null,
90+
headerErrors: null,
91+
pathParameterErrors: null,
92+
queryParameterErrors: {
93+
issues: [
94+
{
95+
code: 'invalid_type',
96+
expected: 'string',
97+
message: 'Required',
98+
path: ['take'],
99+
received: 'undefined',
100+
},
101+
],
102+
name: 'ZodError',
103+
},
104+
});
105+
});
106+
107+
it('should error if body is incorrect', async () => {
108+
const response = await fetch('http://127.0.0.1:8787/posts', {
109+
method: 'POST',
110+
headers: {
111+
'content-type': 'application/json',
112+
'x-api-key': 'foo',
113+
},
114+
body: JSON.stringify({
115+
title: 'Good title',
116+
content: 123,
117+
}),
118+
});
119+
const body = await response.json();
120+
121+
expect(response.status).toStrictEqual(400);
122+
expect(body).toEqual({
123+
message: 'Request validation failed',
124+
bodyErrors: {
125+
issues: [
126+
{
127+
code: 'invalid_type',
128+
expected: 'string',
129+
message: 'Expected string, received number',
130+
path: ['content'],
131+
received: 'number',
132+
},
133+
],
134+
name: 'ZodError',
135+
},
136+
headerErrors: null,
137+
pathParameterErrors: null,
138+
queryParameterErrors: null,
139+
});
140+
});
141+
142+
it('should error if api key header is missing', async () => {
143+
const response = await fetch('http://127.0.0.1:8787/posts?skip=0&take=10');
144+
const body = await response.json();
145+
146+
expect(response.status).toStrictEqual(400);
147+
expect(body).toEqual({
148+
message: 'Request validation failed',
149+
bodyErrors: null,
150+
headerErrors: {
151+
issues: [
152+
{
153+
code: 'invalid_type',
154+
expected: 'string',
155+
message: 'Required',
156+
path: ['x-api-key'],
157+
received: 'undefined',
158+
},
159+
],
160+
name: 'ZodError',
161+
},
162+
pathParameterErrors: null,
163+
queryParameterErrors: null,
164+
});
165+
});
166+
167+
it('should transform body correctly', async () => {
168+
const response = await fetch('http://127.0.0.1:8787/posts', {
169+
method: 'POST',
170+
headers: {
171+
'content-type': 'application/json',
172+
'x-api-key': 'foo',
173+
},
174+
body: JSON.stringify({
175+
title: 'Title with extra spaces ',
176+
content: 'content',
177+
}),
178+
});
179+
const body = await response.json();
180+
181+
expect(response.status).toStrictEqual(201);
182+
expect(body.title).toStrictEqual('Title with extra spaces');
183+
});
184+
185+
it('should format params using pathParams correctly', async () => {
186+
const response = await fetch('http://127.0.0.1:8787/test/123/name', {
187+
headers: {
188+
'x-api-key': 'foo',
189+
},
190+
});
191+
const body = await response.json();
192+
193+
expect(response.status).toStrictEqual(200);
194+
expect(body).toEqual({
195+
id: 123,
196+
name: 'name',
197+
defaultValue: 'hello world',
198+
});
199+
});
200+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
2+
import { apiBlog, Post } from '@ts-rest/example-contracts';
3+
4+
const mockPostFixtureFactory = (partial: Partial<Post>): Post => ({
5+
id: 'mock-id',
6+
title: `Post`,
7+
content: `Content`,
8+
description: `Description`,
9+
published: true,
10+
tags: ['tag1', 'tag2'],
11+
...partial,
12+
});
13+
14+
export default {
15+
async fetch(request: Request): Promise<Response> {
16+
return fetchRequestHandler({
17+
request,
18+
contract: apiBlog,
19+
router: {
20+
getPost: async ({ params: { id } }) => {
21+
const post = mockPostFixtureFactory({ id });
22+
23+
if (!post) {
24+
return {
25+
status: 404,
26+
body: null,
27+
};
28+
}
29+
30+
return {
31+
status: 200,
32+
body: post,
33+
};
34+
},
35+
getPosts: async ({ query }) => {
36+
const posts = [
37+
mockPostFixtureFactory({ id: '1' }),
38+
mockPostFixtureFactory({ id: '2' }),
39+
];
40+
41+
return {
42+
status: 200,
43+
body: {
44+
posts,
45+
count: 0,
46+
skip: query.skip,
47+
take: query.take,
48+
},
49+
};
50+
},
51+
createPost: async ({ body }) => {
52+
const post = mockPostFixtureFactory(body);
53+
54+
return {
55+
status: 201,
56+
body: post,
57+
};
58+
},
59+
updatePost: async ({ body }) => {
60+
const post = mockPostFixtureFactory(body);
61+
62+
return {
63+
status: 200,
64+
body: post,
65+
};
66+
},
67+
deletePost: async () => {
68+
return {
69+
status: 200,
70+
body: { message: 'Post deleted' },
71+
};
72+
},
73+
testPathParams: async ({ params }) => {
74+
return {
75+
status: 200,
76+
body: {
77+
...params,
78+
shouldDelete: 'foo',
79+
},
80+
};
81+
},
82+
},
83+
options: {
84+
responseValidation: true,
85+
},
86+
});
87+
},
88+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "../../dist/out-tsc",
5+
"module": "commonjs",
6+
"types": []
7+
},
8+
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
9+
"include": ["src/**/*.ts"]
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"files": [],
4+
"include": [],
5+
"references": [
6+
{
7+
"path": "./tsconfig.app.json"
8+
},
9+
{
10+
"path": "./tsconfig.spec.json"
11+
}
12+
]
13+
}

0 commit comments

Comments
 (0)