Skip to main content

Uploads

S3Upload bundle allows you to easily upload files to Amazon S3 by helping you upload files, storing metadata about the files in a separate collection AppFiles and providing resolvers to download the urls.

We are using Apollo Upload scalar to transfer the files through the GraphQL API.

Setup​

npm i -S graphql-upload aws-sdk @bluelibs/x-s3-bundle
import { XS3Bundle } from "@bluelibs/x-s3-bundle";
kernel.addBundle(  new XS3Bundle({    s3: {      accessKeyId: process.env.AWS_S3_KEY_ID,      secretAccessKey: process.env.AWS_S3_SECRET,      region: process.env.AWS_S3_REGION,      bucket: process.env.AWS_S3_BUCKET,      // used to generate the downloadable path, example: https://s3.amazonaws.com/my-bucket      endpoint: process.env.AWS_S3_ENDPOINT,    },  }));

Uploading​

A good tutorial to show-case the full flow can be found here

The typical process is that from your client you have added the Apollo Upload Link. This is by default included in x-ui package so you don't have to worry. What it does is that it transforms the HTTP request into a multi-part form to send the files (if there are any)

import { S3UploadService, AppFilesCollection } from "@bluelibs/x-s3-bundle";
const types = `  type Mutation {    upload(file: Upload!): Boolean  }`;
async function uploadResolver(_, args, ctx) {  // The file is the GQL Upload Scalar  const { file } = args;
  const s3UploadService = ctx.container.get(S3UploadService);
  const appFile = await s3UploadService.upload(file, {    uploadedById: ctx.userId,  });
  return true;}

Now, in most cases what interests you is that downloadable url so the user can access it. To do this we have the following options.

Through S3UploadService:

import { S3UploadService } from "@bluelibs/x-s3-bundle";
const s3UploadService = ctx.container.get(S3UploadService);s3UploadService.getFileURL(appFile._id); // This will return a fully downloadable path

Through Nova:

const appFile = appFiles.queryOne({  $: {    filters: { _id: appFile.id },  },  name: 1,  // This is a reducer that will use the UploadService to give you the url  downloadUrl: 1,});

If you want to upload without GraphQL:

const s3UploadService = ctx.container.get(S3UploadService);const appFile = s3UploadService.doUpload(filename, mimeType, buffer);

Removing Files​

When you remove the file, you would expect that it also gets deleted from the S3 Bucket. You would be correct. If you do:

const appFilesCollection = container.get(AppFilesCollection);appFilesCollection.deleteOne({ _id: appFile._id }); // deletes it from s3 and from the database

Data Models​

export class AppFileGroup {  _id: ObjectID;  name?: string;  files: AppFile[];  filesIds: ObjectID[];}
export class AppFile {  _id: ObjectID;  name: string;  path: string;  size: number;  mimeType: string;
  metadata: object;
  /**   * To have a generic way of linking data   */  resourceId?: ObjectID;  resourceType?: string;
  uploadedBy?: IUser;  uploadedById?: ObjectID;
  /**   * @reducer   */  downloadUrl: string;
  groups?: AppFileGroup[];  createdAt: Date;  updatedAt: Date;}

Real world usage​

Single Uploads Storage​

In GraphQL typing it will look something like this:

type User {  avatar: AppFile}
query me {  avatar {    """    Keep in mind that you should do a Nova query in your "me" resolver if you want this to work as downloadUrl is a reducer.    Otherwise, you could write your own field resolver which uses `FileManagementService` to deliver the downloadUrl    """    downloadUrl  }}

When adding the avatar, after uploading it can look something like this:

import { AppFilesCollection } from "@bluelibs/x-s3-bundle";
// Sample of linking of filesclass UsersCollection extends Collection {  static links = {    //    avatar: {      collection: () => AppFilesCollection,      field: "avatarId",    },  };}

A sample resolver how you would update this:

async function uploadAvatarResolver(_, args, ctx) {  // TODO: check if there is a previous avatar and delete it.
  const s3UploadService = ctx.container.get(S3UploadService);  const appFile = await s3UploadService.upload(args.file, {    uploadedById: ctx.userId,  });
  const usersCollection = ctx.container.get(UsersCollection);  usersCollection.updateOne(    { _id: ctx.userId },    {      $set: {        avatarId: appFile._id,      },    }  );}

Multiple Uploads Storage​

While the single upload solution is straight forward, there will be a lot of cases in which you want to add many files to your models. For example, a comment can have many pictures, a task can have many attachments, and so on. To aid this we created a separate collection called AppFileGroups to aid us:

Let's assume we create our task, and we have fileGroupId linked with AppFileGroups through Nova:

const fileManagementService = ctx.container.get(FileManagementService);const fileGroupId = fileManagementService.newFileGroup(); // This will return a fully downloadable pathtasksCollection.insertOne({  title: "Hello",  fileGroupId,});
// Now when you do  the upload and you have the fileconst appFile = await s3UploadService.upload(args.file, {  uploadedById: ctx.userId,});
fileManagementService.addFileToFileGroup(fileGroupId, appFile._id);
// Now you can query it through Nova:query = `  query {    tasks {      fileGroup {        files {          name          mimeType          downloadUrl        }      }    }  }`;

Deleting a file that it also clears all the file groups containing it. You don't have to worry.

As a concept, a File can belong in multiple FileGroups. If, let's say you want in the future to be smart and reuse the files. If you want to remove the file only from a specific file group and not delete it, just run a MongoDB update and use $pull on fileIds inside AppFileGroups collection.

Customise Collections​

If you want to add new files or new things, you can do that by customising your own models You can by entering your own custom collection and model:

class MyAppFilesCollection extends AppFilesCollection<MyAppFileModel> {}class MyAppFileGroupsCollection extends MyAppFileGroupsCollection<MyAppFileGroupModel> {}
kernel.addBundle(  new XS3Bundle({    // the rest    appFilesCollection: MyAppFilesCollection,    appFileGroupsCollection: MyAppFileGroupsCollection,  }));

Customise Upload Logic​

This can be used to either have multiple uploading buckets, either override and customise the logic.

class ImageS3UploadService extends S3UploadService {  constructor() {    super(NEW_AWS_CONFIG);  }
  upload(upload: Promise<Upload>, extension?: Partial<AppFile>) {    // Do your own thing    // this.doUpload()  }
  public async uploadBuffer(    filename: string,    mimetype: string,    buffer: Buffer  ): Promise<string> {    // upload the file and return an identificator string  }}

Images​

By default this package optimises jpeg and png by using imagemin library. Opt-out of this behavior by using:

new XS3Bundle({  optimizeImages: false,});

At the same time for images we also create 3 thumbnails: large (512x512), medium (256x256), small (128x128). We do this for all images by default and we use sharp behind the scnes. If you want to customize this:

new XS3Bundle({  thumbs: [    // Keep in mind that overriding this will no longer do the defaults `large`, `medium`, `small` thumbs.    {      id: "small",      height: 256,      width: 256,      // (optional) Files can have a context, it will only process this thumb for images in this context. Omit if you want to apply to all contexts      contexts: ["avatars"],      // (optional) Resize options from "sharp"      resizeOptions: {},    },  ],});

Note: context is passed by extension of the app file:

const appFile = await s3UploadService.upload(file, {  uploadedById: ctx.userId,  context: ["avatar"],});
note

When fetching with GraphQL you'll receive a deprecation notice on thumbs.id, that is because you should use type instead of id on your frontend. To ensure you don't mess with Apollo Client cache.

Events​

We expose two events: AfterFileUploadEvent and BeforeFileUploadEvent, each contains data relevant to its processing, you could opt-out completely of custom image processing by doing:

new XS3Bundle({  // Now you can control this by hooking into a BeforeFileUploadEvent  thumbs: [],  optimizeImages: false,});
eventManager.addListener(BeforeFileUploadEvent, (e) => {  const { extension, mimetype, filename, buffer } = e.data;  // Do your own preparation and update extension as you see it fit.});