Skip to content

Commit 928bd9c

Browse files
committed
add guide on file uploads
1 parent fd2e903 commit 928bd9c

File tree

2 files changed

+184
-0
lines changed

2 files changed

+184
-0
lines changed

src/pages/learn/_meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default {
1919
"best-practices": "",
2020
"thinking-in-graphs": "",
2121
"serving-over-http": "",
22+
"file-uploads": "",
2223
authorization: "",
2324
pagination: "",
2425
"schema-design": "Schema Design",

src/pages/learn/file-uploads.mdx

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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

Comments
 (0)