Upload images to AWS S3 with Apollo GraphQL

I recently found the need to add an image upload to a form for user avatars. The app I am building uses Apollo GraphQL so here I will share my solution with you.

Client Side form

As in most of my projects, I am using React on the client side. I assume you've already set up Apollo Client successfully on the client, so we just dive right into the image upload.

We are going to need the useMutation hook from @apollo/react-hooks as well as the useDropzone hook from react-dropzone.

The client side component looks like this:

import React, { useCallback, useState, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import gql from "graphql-tag";
import { useMutation } from "@apollo/react-hooks";

import styled from "@emotion/styled";

// just some styled components for the image upload area
const getColor = props => {
  if (props.isDragAccept) {
    return "#00e676";
  }
  if (props.isDragReject) {
    return "#ff1744";
  }
  if (props.isDragActive) {
    return "#2196f3";
  }
  return "#eeeeee";
};

const Container = styled.div`
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px;
  border-width: 2px;
  border-radius: 2px;
  border-color: ${props => getColor(props)};
  border-style: dashed;
  background-color: #fafafa;
  color: #bdbdbd;
  outline: none;
  transition: border 0.24s ease-in-out;
`;

const thumbsContainer = {
  display: "flex",
  marginTop: 16
};

const thumbStyle = {
  display: "inline-flex",
  borderRadius: 2,
  border: "1px solid #eaeaea",
  marginBottom: 8,
  marginRight: 8,
  width: 100,
  height: 100,
  padding: 4
};

const thumbInner = {
  display: "flex",
  minWidth: 0,
  overflow: "hidden"
};

const img = {
  display: "block",
  width: "auto",
  height: "100%"
};

const errorStyle = {
  color: "#c45e5e",
  fontSize: "0.75rem"
};

// relevant code starts here
const uploadFileMutation = gql`
  mutation UploadFile($file: Upload!) {
    uploadFile(file: $file) {
      Location
    }
  }
`;

const Upload = ({ register }) => {
  const [preview, setPreview] = useState();
  const [errors, setErrors] = useState();
  const [uploadFile, { data }] = useMutation(uploadFileMutation);
  const onDrop = useCallback(
    async ([file]) => {
      if (file) {
        setPreview(URL.createObjectURL(file));
        uploadFile({ variables: { file } });
      } else {
        setErrors("Something went wrong. Check file type and size (max. 1 MB)");
      }
    },
    [uploadFile]
  );
  const {
    getRootProps,
    getInputProps,
    isDragActive,
    isDragAccept,
    isDragReject
  } = useDropzone({
    onDrop,
    accept: "image/jpeg, image/png",
    maxSize: 1024000
  });

  const thumb = (
    <div style={thumbStyle}>
      <div style={thumbInner}>
        <img src={preview} style={img} />
      </div>
    </div>
  );

  return (
    <Container {...getRootProps({ isDragActive, isDragAccept, isDragReject })}>
      <input {...getInputProps()} />
      {isDragActive ? (
        <p>Drop the files here ...</p>
      ) : (
        <p>Drop file here, or click to select the file</p>
      )}
      {preview && <aside style={thumbsContainer}>{thumb}</aside>}
      {errors && <span style={errorStyle}>{errors}</span>}
      {data && data.uploadFile && (
        <input
          type="hidden"
          name="avatarUrl"
          value={data.uploadFile.Location}
          ref={register}
        />
      )}
    </Container>
  );
};

export default Upload;

Notice that I am creating a preview of the image I am about to upload and save it in the component's state:

setPreview(URL.createObjectURL(file));

I also added a hidden input field where I store the image's location (our AWS S3 bucket + file path) after uploading the image so I can associate the image with the user record in my form.

<input
  type="hidden"
  name="avatarUrl"
  value={data.uploadFile.Location}
  ref={register}
/>

The ref={register} here registers the component to my form - this is done by react-hook-form and I pass in the register function from the parent (the form) component. So much for the client side part of the application. Now let's have a look at the server side for uploading the images to our AWS S3 bucket.

The GraphQL server part

So somewhere we need to have an Apollo Graphql server running that handles our uploadImage mutation. I have setup a simple serverless Apollo server on AWS Lambda. Let's say in our schema we have the following mutations:

  type Mutation {
    createUser(
      username: String!
      email: String!
      avatarUrl: String
    ): User!
    uploadFile(file: Upload!): S3Object
  }

The uploadFile mutation takes a single file argument of type Upload! and returns an S3Object. The S3Object is just a type that I defined from the return values that s3.upload(...) gives us. It looks like this:

  type S3Object {
    ETag: String
    Location: String!
    Key: String!
    Bucket: String!
  }

The important part here for our purpose is the Location field. It contains the absolute path to the uploaded image on S3.

So let's add the resolver function:

const resolvers = {
  Query: {
    // ...
  },
  Mutation: {
    // ...
    uploadFile: async (parent, { file }) => {
      const response = await handleFileUpload(file);

      return response;
    }
  }
};

The handleFileUpload method is imported from my resolvers file and handles the file upload to S3.

The code looks like this:

const AWS = require("aws-sdk");
// store each image in it's own unique folder to avoid name duplicates
const uuidv4 = require("uuid/v4");
// load config data from .env file
require("dotenv").config();
// update AWS config env data
AWS.config.update({
  accessKeyId: process.env.AWS_ACCESS_ID,
  secretAccessKey: process.env.AWS_SECRET_KEY,
  region: process.env.AWS_REGION
});
const s3 = new AWS.S3({ region: process.env.AWS_REGION });

// my default params for s3 upload
// I have a max upload size of 1 MB
const s3DefaultParams = {
  ACL: "public-read",
  Bucket: process.env.S3_BUCKET_NAME,
  Conditions: [
    ["content-length-range", 0, 1024000], // 1 Mb
    { acl: "public-read" }
  ]
};

// the actual upload happens here
const handleFileUpload = async file => {
  const { createReadStream, filename } = await file;

  const key = uuidv4();

  return new Promise((resolve, reject) => {
    s3.upload(
      {
        ...s3DefaultParams,
        Body: createReadStream(),
        Key: `${key}/${filename}`
      },
      (err, data) => {
        if (err) {
          console.log("error uploading...", err);
          reject(err);
        } else {
          console.log("successfully uploaded file...", data);
          resolve(data);
        }
      }
    );
  });
};

Conclusion

So now, every time an image get's selected or dropped on the dropzone in the form, the uploadImage mutation gets called and the image gets immediately uploaded in the background. The image's location get's stored in a hidden input field as soon as the upload has finished so the createUser mutation can store the location as part of the user record. Thanks to Ben Awad for the initial inspiration.