All Articles

Multipart file upload to Active Storage using GraphQL-ruby and Apollo

This is the second part of doing a file upload using GraphQL-ruby to Active Storage. You can find the previous blog: File upload in GraphQL-ruby using Active Storage Direct Upload

Yes. I am writing this blog because I finally figured out what I was doing wrong when doing a multipart file upload.

Problem Statement

Let say Sam uploads a document to the application. The document gets direct uploaded to S3(or any other service you use), but Sam doesn’t save the form for some reason. The document doesn’t get associated in the database so the application doesn’t show wrong data but we have a stale file in the S3 which is never needed or would be duplicated when Sam retires to upload the document later.

Multipart file upload to rescue

Multipart file upload does upload the document to the Active Storage service(S3) unless the request to mutate the record is sent to the server.

Setup client-side

  • Add the npm package apollo-upload-client. The NPM package helps in making the multipart request to the server via Apollo.
yarn add apollo-upload-client
or
npm install apollo-upload-client
  • Update the component which configures the ApolloClient for the application. It should delegate all the multipart HTTP request to upload link provided by apollo-upload-client.
// Main.jsx
import { createUploadLink } from 'apollo-upload-client';

const httpLink = ApolloLink.split(
  (operation) => operation.getContext().hasUpload,
  createUploadLink({ uri: '/graphql' }),
  new HttpLink({ uri: '/graphql' }),
);

const client = new ApolloClient({
  ...
  link: httpLink,
});

In the above code, we are delegating all the request to createUploadLink when the hasUpload is set to true in mutation context. Apollo doesn’t know unless mentioned explicitly that the request has file upload and needs to delegate to createUploadLink instead of HttpLink.

  • Let’s take an example of profile avatar upload react component and the mutation setup.
import React, { Fragment } from 'react';
import { useMutation } from '@apollo/client';
import { UPDATE_PROFILE } from "./mutations";

export default function EditProfile() {
  const [updateProfile] = useMutation(UPDATE_PROFILE, {
    context: { hasUpload: true },
    onComplete: {
      // do something
    },
  });

  const handleFileChange = ({ target: { validity, files } }) => {
    if (validity.valid) {
      // save to component state
      // or save the file to the form state management library you are using.
    }
  }

  const handleSubmit = (payload) => {
    // make sure payload has files in the namespace you require.
    // that can be nested attributes, single or many attachments.
    updateProfile({ variables: payload });
  }

  return (
    <Fragment>
      // form HTML or React component need for basic profile update.
      ...
      <input name="users[avatar]" type="file" onChange={handleFileChange} />
    </Fragment>
  )
}

Setup server-side

  • Add the ApolloUploadServer gem to the gemfile. ApolloUploadServer adds a middleware that helps in parsing the multipart request sent by the client.
# gemfile
# for multipart file upload in GraphQL
gem 'apollo_upload_server', '2.0.2'
  • Add a graphql mutation which accepts a file as an argument
# apps/graphql/input_object_attributes/user_update_attributes.rb
class InputObjectAttributes::UserUpdateAttributes < Types::BaseInputObject
  description "Attributes for update User"

  argument :id, ID, required: false
  argument :email, String, required: true
  ...
  argument :avatar, CustomTypes::FileType, required: true
end

# apps/graphql/mutations/users/update.rb
class Mutations::Users::Update < Mutations::BaseMutation
  graphql_name "UpdateProfile"

  description "Mutation to update the user details"

  argument :id, ID, required: true
  argument :user_attributes,
           InputObjectAttributes::UserUpdateAttributes,
           required: true

  field :user, Types::UserType, null: false

  def resolve(id:, user_attributes:)
    user = User.find_by!(id: id)
    if user.update!(user_attributes.to_h)
      { user: user }
    end
  end
end
  • The last part is adding a custom scalar instead of using ApolloUploadServer::Upload scalar type provided by ApolloUploadServer gem. Rails Active Storage excepts the file to be ActionDispatch::Http::UploadedFile class but ApolloUploadServer return a ApolloUploadServer::Wrappers::UploadedFile.
# apps/graphql/custom_types/file_type
class CustomTypes::FileType < Types::BaseScalar
  description "A valid URL, transported as a string"

  def self.coerce_input(file, context)
    ActionDispatch::Http::UploadedFile.new(
      filename: file.original_filename,
      type: file.content_type,
      headers: file.headers,
      tempfile: file.tempfile,
    )
  end
end

Happy Coding!!