Skip to content

Commit 068822d

Browse files
committed
feat: Add multipart/form-data
1 parent 6032990 commit 068822d

21 files changed

Lines changed: 447 additions & 94 deletions

File tree

.changeset/shaggy-kings-collect.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/express': minor
4+
'@ts-rest/nest': minor
5+
'@ts-rest/react-query': minor
6+
---
7+
8+
Add support for multipart/form-data

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,6 @@ web-build/
5858

5959
apps/example-expo-e2e/artifacts
6060
apps/expo-e2e/artifacts
61+
62+
# example apps upload directory
63+
uploads/

apps/docs/docs/core/errors.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const routerBasic = c.router({
1717
});
1818
```
1919

20-
## Client
20+
## Client Errors
2121

2222
The default fetch client has support for this,
2323

@@ -33,6 +33,8 @@ if (status === 200) {
3333
}
3434
```
3535

36+
### Client Error Typing
37+
3638
:::info
3739

3840
The typed response includes the typed status codes along with any other possible statuses

apps/docs/docs/core/fetch.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ export const client = initClient(router, {
99
});
1010
```
1111

12+
### Query
13+
1214
**Query** against the contract, a _query_ is a function that does a GET request to the api.
1315

1416
```typescript
1517
const { data } = await client.posts.get();
1618
```
1719

20+
### Mutate
21+
1822
**Mutate** against the contract, a _mutation_ is a function that does a POST, PUT, PATCH or DELETE request to the api.
1923

2024
```typescript
@@ -39,7 +43,7 @@ if (status === 200) {
3943
}
4044
```
4145

42-
Return type
46+
### Return type
4347

4448
```typescript
4549
const data: {

apps/docs/docs/core/form-data.mdx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import Tabs from '@theme/Tabs';
2+
import TabItem from '@theme/TabItem';
3+
4+
# multipart/form-data
5+
6+
ts-rest supports multipart/form-data requests, this is useful for uploading files or working with FormData from a form.
7+
8+
## Contract
9+
10+
The contract implementation is the same as any other mutation, however, `contentType` must be set to `multipart/form-data` and the `body` must be a `FormData-compatible` object (one level deep, no weird nested structures!).
11+
12+
```ts
13+
const c = initContract();
14+
15+
export const postsApi = c.router({
16+
updatePostThumbnail: {
17+
method: 'POST',
18+
path: '/posts/:id/thumbnail',
19+
contentType: 'multipart/form-data', // <- Only difference
20+
body: c.body<{ thumbnail: File }>(), // <- Use File type in here
21+
responses: {
22+
200: z.object({
23+
uploadedFile: z.object({
24+
name: z.string(),
25+
size: z.number(),
26+
type: z.string(),
27+
}),
28+
}),
29+
400: z.object({
30+
message: z.string(),
31+
}),
32+
},
33+
},
34+
});
35+
```
36+
37+
## Client
38+
39+
If your query utilizes multipart/form-data, ts-rest allows you to choose from FormData or a type safe object, the latter is recommended in most cases, just make sure you don't make a nested object.
40+
41+
```ts
42+
// client.ts
43+
44+
const App = () => {
45+
const [thumbnail, setThumbnail] = React.useState<File | null>(null);
46+
47+
return (
48+
<div>
49+
<input
50+
multiple={false}
51+
type="file"
52+
onChange={(e) => setThumbnail(e.target.files?.[0] || null)}
53+
/>
54+
<button
55+
onClick={() => {
56+
if (file) {
57+
apiClient.uploadFile({
58+
body: {
59+
thumbnail: file, // <- typed body with "File" type
60+
},
61+
});
62+
}
63+
}}
64+
>
65+
Upload
66+
</button>
67+
</div>
68+
);
69+
};
70+
```
71+
72+
## Server - Express
73+
74+
With Express it is recommend to use the `multer` package to handle the multipart/form-data requests.
75+
76+
ts-rest offers some nice types to help with this, however, we're leaving this up to you to implement with middleware outside of ts-rest.
77+
78+
- `file` is typed as `unknown` <- BYO middleware
79+
- `files` is typed as `unknown` <- BYO middleware
80+
- `body` has had any `File` types removed (so other types are still there)
81+
82+
```ts
83+
const s = initServer();
84+
85+
const postsRouter = s.router(postsApi, {
86+
updatePostThumbnail: async ({ file, files, body }) => {
87+
const thumbnail = file as Express.Multer.File;
88+
89+
return {
90+
status: 200,
91+
body: {
92+
message: `File ${thumbnail.originalname} successfully!`,
93+
},
94+
};
95+
},
96+
});
97+
98+
const app = express();
99+
100+
app.use(cors());
101+
102+
// File upload
103+
app.post(postsApi.updatePostThumbnail.path, upload.single('thumbnail'));
104+
```
105+
106+
## Server - Nest
107+
108+
With Nest this is a pretty simple implementation, due to the extensible Decorator driven approach of Nest, you're able to utilize you're favourite multipart/form-data middleware, in this case we're following <a href="https://docs.nestjs.com/techniques/file-upload">https://docs.nestjs.com/techniques/file-upload</a> from Nest.
109+
110+
- `body` has had any `File` types removed (so other types are still there)
111+
112+
```ts
113+
// nest
114+
@Controller()
115+
export class AppController implements ControllerShape {
116+
@Api(s.route.updateUserAvatar)
117+
@UseInterceptors(FileInterceptor('avatar'))
118+
async updateUserAvatar(
119+
@ApiDecorator() { params: { id } }: RouteShape['updateUserAvatar'],
120+
@UploadedFile() avatar: Express.Multer.File
121+
) {
122+
return {
123+
status: 200 as const,
124+
body: {
125+
message: `Updated user ${id}'s avatar with ${avatar.originalname}`,
126+
},
127+
};
128+
}
129+
}
130+
```

apps/docs/sidebars.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const sidebars = {
4040
{ type: 'doc', id: 'core/core' },
4141
{ type: 'doc', id: 'core/fetch' },
4242
{ type: 'doc', id: 'core/errors' },
43+
{ type: 'doc', id: 'core/form-data' },
4344
],
4445
},
4546
{

apps/example-microservice/posts-service/src/main.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
import * as express from 'express';
77
import * as cors from 'cors';
8+
import * as multer from 'multer';
9+
10+
const upload = multer({ dest: 'uploads/' });
811

912
import { postsApi } from '@ts-rest/example-microservice/util-posts-api';
1013
import { createExpressEndpoints, initServer } from '@ts-rest/express';
@@ -44,12 +47,25 @@ const postsRouter = s.router(postsApi, {
4447
],
4548
};
4649
},
50+
updatePostThumbnail: async ({ file }) => {
51+
const thumbnail = file as Express.Multer.File;
52+
53+
return {
54+
status: 200,
55+
body: {
56+
message: `File ${thumbnail.originalname} successfully!`,
57+
},
58+
};
59+
},
4760
});
4861

4962
const app = express();
5063

5164
app.use(cors());
5265

66+
// File upload
67+
app.post(postsApi.updatePostThumbnail.path, upload.single('thumbnail'));
68+
5369
createExpressEndpoints(postsApi, postsRouter, app);
5470

5571
const port = process.env.port || 5003;

apps/example-microservice/users-service/src/app/app.controller.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { Controller, Get } from '@nestjs/common';
1+
import { Controller, UploadedFile, UseInterceptors } from '@nestjs/common';
22
import { usersApi } from '@ts-rest/example-microservice/util-users-api';
33
import { Api, ApiDecorator, initNestServer } from '@ts-rest/nest';
44
import { AppService } from './app.service';
5+
import { FileInterceptor } from '@nestjs/platform-express';
6+
import 'multer';
57

68
const s = initNestServer(usersApi);
79
type ControllerShape = typeof s.controllerShape;
@@ -22,4 +24,18 @@ export class AppController implements ControllerShape {
2224
},
2325
};
2426
}
27+
28+
@Api(s.route.updateUserAvatar)
29+
@UseInterceptors(FileInterceptor('avatar'))
30+
async updateUserAvatar(
31+
@ApiDecorator() { params: { id } }: RouteShape['updateUserAvatar'],
32+
@UploadedFile() avatar: Express.Multer.File
33+
) {
34+
return {
35+
status: 200 as const,
36+
body: {
37+
message: `Updated user ${id}'s avatar with ${avatar.originalname}`,
38+
},
39+
};
40+
}
2541
}

apps/example-microservice/web-app/src/App.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,37 @@ export const App = () => {
88

99
const posts = data?.body || [];
1010

11+
const [file, setFile] = React.useState<File | null>(null);
12+
1113
return (
1214
<div>
1315
<h1>Posts from posts-service</h1>
1416
{posts.map((post) => (
1517
<div key={post.id}>
1618
<h1>{post.title}</h1>
1719
<p>{post.content}</p>
20+
<input
21+
multiple={false}
22+
type="file"
23+
onChange={(e) => setFile(e.target.files?.[0] || null)}
24+
/>
25+
<button
26+
onClick={() => {
27+
if (file) {
28+
postsClient.updatePostThumbnail.mutation({
29+
body: {
30+
thumbnail: file,
31+
data: 'Hey there!',
32+
},
33+
params: {
34+
id: '1',
35+
},
36+
});
37+
}
38+
}}
39+
>
40+
Upload
41+
</button>
1842
</div>
1943
))}
2044
</div>

libs/example-microservice/util-posts-api/src/lib/example-microservice-util-posts-api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,18 @@ export const postsApi = c.router({
3333
}),
3434
},
3535
},
36+
updatePostThumbnail: {
37+
method: 'POST',
38+
path: '/posts/:id/thumbnail',
39+
contentType: 'multipart/form-data',
40+
body: c.body<{ thumbnail: File; data: string }>(),
41+
responses: {
42+
200: z.object({
43+
message: z.string(),
44+
}),
45+
400: z.object({
46+
message: z.string(),
47+
}),
48+
},
49+
},
3650
});

0 commit comments

Comments
 (0)