GraphQL

File Uploads with GraphQL

20 min Lesson 15 of 35

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:
  1. Implement a file upload mutation with size validation (max 5MB) and type restrictions
  2. Create a multiple file upload resolver that processes images and generates thumbnails
  3. Build a profile picture upload mutation that deletes the old image before saving the new one
  4. Add progress tracking for file uploads using GraphQL subscriptions
  5. Implement a file management system with upload, delete, and list operations