tpschmidt
tpschmidt

Reputation: 2707

NestJS: Image Upload & Serve API

I tried to create an API for uploading & retrieving images with NestJS. Images should be stored on S3.

What I currently have:

Controller

@Post()
@UseInterceptors(FileFieldsInterceptor([
    {name: 'photos', maxCount: 10},
]))
async uploadPhoto(@UploadedFiles() files): Promise<void> {
    await this.s3Service.savePhotos(files.photos)
}


@Get('/:id')
@Header('content-type', 'image/jpeg')
async getPhoto(@Param() params,
               @Res() res) {
    const photoId = PhotoId.of(params.id)
    const photoObject = await this.s3Service.getPhoto(photoId)
    res.send(photoObject)
}

S3Service

async savePhotos(photos: FileUploadEntity[]): Promise<any> {
    return Promise.all(photos.map(photo => {
        const filePath = `${moment().format('YYYYMMDD-hhmmss')}${Math.floor(Math.random() * (1000))}.jpg`
        const params = {
            Body: photo.buffer,
            Bucket: Constants.BUCKET_NAME,
            Key: filePath,
        }
        return new Promise((resolve) => {
            this.client.putObject(params, (err: any, data: any) => {
                if (err) {
                    logger.error(`Photo upload failed [err=${err}]`)
                    ExceptionHelper.throw(ErrorCodes.SERVER_ERROR_UNCAUGHT_EXCEPTION)
                }
                logger.info(`Photo upload succeeded [filePath=${filePath}]`)
                return resolve()
            })
        })
    }))
}

async getPhoto(photoId: PhotoId): Promise<AWS.S3.Body> {
    const object: S3.GetObjectOutput = await this.getObject(S3FileKey.of(`${Constants.S3_PHOTO_PATH}/${photoId.value}`))
        .catch(() => ExceptionHelper.throw(ErrorCodes.RESOURCE_NOT_FOUND_PHOTO)) as S3.GetObjectOutput
    logger.info(JSON.stringify(object.Body))
    return object.Body
}

async getObject(s3FilePath: S3FileKey): Promise<S3.GetObjectOutput> {
    logger.info(`Retrieving object from S3 s3FilePath=${s3FilePath.value}]`)
    return this.client.getObject({
        Bucket: Constants.BUCKET_NAME,
        Key: s3FilePath.value
    }).promise()
        .catch(err => {
            logger.error(`Could not retrieve object from S3 [err=${err}]`)
            ExceptionHelper.throw(ErrorCodes.SERVER_ERROR_UNCAUGHT_EXCEPTION)
        }) as S3.GetObjectOutput
}

The photo object actually ends up in S3, but when I download it I can't open it. Same for the GET => can't be displayed.

What general mistake(s) I'm making here?

Upvotes: 2

Views: 5082

Answers (2)

tpschmidt
tpschmidt

Reputation: 2707

For anyone having the same troubles, I finally figured it out:

I enabled binary support on API Gateway (<your-gateway> Settings -> Binary Media Types -> */*) and then returned all responses from lambda base64 encoded. API Gateway will do the decode automatically before returning the response to the client. With serverless express you can can enable the auto base64 encoding easily at the server creation:

const BINARY_MIME_TYPES = [
    'application/javascript',
    'application/json',
    'application/octet-stream',
    'application/xml',
    'font/eot',
    'font/opentype',
    'font/otf',
    'image/jpeg',
    'image/png',
    'image/svg+xml',
    'text/comma-separated-values',
    'text/css',
    'text/html',
    'text/javascript',
    'text/plain',
    'text/text',
    'text/xml',
]

async function bootstrap() {
    const expressServer = express()
    const nestApp = await NestFactory.create(AppModule, new ExpressAdapter(expressServer))
    await nestApp.init()

    return serverlessExpress.createServer(expressServer, null, BINARY_MIME_TYPES)
}

In the Controller, you're now able to just return the S3 response body:

@Get('/:id')
async getPhoto(@Param() params,
               @Res() res) {
    const photoId = PhotoId.of(params.id)
    const photoObject: S3.GetObjectOutput = await this.s3Service.getPhoto(photoId)
    res
        .set('Content-Type', 'image/jpeg')
        .send(photoObject.Body)
}

Hope this helps somebody!

Upvotes: 1

kamilg
kamilg

Reputation: 743

Not sure what values are you returning to your consumer and which values they use to get the Image again; Could you post how the actual response looks like, what is the request and verify, if the FQDN & Path match? It seems you forgot about ACL as well, i.e. the resources you upload this way are not public-read by default.

BTW you could use aws SDK there:

import { Injectable } from '@nestjs/common'
import * as AWS from 'aws-sdk'
import { InjectConfig } from 'nestjs-config'
import { AwsConfig } from '../../config/aws.config'
import UploadedFile from '../interfaces/uploaded-file'

export const UPLOAD_WITH_ACL = 'public-read'

@Injectable()
export class ImageUploadService {
  s3: AWS.S3
  bucketName
  cdnUrl

  constructor(@InjectConfig() private readonly config) {
    const awsConfig = (this.config.get('aws') || { bucket: '', secretKey: '', accessKey: '', cdnUrl: '' }) as AwsConfig // read from envs
    this.bucketName = awsConfig.bucket
    this.cdnUrl = awsConfig.cdnUrl
    AWS.config.update({
      accessKeyId: awsConfig.accessKey,
      secretAccessKey: awsConfig.secretKey,
    })
    this.s3 = new AWS.S3()
  }

  upload(file: UploadedFile): Promise<string> {
    return new Promise((resolve, reject) => {
      const params: AWS.S3.Types.PutObjectRequest = {
        Bucket: this.bucketName,
        Key: `${Date.now().toString()}_${file.originalname}`,
        Body: file.buffer,
        ACL: UPLOAD_WITH_ACL,
      }
      this.s3.upload(params, (err, data: AWS.S3.ManagedUpload.SendData) => {
        if (err) {
          return reject(err)
        }
        resolve(`${this.cdnUrl}/${data.Key}`)
      })
    })
  }

}

Upvotes: 1

Related Questions