|
| 1 | +# Handling File Uploads |
| 2 | + |
| 3 | +GraphQL doesn't natively support file uploads. The [GraphQL specification](https://spec.graphql.org/draft/) is transport-agnostic |
| 4 | +and historically assumed `application/json`, but the evolving [GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/) |
| 5 | +introduces support for additional media types: `application/graphql-response+json`. |
| 6 | + |
| 7 | +Since uploading files typically requires `multipart/form-data`, adding upload capabilities still |
| 8 | +means extending the HTTP layer yourself. This guide explains how to handle file uploads using |
| 9 | +[`graphql-http`](https://github.com/graphql/graphql-http), a minimal, spec-compliant GraphQL server implementation for JavaScript. |
| 10 | + |
| 11 | +## Why file uploads require extra work |
| 12 | + |
| 13 | +A standard GraphQL request sends a query or mutation and optional variables as JSON. But file |
| 14 | +uploads require binary data, which JSON can't represent. Instead, clients typically use |
| 15 | +`multipart/form-data`, the same encoding used for HTML file forms. This format is incompatible |
| 16 | +with how GraphQL servers like `graphql-http` handle requests by default. |
| 17 | + |
| 18 | +To bridge this gap, the GraphQL community developed a convention: the [GraphQL multipart |
| 19 | +request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). This |
| 20 | +approach allows files to be uploaded as part of a GraphQL mutation, with the server handling the |
| 21 | +`multipart/form-data` payload and injecting the uploaded file into the appropriate variable. |
| 22 | + |
| 23 | +## The multipart upload format |
| 24 | + |
| 25 | +The multipart spec defines a three-part request format: |
| 26 | + |
| 27 | +- `operations`: A JSON string representing the GraphQL operation |
| 28 | +- `map`: A JSON object that maps file field name to variable paths |
| 29 | +- One or more files: Attached to the form using the same field names referenced in the `map` |
| 30 | + |
| 31 | +### Example |
| 32 | + |
| 33 | +```graphql |
| 34 | +mutation UploadFile($file: Upload!) { |
| 35 | + uploadFile(file: $file) { |
| 36 | + filename |
| 37 | + mimetype |
| 38 | + } |
| 39 | +} |
| 40 | +``` |
| 41 | + |
| 42 | +And the corresponding `map` field: |
| 43 | + |
| 44 | +```json |
| 45 | +{ |
| 46 | + "0": ["variables.file"] |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +The server is responsible for parsing the multipart body, interpreting the `map`, and replacing |
| 51 | +variable paths with the corresponding file streams. |
| 52 | + |
| 53 | +## Implementing uploads with graphql-http |
| 54 | + |
| 55 | +The `graphql-http` package doesn’t handle multipart requests out of the box. To support file |
| 56 | +uploads, you’ll need to: |
| 57 | + |
| 58 | +1. Parse the multipart form request. |
| 59 | +2. Map the uploaded file(s) to GraphQL variables. |
| 60 | +3. Inject those into the request body before passing it to `createHandler()`. |
| 61 | + |
| 62 | +Here's how to do it in an Express-based server using JavaScript and the [`busboy`](https://www.npmjs.com/package/busboy), |
| 63 | +a popular library for parsing `multipart/form-data`. |
| 64 | + |
| 65 | +### Example: Express + graphql-http + busboy |
| 66 | + |
| 67 | +```js |
| 68 | +import express from 'express'; |
| 69 | +import busboy from 'busboy'; |
| 70 | +import { createHandler } from 'graphql-http/lib/use/express'; |
| 71 | +import { schema } from './schema.js'; |
| 72 | + |
| 73 | +const app = express(); |
| 74 | + |
| 75 | +app.post('/graphql', (req, res, next) => { |
| 76 | + const contentType = req.headers['content-type'] || ''; |
| 77 | + |
| 78 | + if (contentType.startsWith('multipart/form-data')) { |
| 79 | + const bb = busboy({ headers: req.headers }); |
| 80 | + let operations, map; |
| 81 | + const files = {}; |
| 82 | + |
| 83 | + bb.on('field', (name, val) => { |
| 84 | + if (name === 'operations') operations = JSON.parse(val); |
| 85 | + else if (name === 'map') map = JSON.parse(val); |
| 86 | + }); |
| 87 | + |
| 88 | + bb.on('file', (fieldname, file, { filename, mimeType }) => { |
| 89 | + files[fieldname] = { file, filename, mimeType }; |
| 90 | + }); |
| 91 | + |
| 92 | + bb.on('close', () => { |
| 93 | + for (const [key, paths] of Object.entries(map)) { |
| 94 | + for (const path of paths) { |
| 95 | + const keys = path.split('.'); |
| 96 | + let target = operations; |
| 97 | + while (keys.length > 1) target = target[keys.shift()]; |
| 98 | + target[keys[0]] = files[key].file; |
| 99 | + } |
| 100 | + } |
| 101 | + req.body = operations; |
| 102 | + next(); |
| 103 | + }); |
| 104 | + |
| 105 | + req.pipe(bb); |
| 106 | + } else { |
| 107 | + next(); |
| 108 | + } |
| 109 | +}, createHandler({ schema })); |
| 110 | + |
| 111 | +app.listen(4000); |
| 112 | +``` |
| 113 | + |
| 114 | +This example: |
| 115 | + |
| 116 | +- Parses `multipart/form-data` uploads. |
| 117 | +- Extracts GraphQL query and variables from the `operations` field. |
| 118 | +- Inserts file streams in place of `Upload` variables. |
| 119 | +- Passes the modified request to `graphql-http`. |
| 120 | + |
| 121 | +## Defining the upload scalar |
| 122 | + |
| 123 | +The GraphQL schema must include a custom scalar type for uploaded files: |
| 124 | + |
| 125 | +```graphql |
| 126 | +scalar Upload |
| 127 | + |
| 128 | +extend type Mutation { |
| 129 | + uploadFile(file: Upload!): FileMetadata |
| 130 | +} |
| 131 | + |
| 132 | +type FileMetadata { |
| 133 | + filename: String! |
| 134 | + mimetype: String! |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +In your resolvers, treat `file` as a readable stream: |
| 139 | + |
| 140 | +```js |
| 141 | +export const resolvers = { |
| 142 | + Upload: GraphQLScalarType, // implement as needed, or treat as opaque in resolver |
| 143 | + Mutation: { |
| 144 | + uploadFile: async (_, { file }) => { |
| 145 | + const chunks = []; |
| 146 | + for await (const chunk of file) { |
| 147 | + chunks.push(chunk); |
| 148 | + } |
| 149 | + // process or store the file as needed |
| 150 | + return { |
| 151 | + filename: 'uploaded-file.txt', |
| 152 | + mimetype: 'text/plain', |
| 153 | + }; |
| 154 | + } |
| 155 | + } |
| 156 | +}; |
| 157 | +``` |
| 158 | + |
| 159 | +You can define `Upload` as a passthrough scalar if your server middleware already |
| 160 | +handles file parsing: |
| 161 | + |
| 162 | +```js |
| 163 | +import { GraphQLScalarType } from 'graphql'; |
| 164 | + |
| 165 | +export const Upload = new GraphQLScalarType({ |
| 166 | + name: 'Upload', |
| 167 | + serialize: () => { throw new Error('Upload serialization unsupported'); }, |
| 168 | + parseValue: value => value, |
| 169 | + parseLiteral: () => { throw new Error('Upload literals unsupported'); } |
| 170 | +}); |
| 171 | +``` |
| 172 | + |
| 173 | +## Best practices |
| 174 | + |
| 175 | +- Streaming: Don’t read entire files into memory. Instead, stream files to disk or an external |
| 176 | +storage service. This reduces memory pressure and improves |
| 177 | +scalability. |
| 178 | +- Security: Always validate file types, restrict maximum file sizes, and sanitize filenames to prevent |
| 179 | +path traversal or injection vulnerabilities. |
| 180 | +- Alternatives: For large files or more scalable architectures, consider using pre-signed URLs |
| 181 | +with an object storage service like S3. The client uploads the file directly, and the GraphQL |
| 182 | +mutation receives the file URL instead. |
| 183 | +- Client support: Use a client library that supports the GraphQL multipart request specification. |
0 commit comments