File upload in GraphQL-ruby using Active Storage Direct Upload

File upload in GraphQL is a tricky problem to solve since GraphQL doesn't natively support multipart form data. However, using Active Storage's direct upload feature provides an elegant solution that bypasses your Rails server for the actual file transfer.

The Problem

Traditional file uploads in GraphQL require multipart requests, which GraphQL doesn't handle well. Direct upload solves this by allowing files to be uploaded directly to your storage service (S3, GCS, etc.) without passing through your Rails server.

Solution: Active Storage Direct Upload

Active Storage's direct upload feature generates signed URLs that allow clients to upload files directly to your storage service, reducing server load and improving performance.

Backend Setup

1. Configure Active Storage

Ensure Active Storage is configured in your Rails application:

# config/storage.yml
amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
  region: us-east-1
  bucket: your-bucket-name

2. Create Direct Upload Mutation

Create a GraphQL mutation to generate direct upload URLs:

# 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

3. Create Mutation to Attach File

Create a mutation that uses the signed_id to attach the file:

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

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

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

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

Frontend Implementation

1. Calculate File Checksum

First, calculate the file checksum (MD5):

async function calculateChecksum(file) {
  const buffer = await file.arrayBuffer()
  const hashBuffer = await crypto.subtle.digest('MD5', buffer)
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
  return btoa(hashHex)
}

2. Generate Direct Upload URL

Query GraphQL for the direct upload URL:

import { gql } from '@apollo/client'

const GENERATE_DIRECT_UPLOAD = gql`
  mutation GenerateDirectUpload(
    $filename: String!
    $byteSize: Int!
    $contentType: String!
    $checksum: String!
  ) {
    generateDirectUpload(
      filename: $filename
      byteSize: $byteSize
      contentType: $contentType
      checksum: $checksum
    ) {
      url
      headers
      signedId
    }
  }
`

async function uploadFile(file) {
  const checksum = await calculateChecksum(file)

  const { data } = await client.mutate({
    mutation: GENERATE_DIRECT_UPLOAD,
    variables: {
      filename: file.name,
      byteSize: file.size,
      contentType: file.type,
      checksum: checksum,
    },
  })

  return data.generateDirectUpload
}

3. Upload File Directly

Upload the file directly to storage:

async function uploadToStorage(file, directUploadData) {
  const headers = JSON.parse(directUploadData.headers)

  const response = await fetch(directUploadData.url, {
    method: 'PUT',
    headers: headers,
    body: file,
  })

  if (!response.ok) {
    throw new Error('Upload failed')
  }

  return directUploadData.signedId
}

4. Create Record with Attachment

After upload, create your record with the signed_id:

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

async function createPostWithFile(title, file) {
  // Step 1: Get direct upload URL
  const directUploadData = await uploadFile(file)

  // Step 2: Upload file directly to storage
  const signedId = await uploadToStorage(file, directUploadData)

  // Step 3: Create post with signed_id
  const { data } = await client.mutate({
    mutation: CREATE_POST,
    variables: {
      title,
      signedId,
    },
  })

  return data.createPostWithAttachment
}

Complete Example Component

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

function PostForm() {
  const [file, setFile] = useState(null)
  const [uploading, setUploading] = useState(false)

  const handleSubmit = async (e) => {
    e.preventDefault()
    setUploading(true)

    try {
      const title = e.target.title.value
      await createPostWithFile(title, file)
      alert('Post created successfully!')
    } catch (error) {
      console.error('Error:', error)
      alert('Failed to create post')
    } finally {
      setUploading(false)
    }
  }

  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={uploading}>
        {uploading ? 'Uploading...' : 'Create Post'}
      </button>
    </form>
  )
}

Benefits of Direct Upload

  • Performance: Files bypass your Rails server
  • Scalability: Reduces server load
  • Cost: Lower bandwidth costs
  • Speed: Faster uploads, especially for large files
  • Reliability: Direct connection to storage service

Security Considerations

  1. Validate file types: Check content_type on the server
  2. File size limits: Enforce maximum file sizes
  3. Checksum verification: Ensure file integrity
  4. Signed URLs: Use time-limited signed URLs

Error Handling

Handle errors at each step:

try {
  const directUploadData = await uploadFile(file)
  const signedId = await uploadToStorage(file, directUploadData)
  await createPostWithFile(title, signedId)
} catch (error) {
  if (error.message === 'Upload failed') {
    // Handle upload failure
  } else {
    // Handle other errors
  }
}

Conclusion

Active Storage direct upload provides an efficient solution for file uploads in GraphQL applications. By uploading files directly to storage and using signed IDs to attach them, you can build performant file upload features without overloading your Rails server.

File upload in GraphQL-ruby using Active Storage Direct Upload - Abhay Nikam