GraphQL
File Uploads with GraphQL
Handling File Uploads in GraphQL
File uploads in GraphQL require special handling since GraphQL traditionally works with JSON. The GraphQL multipart request specification enables file uploads by combining GraphQL operations with multipart/form-data.
GraphQL Multipart Request Spec
The specification defines how to send files alongside GraphQL operations:
POST /graphql
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="operations"
{
"query": "mutation($file: Upload!) { uploadFile(file: $file) { url } }",
"variables": { "file": null }
}
------Boundary
Content-Disposition: form-data; name="map"
{ "0": ["variables.file"] }
------Boundary
Content-Disposition: form-data; name="0"; filename="photo.jpg"
Content-Type: image/jpeg
[binary file data]
------Boundary--
Note: The
map field links file positions in the multipart request to GraphQL variable paths. This allows multiple files to be uploaded in a single request.
Apollo Upload Server Setup
Install and configure Apollo Upload Server for file uploads:
// Install dependencies
// npm install apollo-server-express graphql-upload
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
const { graphqlUploadExpress } = require('graphql-upload');
const { GraphQLUpload } = require('graphql-upload');
// Define schema with Upload scalar
const typeDefs = gql`
scalar Upload
type File {
filename: String!
mimetype: String!
encoding: String!
url: String!
}
type Mutation {
uploadFile(file: Upload!): File!
uploadMultipleFiles(files: [Upload!]!): [File!]!
}
`;
// Create Express app
const app = express();
// Add upload middleware BEFORE Apollo Server
// This must come before applyMiddleware
app.use(graphqlUploadExpress({
maxFileSize: 10000000, // 10 MB
maxFiles: 10
}));
// Create Apollo Server
const server = new ApolloServer({
typeDefs,
resolvers,
uploads: false // Disable Apollo Server's built-in upload handling
});
// Apply Apollo middleware
await server.start();
server.applyMiddleware({ app });
app.listen(4000, () => {
console.log('Server running on http://localhost:4000/graphql');
});
Single File Upload Resolver
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { GraphQLUpload } = require('graphql-upload');
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadFile: async (_, { file }) => {
// Get file stream
const { createReadStream, filename, mimetype, encoding } = await file;
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(mimetype)) {
throw new Error('Only JPEG, PNG, and GIF images are allowed');
}
// Generate unique filename
const ext = path.extname(filename);
const uniqueFilename = `${uuidv4()}${ext}`;
// Define upload path
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filePath = path.join(uploadDir, uniqueFilename);
// Create write stream and pipe file
const stream = createReadStream();
const writeStream = fs.createWriteStream(filePath);
await new Promise((resolve, reject) => {
stream
.pipe(writeStream)
.on('finish', resolve)
.on('error', reject);
});
// Return file info
return {
filename: uniqueFilename,
mimetype,
encoding,
url: `/uploads/${uniqueFilename}`
};
}
}
};
Multiple File Uploads
const resolvers = {
Upload: GraphQLUpload,
Mutation: {
uploadMultipleFiles: async (_, { files }) => {
// Process all files in parallel
const uploadPromises = files.map(async (file) => {
const { createReadStream, filename, mimetype, encoding } = await file;
// Validate file
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf'
];
if (!allowedTypes.includes(mimetype)) {
throw new Error(`Invalid file type: ${mimetype}`);
}
// Check file size by reading stream
let fileSize = 0;
const maxSize = 5 * 1024 * 1024; // 5 MB
// Generate unique filename
const ext = path.extname(filename);
const uniqueFilename = `${uuidv4()}${ext}`;
const uploadDir = path.join(__dirname, 'uploads');
const filePath = path.join(uploadDir, uniqueFilename);
// Save file
const stream = createReadStream();
const writeStream = fs.createWriteStream(filePath);
await new Promise((resolve, reject) => {
stream
.on('data', (chunk) => {
fileSize += chunk.length;
if (fileSize > maxSize) {
stream.destroy();
fs.unlinkSync(filePath);
reject(new Error('File too large'));
}
})
.pipe(writeStream)
.on('finish', resolve)
.on('error', reject);
});
return {
filename: uniqueFilename,
mimetype,
encoding,
url: `/uploads/${uniqueFilename}`
};
});
return await Promise.all(uploadPromises);
}
}
};
Tip: Process multiple file uploads in parallel using
Promise.all() to improve performance, but be mindful of memory usage.
File Validation
// fileValidation.js
class FileValidator {
static validateImage(mimetype, filename) {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (!allowedTypes.includes(mimetype)) {
throw new Error(
`Invalid image type. Allowed: ${allowedTypes.join(', ')}`
);
}
const ext = path.extname(filename).toLowerCase();
if (!allowedExtensions.includes(ext)) {
throw new Error(
`Invalid file extension. Allowed: ${allowedExtensions.join(', ')}`
);
}
}
static async validateFileSize(stream, maxSize) {
let size = 0;
return new Promise((resolve, reject) => {
stream
.on('data', (chunk) => {
size += chunk.length;
if (size > maxSize) {
stream.destroy();
reject(new Error(`File exceeds ${maxSize / 1024 / 1024}MB limit`));
}
})
.on('end', () => resolve(size))
.on('error', reject);
});
}
static validateFilename(filename) {
// Remove potentially dangerous characters
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
return sanitized;
}
}
// Usage in resolver
const resolvers = {
Mutation: {
uploadFile: async (_, { file }) => {
const { createReadStream, filename, mimetype } = await file;
// Validate file type
FileValidator.validateImage(mimetype, filename);
// Sanitize filename
const safeName = FileValidator.validateFilename(filename);
// Validate file size
const stream = createReadStream();
const maxSize = 5 * 1024 * 1024; // 5 MB
await FileValidator.validateFileSize(stream, maxSize);
// Save file...
}
}
};
Upload with Metadata
type Mutation {
uploadAvatar(
file: Upload!
userId: ID!
): User!
uploadPost(
file: Upload!
title: String!
description: String
tags: [String!]
): Post!
}
type User {
id: ID!
username: String!
avatarUrl: String
}
type Post {
id: ID!
title: String!
description: String
imageUrl: String!
tags: [String!]!
createdAt: DateTime!
}
const resolvers = {
Mutation: {
uploadAvatar: async (_, { file, userId }, context) => {
if (!context.user) {
throw new AuthenticationError('Authentication required');
}
if (context.user.id !== userId && !context.user.isAdmin) {
throw new ForbiddenError('Cannot update another user\'s avatar');
}
// Upload file
const { url } = await uploadImage(file, 'avatars');
// Update user
const user = await User.findByIdAndUpdate(
userId,
{ avatarUrl: url },
{ new: true }
);
return user;
},
uploadPost: async (_, { file, title, description, tags }, context) => {
if (!context.user) {
throw new AuthenticationError('Authentication required');
}
// Upload image
const { url } = await uploadImage(file, 'posts');
// Create post
const post = await Post.create({
authorId: context.user.id,
title,
description,
imageUrl: url,
tags,
createdAt: new Date()
});
return post;
}
}
};
// Helper function
async function uploadImage(file, folder) {
const { createReadStream, filename, mimetype } = await file;
FileValidator.validateImage(mimetype, filename);
const ext = path.extname(filename);
const uniqueFilename = `${uuidv4()}${ext}`;
const uploadDir = path.join(__dirname, 'uploads', folder);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const filePath = path.join(uploadDir, uniqueFilename);
const stream = createReadStream();
const writeStream = fs.createWriteStream(filePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream).on('finish', resolve).on('error', reject);
});
return {
filename: uniqueFilename,
mimetype,
url: `/${folder}/${uniqueFilename}`
};
}
Warning: Always validate file types, sizes, and names. Never trust client-provided filenames or MIME types. Store files outside the web root or use proper access controls.
Client-Side Upload Example
// React example with Apollo Client
import { gql, useMutation } from '@apollo/client';
const UPLOAD_FILE = gql`
mutation UploadFile($file: Upload!) {
uploadFile(file: $file) {
filename
url
}
}
`;
function FileUploadForm() {
const [uploadFile, { loading, error }] = useMutation(UPLOAD_FILE);
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const { data } = await uploadFile({
variables: { file }
});
console.log('Uploaded:', data.uploadFile.url);
} catch (err) {
console.error('Upload failed:', err);
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={loading}
/>
{loading && <p>Uploading...</p>}
{error && <p>Error: {error.message}</p>}
</div>
);
}
// Multiple files example
const UPLOAD_MULTIPLE = gql`
mutation UploadMultiple($files: [Upload!]!) {
uploadMultipleFiles(files: $files) {
filename
url
}
}
`;
function MultipleFileUpload() {
const [uploadFiles] = useMutation(UPLOAD_MULTIPLE);
const handleChange = async (event) => {
const files = Array.from(event.target.files);
const { data } = await uploadFiles({
variables: { files }
});
console.log('Uploaded:', data.uploadMultipleFiles);
};
return (
<input
type="file"
multiple
onChange={handleChange}
/>
);
}
Exercise:
- Implement a file upload mutation with size validation (max 5MB) and type restrictions
- Create a multiple file upload resolver that processes images and generates thumbnails
- Build a profile picture upload mutation that deletes the old image before saving the new one
- Add progress tracking for file uploads using GraphQL subscriptions
- Implement a file management system with upload, delete, and list operations