If you've ever used the AWS SDK to upload files to S3, you know how convenient it can be. However, not everyone has the option to leverage AWS services, and that's where DigitalOcean's Spaces comes in. Spaces is an excellent alternative that offers similar functionality and is just as easy to use.

While uploading files to Spaces using a Node.js server is well-documented, resources covering the process with a NestJS server are harder to come by. In this article, I'll walk you through how to seamlessly upload files to DigitalOcean Spaces using the AWS SDK within a NestJS application.

Before we dive in, let's clarify a few assumptions: I'm assuming you're already familiar with NestJS, have experience with DigitalOcean Spaces, and have set up a Spaces instance with the necessary API keys. Additionally, you should have a working NestJS project ready to go.

With those basics covered, let's move on to creating a simple controller that accepts a file from a form-data request body and uploads it to DigitalOcean Spaces. This should be a straightforward exercise and a great way to get comfortable with the process. Let's get started!

1. Creating a Service to Handle File Uploads

One of NestJS's standout features is its strong emphasis on the Dependency Injection (DI) design pattern. DI simplifies managing dependencies in a TypeScript codebase by resolving them automatically based on their types.

For our file upload functionality, we'll create a custom service. Unlike built-in services that NestJS can automatically resolve, custom services require a bit more setup. We'll need to create a custom provider  to ensure our service is correctly instantiated and injected where needed.

Before we dive into the code, let's start by installing the AWS SDK. You can do so with one of the following commands:

in case you are using npm:

// npm
npm install aws-sdk
// yarn
yarn add  aws-sdk

In our codebase, under src, let's create a directory called SpacesModule that contains  a directory called SpacesService that contains 2 files, index.ts and doSpacesService.ts. index.ts will contain the provider and doSpacesService.ts will be the actual service.

// index.ts
import * as AWS from 'aws-sdk';
import { Provider } from '@nestjs/common';

// Unique identifier of the service in the dependency injection layer
export const DoSpacesServiceLib = 'lib:do-spaces-service';

// Creation of the value that the provider will always be returning.
// An actual AWS.S3 instance
const spacesEndpoint = new AWS.Endpoint('fra1.digitaloceanspaces.com');

const S3 = new AWS.S3({
  endpoint: spacesEndpoint.href,
  credentials: new AWS.Credentials({
    accessKeyId: '<put-your-digital-ocean-spaces-key-here>',
    secretAccessKey: '<put-your-digital-ocean-spaces-secret-here>',
  }),
});

// Now comes the provider
export const DoSpacesServicerovider: Provider<AWS.S3> = {
  provide: DoSpacesServiceLib,
  useValue: S3,
};

// This is just a simple interface that represents an uploaded file object 
export interface UploadedMulterFileI {
  fieldname: string;
  originalname: string;
  encoding: string;
  mimetype: string;
  buffer: Buffer;
  size: number;
}

Now, let's create the service with a method called uploadFile

// doSpacesService.ts
import { Inject, Injectable } from '@nestjs/common';
import * as AWS from 'aws-sdk';
import {
  DoSpacesServiceLib,
  DoSpacesServicerovider,
} from './doSpacesService';

// Typical nestJs service
@Injectable()
export class DoSpacesService {
  constructor(@Inject(DoSpacesServiceLib) private readonly s3: AWS.S3) {}

  async uploadFile(file: UploadedMulterFileI) {
    // Precaution to avoid having 2 files with the same name
    const fileName = `${Date.now()}-${
      file.originalname
    }`;

    // Return a promise that resolves only when the file upload is complete
    return new Promise((resolve, reject) => {
      this.s3.putObject(
        {
          Bucket: '<put-here-the-name-of-your-spaces-bucket>',
          Key: fileName,
          Body: file.buffer,
          ACL: 'public-read',
        },
        (error: AWS.AWSError) => {
          if (!error) {
            resolve(`<put-here-the-public-link-to-your-spaces-instance>/${fileName}`);
          } else {
            reject(
              new Error(
                `DoSpacesService_ERROR: ${error.message || 'Something went wrong'}`,
              ),
            );
          }
        },
      );
    });
  }
}

2. As the last pieces of the puzzle, let's create a module to wrap everything and the Controller

Under the SpacesModule directory, let's create 2 files, spaces.module.ts and spaces.controller.ts at this point, our SpacesModule directory looks like this:

src-
    |
    |SpacesModule
                |
                |-SpacesService
                |             |
                |             | doSpacesService.ts
                |             | index.ts
                | spaces.controller.ts
                | spaces.module.ts
                

in spaces.controller.ts let's have the following

import {
  Controller,
  UploadedFile,
  UseInterceptors,
  Post
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { DoSpacesService } from './SpacesService/doSpacesService';
import { DoSpacesServicerovider, UploadedMulterFileI } from './SpacesService';

// just a typical nestJs controller
@Controller('/api/v1/do')
export class CommonController {
  constructor(
    private readonly doSpacesService: DoSpacesService,
  ) {}

  @UseInterceptors(FileInterceptor('file'))
  @Post('spaces')
  async uploadFile(@UploadedFile() file: UploadedMulterFileI) {
    const url = await this.doSpacesService.uploadFile(file);

    return {
      url,
    };
  }
}

in spaces.module.ts let's have the following

import { Module } from '@nestjs/common';
import { SpacesController } from './spaces.controller';
import { DoSpacesService } from './SpacesService/doSpacesService';
import { DoSpacesServicerovider } from './SpacesService';

@Module({
  imports: [],
  controllers: [SpacesController],
  // provide both the service and the custom provider
  providers: [DoSpacesServicerovider, DoSpacesService],
})
export class SpacesModule {}


We've covered all the steps necessary to handle file uploads to Digital Ocean Spaces using a NestJS server and the AWS SDK. All that's left to do now is to add the module we created to the main app module and then send a POST request to the /do/spaces endpoint with the file attached as a form field named file. If everything is set up correctly, you should receive a URL back in the response, and you can check the file on Digital Ocean Spaces to confirm that the upload was successful.

References

  1. NestJs Custom Providers: https://docs.nestjs.com/fundamentals/custom-providers#custom-providers
  2. AWS NPM SDK: https://www.npmjs.com/package/aws-sdk
  3. Digital Ocean Spaces: https://www.digitalocean.com/products/spaces/