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
- File size limits: Set appropriate limits on file sizes
- Content type validation: Validate file types on both client and server
- Error handling: Provide clear error messages for failed uploads
- Progress tracking: Show upload progress for better UX
- 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.