yushakuJuly 28, 2023
Downloading a file with Nest depends on how you retrieve it from your file storage:
Buffer
use response.send(fileBuffer)
Stream
use fileStream.pipe(response)
This will get the job done easily but you'll loose access to the response during response interceptors. See the LoggingInterceptor
as an example as interceptor.
As an alternative, Nest provides StreamableFile
, which solves the response interceptor problem, and supports both Buffer
and Stream
in one swoop. 🦾
download.controller.ts
import { Controller, Get, Res, StreamableFile, UseInterceptors, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { DownloadService } from './download.service'; import { Response } from 'express'; import { LoggingInterceptor } from 'src/logging.interceptor'; @UseInterceptors(LoggingInterceptor) @Controller('download') @ApiTags('download') export class DownloadController { constructor(private readonly downloadService: DownloadService) {} @Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.send(file); } @Get('stream') stream(@Res() response: Response) { const file = this.downloadService.imageStream(); file.pipe(response); } @Get('streamable') streamable(@Res({ passthrough: true }) response: Response) { const file = this.downloadService.fileStream(); // or // const file = this.downloadService.fileBuffer(); return new StreamableFile(file); // 👈 supports Buffer and Stream } }
download.service.ts
import { Injectable } from '@nestjs/common'; import { createReadStream, readFileSync } from 'fs'; import { join } from 'path'; /** * This service would probably download files from a file storage * like S3, minio etc. */ @Injectable() export class DownloadService { constructor() { //Create a connection to your file storage } imageBuffer() { return readFileSync(join(process.cwd(), 'notiz.png')); } imageStream() { return createReadStream(join(process.cwd(), 'notiz.png')); } fileBuffer() { return readFileSync(join(process.cwd(), 'package.json')); } fileStream() { return createReadStream(join(process.cwd(), 'package.json')); } }
logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { console.log('Before...'); const now = Date.now(); return next.handle().pipe( tap(() => console.log(`After... ${Date.now() - now}ms`)), tap((response) => console.log(response)), // 👈 response is defined only when StreamableFile is used ); } }
Express Response
allows you to modify the response headers based on your needs. This is possible for all three options described above. Very important to note here is to configure the response @Res({ passthrough: true })
when using StreamableFile
, otherwise the response won't end.
Here are some examples of how to customize the response headers for your endpoints or use response.setHeaders(...)
for complete custom headers like caching.
By default application/octet-stream
is set as the Content-Type
. If you know you are returning an image or a document from your endpoint change the content type.
download.controller.ts
@Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.contentType('png'); // response.contentType('image/png'); // response.contentType('image/*'); // response.contentType('application/pdf'); response.send(file); }
Use the header Content-Disposition
to inform if the file should be displayed inline, the default, or downloaded as an attachment. Use response.attachment()
to indicate the file as download and optionally pass a filename
.
// download.controller.ts @Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.contentType('image/png'); response.attachment(); //Provide a filename // response.attachment('notiz.png'); response.send(file); }
Until now, you covered the response part of the Rest API. How about Swagger? Does Swagger already know that the endpoint response is a file? Let's check it out at http://localhost:3000/api
.
Swagger is not aware of the file response and not even your custom content type. There is a way of telling Swagger about it. Can you spot the two decorators for this job? 🧐
The decorators for the job are @ApiResponse
and @ApiProduces
. 🤝
@ApiResponse
is responsible for changing the response schema to a binary
format. Similar to what you had to do for the type-safe file upload.
// download controller.ts import { ApiResponse } from '@nestjs/swagger'; @ApiResponse({ schema: { type: 'string', format: 'binary', }, status: HttpStatus.OK, }) @Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.contentType('image/png'); response.send(file); }
You can easily replace the decorator @ApiResponse
with predefined status codes like @ApiOkResponse
, @ApiCreatedResponse
and skip the status
code option.
Next, use @ApiProduces
to inform Swagger about the response content type. It supports multiple content types and also wildcards (these things */*
, image/*
).
// download.controller.ts import { ApiOkResponse, ApiProduces } from '@nestjs/swagger'; @ApiOkResponse({ schema: { type: 'string', format: 'binary', }, }) @ApiProduces('image/png') @Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.contentType('image/png'); response.send(file); }
Your Swagger types should look as awesome as this. 👇
Great, are you done yet? You are good to go, but there is one optimization you can do for simplifying the applied decorators. Why not create a custom @ApiFileResponse
decorator to finish it up?
Add a new file api-file-response.decorator.ts
and export a function called ApiFileResponse
like the decorator. You'll create a composition of @ApiOkResponse
and @ApiProduces
.
//api-file-response.decorator.ts import { applyDecorators } from '@nestjs/common'; import { ApiOkResponse, ApiProduces } from '@nestjs/swagger'; export function ApiFileResponse(...mimeTypes: string[]) { return applyDecorators( ApiOkResponse({ schema: { type: 'string', format: 'binary', }, }), ApiProduces(...mimeTypes), ); }
That's looking good and you can still pass any content type (mimeTypes
) as you wish. And it is as simple as that. 🤩
// download.controller.ts // Before import { ApiOkResponse, ApiProduces } from '@nestjs/swagger'; @ApiOkResponse({ schema: { type: 'string', format: 'binary', }, }) @ApiProduces('image/png') @Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.contentType('image/png'); response.send(file); } // After import { ApiFileResponse } from './api-file-response.decorator'; @ApiFileResponse('image/png') @Get('buffer') buffer(@Res() response: Response) { const file = this.downloadService.imageBuffer(); response.contentType('image/png'); response.send(file); }