lukeautry / tsoa

Build OpenAPI-compliant REST APIs using TypeScript and Node
MIT License
3.56k stars 503 forks source link

One working example showing a controller serving an image from a stream or buffer #1686

Closed ManfredLange closed 1 month ago

ManfredLange commented 1 month ago

Sorting

I've also used standard internet search, Github Copilot and Perplexity.ai. I also consulted the documentation.

Expected Behavior

Current Behavior

Possible Solution

Steps to Reproduce

      1. 4.

Context (Environment)

Version of the library: 6.4.0 (@tsoa/client and @tsoa/runtime) Version of NodeJS: 20.17.0

I'm using pnpm but I don't think that has any material relevance.

Detailed Description

I'm trying to find one single and complete example for the following:

  1. File is stored locally in the file system
  2. Controller has a method/function that handles a GET request to get that file.
  3. The method/function returns the buffer/file and sets headers appropriately

Here is an implementation that does most of it but returns it to the browser as a json object, not the image itself:

   @Get('{imageId}')
   @Response<Buffer>('200', 'image/png')
   public async getImageById(imageId: string): Promise<Buffer | undefined> {
      const imageDirectory = this._environment.blogPostsDirectory;
      const imagePath = join(imageDirectory, imageId);

      try {
         console.log(`loc 240923-0745: reading image from ${imagePath}`);
         const image = await fs.promises.readFile(imagePath);
         this.setHeader('Content-Type', 'image/png');
         this.setHeader('Content-Length', image.length.toString());
         return image;
      } catch (error) {
         console.log(`loc 240923-0746: image ${imagePath} not found`);
         this.setStatus(404);
         return undefined;
      }
   }

It appears to me that I am overlooking something completely obvious but I haven't been able to figure it out just yet.

Perhaps someone who has more experience with TSOA than me can point me in the right direction. To me it appears that downloading a file is something quite common, so I am hoping that TSOA offers some adquate solution for this.

Thank you!

Breaking change?

ManfredLange commented 1 month ago

Since this was a blocker for me, I continued my search. This time I used the ChatGPT model "o1-preview" for a conversation. After some suggestions with compile errors, I managed to get the following code out of it which appears to work as desired. I'm sharing this here, in case someone else has a similar issue. Note that this is for TSOA 6.4.0.

import { Controller, Get, Route, Response, Path, SuccessResponse } from '@tsoa/runtime';
import { inject } from 'inversify';
import { Types } from '../config/ioc.types';
import { IEnvironment } from '../config/IEnvironment';
import path, { join } from 'path';
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import { Readable } from 'stream';
import { provide } from 'inversify-binding-decorators';

@Route('/2024-09-06/images')
@provide(ImageController)
export class ImageController extends Controller {
   public constructor(
      @inject(Types.IEnvironment) environment: IEnvironment,
   ) {
      super();
      this._environment = environment;
   }

   @Get('{imageId}')
   @SuccessResponse('200', 'OK')
   @Response(404, 'Image not found')
   public async getImage(
      @Path() imageId: string,
   ): Promise<Readable> {
      // Securely construct the full path to the image
      const imageDirectory = this._environment.blogPostsDirectory;
      const imagePath = join(imageDirectory, imageId);

      // Prevent directory traversal attacks
      if (!imagePath.startsWith(imageDirectory)) {
         this.setStatus(400);
         throw new Error('Invalid image path');
      }

      // Check if the file exists
      try {
         await stat(imagePath);
      } catch (err) {
         // File does not exist
         this.setStatus(404);
         throw new Error('Image not found');
      }

      // Determine the Content-Type based on the file extension
      const fileExtension = path.extname(imagePath).toLowerCase();
      let contentType = 'image/png'; // Default Content-Type

      switch (fileExtension) {
         case '.jpg':
         case '.jpeg':
            contentType = 'image/jpeg';
            break;
         case '.gif':
            contentType = 'image/gif';
            break;
         case '.bmp':
            contentType = 'image/bmp';
            break;
         case '.svg':
            contentType = 'image/svg+xml';
            break;
         case '.webp':
            contentType = 'image/webp';
            break;
         case '.png':
         default:
            contentType = 'image/png';
            break;
      }

      // Set the Content-Type header
      this.setHeader('Content-Type', contentType);

      // Create a read stream and return it
      return createReadStream(imagePath);
   }
   private _environment: IEnvironment;
}

Note that this code was generated by AI. I merely edited it to some degree to fit into our environment. Happy for you to use it "as-is" but keep in mind that you are responsible and accountable for any bugs that may be left in this code snippet. Happy coding!