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

Uploading files in GraphQL applications can be challenging since GraphQL doesn't natively support multipart form data. However, with GraphQL-ruby and Apollo Client, you can implement file uploads to Active Storage using a multipart request approach.

The Challenge

GraphQL queries are typically sent as JSON, but file uploads require multipart/form-data. This creates a challenge when trying to upload files through GraphQL mutations.

Solution: Multipart Request

The solution involves sending a multipart request with both the GraphQL query and the file, then handling it on the server side.

Backend Setup

1. Install Required Gems

Ensure you have Active Storage configured:

# Gemfile
gem 'graphql'
gem 'activestorage'

2. Create Upload Mutation

Create a mutation that accepts file uploads:

# app/graphql/mutations/create_post_with_attachment.rb
module Mutations
  class CreatePostWithAttachment < BaseMutation
    argument :title, String, required: true
    argument :file, ApolloUploadServer::Upload, required: true

    field :post, Types::PostType, null: true
    field :errors, [String], null: false

    def resolve(title:, file:)
      post = Post.new(title: title)
      post.attachment.attach(file)

      if post.save
        { post: post, errors: [] }
      else
        { post: nil, errors: post.errors.full_messages }
      end
    end
  end
end

3. Configure GraphQL Schema

Add the mutation to your schema:

# app/graphql/your_schema.rb
class YourSchema < GraphQL::Schema
  mutation(Types::MutationType)
end

Frontend Setup with Apollo

1. Install Apollo Upload Link

npm install apollo-upload-client
# or
yarn add apollo-upload-client

2. Configure Apollo Client

Set up Apollo Client with the upload link:

import { ApolloClient, InMemoryCache } from '@apollo/client'
import { createUploadLink } from 'apollo-upload-client'

const uploadLink = createUploadLink({
  uri: 'http://localhost:3000/graphql',
})

const client = new ApolloClient({
  link: uploadLink,
  cache: new InMemoryCache(),
})

3. Create Upload Mutation

Define your mutation:

import { gql } from '@apollo/client'

const CREATE_POST_WITH_ATTACHMENT = gql`
  mutation CreatePostWithAttachment($title: String!, $file: Upload!) {
    createPostWithAttachment(title: $title, file: $file) {
      post {
        id
        title
        attachmentUrl
      }
      errors
    }
  }
`

4. Use in Component

Implement file upload in your React component:

import { useMutation } from '@apollo/client'
import { useState } from 'react'

function PostForm() {
  const [file, setFile] = useState(null)
  const [createPost, { loading, error }] = useMutation(CREATE_POST_WITH_ATTACHMENT)

  const handleSubmit = async (e) => {
    e.preventDefault()
    const title = e.target.title.value

    try {
      const { data } = await createPost({
        variables: {
          title,
          file,
        },
      })

      if (data.createPostWithAttachment.errors.length === 0) {
        console.log('Post created:', data.createPostWithAttachment.post)
      }
    } catch (err) {
      console.error('Error:', err)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="title"
        placeholder="Post title"
        required
      />
      <input
        type="file"
        onChange={(e) => setFile(e.target.files[0])}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Uploading...' : 'Create Post'}
      </button>
    </form>
  )
}

Alternative: Direct Upload

For better performance with large files, consider using Active Storage's direct upload feature:

1. Backend: Generate Direct Upload URL

# app/graphql/mutations/generate_direct_upload.rb
module Mutations
  class GenerateDirectUpload < BaseMutation
    argument :filename, String, required: true
    argument :byte_size, Int, required: true
    argument :content_type, String, required: true
    argument :checksum, String, required: true

    field :url, String, null: false
    field :headers, String, null: false
    field :signed_id, String, null: false

    def resolve(filename:, byte_size:, content_type:, checksum:)
      blob = ActiveStorage::Blob.create_before_direct_upload!(
        filename: filename,
        byte_size: byte_size,
        content_type: content_type,
        checksum: checksum
      )

      {
        url: blob.service_url_for_direct_upload,
        headers: blob.service_headers_for_direct_upload.to_json,
        signed_id: blob.signed_id
      }
    end
  end
end

2. Frontend: Upload Directly

// Upload file directly to storage
const uploadFile = async (file) => {
  // Get direct upload URL from GraphQL
  const { data } = await client.mutate({
    mutation: GENERATE_DIRECT_UPLOAD,
    variables: {
      filename: file.name,
      byteSize: file.size,
      contentType: file.type,
      checksum: await calculateChecksum(file),
    },
  })

  // Upload directly to storage
  await fetch(data.generateDirectUpload.url, {
    method: 'PUT',
    headers: JSON.parse(data.generateDirectUpload.headers),
    body: file,
  })

  // Use signed_id in your mutation
  return data.generateDirectUpload.signedId
}

Best Practices

  1. File size limits: Set appropriate limits on file sizes
  2. Content type validation: Validate file types on both client and server
  3. Error handling: Provide clear error messages for failed uploads
  4. Progress tracking: Show upload progress for better UX
  5. Direct upload: Use direct upload for large files to reduce server load

Conclusion

Multipart file uploads with GraphQL-ruby and Apollo require some setup, but once configured, they provide a clean way to handle file uploads in your GraphQL API. Choose between multipart requests for smaller files or direct uploads for better performance with larger files.

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