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
- Validate file types: Check content_type on the server
- File size limits: Enforce maximum file sizes
- Checksum verification: Ensure file integrity
- 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.