Skip to content

Commit 820658a

Browse files
damieng57Damien GAIGAmichaelangeloio
authored
feat: allow file downloads with express (#473)
Co-authored-by: Damien GAIGA <damien.gaiga.contractor@arcelormittal.com> Co-authored-by: Michael Angelo Rivera <55844504+michaelangeloio@users.noreply.github.com>
1 parent 6d2b5cb commit 820658a

4 files changed

Lines changed: 79 additions & 0 deletions

File tree

.changeset/dry-planes-sin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/express': minor
3+
---
4+
5+
feat `@ts-rest/express`: Allow file downloads with Express
2.23 KB
Loading

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createExpressEndpoints, initServer } from './ts-rest-express';
33
import * as supertest from 'supertest';
44
import * as express from 'express';
55
import { z } from 'zod';
6+
import * as fs from 'node:fs';
7+
import path = require('path');
68

79
const c = initContract();
810
const postsRouter = c.router({
@@ -174,3 +176,70 @@ describe('ts-rest-express', () => {
174176
);
175177
});
176178
});
179+
180+
describe('download', () => {
181+
it('allows download image', async () => {
182+
const c = initContract();
183+
184+
const contract = c.router({
185+
getFile: {
186+
method: 'GET',
187+
path: `/image`,
188+
headers: z.object({
189+
'Content-Type': z.string().optional(),
190+
'Content-disposition': z.string().optional(),
191+
}),
192+
responses: {
193+
200: z.unknown(),
194+
},
195+
summary: 'Get an image',
196+
},
197+
});
198+
199+
const s = initServer();
200+
const originalFilePath = path.join(__dirname, 'assets/logo.png');
201+
202+
const router = s.router(contract, {
203+
getFile: async ({ res }) => {
204+
res.setHeader('Content-type', 'image/png');
205+
206+
return {
207+
status: 200,
208+
body: fs.createReadStream(originalFilePath),
209+
};
210+
},
211+
});
212+
213+
const app = express();
214+
215+
app.use(express.json());
216+
app.use(express.urlencoded({ extended: true }));
217+
218+
createExpressEndpoints(contract, router, app, {
219+
responseValidation: true,
220+
});
221+
222+
app.use(
223+
(
224+
err: any,
225+
req: express.Request,
226+
res: express.Response,
227+
next: express.NextFunction,
228+
) => {
229+
if (err instanceof ResponseValidationError) {
230+
res.status(500).send('Response validation failed');
231+
return;
232+
}
233+
234+
next(err);
235+
},
236+
);
237+
238+
const responseImage = await supertest(app).get('/image');
239+
expect(responseImage.status).toEqual(200);
240+
expect(responseImage.body.toString()).toEqual(fs.readFileSync(originalFilePath, {encoding: 'utf-8'}));
241+
expect(responseImage.headers['content-type']).toEqual(
242+
'image/png',
243+
);
244+
});
245+
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
isAppRouteImplementation,
2525
} from './types';
2626
import { RequestValidationError } from './request-validation-error';
27+
import { Stream } from 'stream';
2728

2829
export const initServer = () => {
2930
return {
@@ -155,6 +156,10 @@ const initializeExpressRoute = ({
155156

156157
const statusCode = Number(result.status);
157158

159+
if (result.body instanceof Stream) {
160+
return result.body.pipe(res.status(result.status));
161+
}
162+
158163
let validatedResponseBody = result.body;
159164

160165
if (options.responseValidation) {

0 commit comments

Comments
 (0)