Skip to content

Commit 52e647f

Browse files
committed
Correctly handle multipart/form-data requests
1 parent 4183613 commit 52e647f

File tree

4 files changed

+334
-16
lines changed

4 files changed

+334
-16
lines changed

README.md

+98
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,101 @@ class UserRootValue(GraphQLView):
3939
return request.user
4040

4141
```
42+
43+
### File upload support
44+
45+
File uploads are supported via [multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec).
46+
47+
You can simply define a ``FileUpload`` field in your schema, and use
48+
it to receive data from uploaded files.
49+
50+
51+
Example using ``graphql-core``:
52+
53+
```python
54+
from collections import NamedTuple
55+
from graphql.type.definition import GraphQLScalarType
56+
57+
58+
GraphQLFileUpload = GraphQLScalarType(
59+
name='FileUpload',
60+
description='File upload',
61+
serialize=lambda x: None,
62+
parse_value=lambda value: value,
63+
parse_literal=lambda node: None,
64+
)
65+
66+
67+
FileEchoResult = namedtuple('FileEchoResult', 'data,name,type')
68+
69+
70+
FileEchoResultSchema = GraphQLObjectType(
71+
name='FileEchoResult,
72+
fields={
73+
'data': GraphQLField(GraphQLString),
74+
'name': GraphQLField(GraphQLString),
75+
'type': GraphQLField(GraphQLString),
76+
}
77+
)
78+
79+
80+
def resolve_file_echo(obj, info, file):
81+
data = file.stream.read().decode()
82+
return FileEchoResult(
83+
data=data,
84+
name=file.filename,
85+
type=file.content_type)
86+
87+
88+
MutationRootType = GraphQLObjectType(
89+
name='MutationRoot',
90+
fields={
91+
# ...
92+
'fileEcho': GraphQLField(
93+
type=FileUploadTestResultSchema,
94+
args={'file': GraphQLArgument(GraphQLFileUpload)},
95+
resolver=resolve_file_echo,
96+
),
97+
# ...
98+
}
99+
)
100+
```
101+
102+
103+
Example using ``graphene``:
104+
105+
```python
106+
import graphene
107+
108+
class FileUpload(graphene.Scalar):
109+
110+
@staticmethod
111+
def serialize(value):
112+
return None
113+
114+
@staticmethod
115+
def parse_literal(node):
116+
return None
117+
118+
@staticmethod
119+
def parse_value(value):
120+
return value # IMPORTANT
121+
122+
123+
class FileEcho(graphene.Mutation):
124+
125+
class Arguments:
126+
myfile = FileUpload(required=True)
127+
128+
ok = graphene.Boolean()
129+
name = graphene.String()
130+
data = graphene.String()
131+
type = graphene.String()
132+
133+
def mutate(self, info, myfile):
134+
return FileEcho(
135+
ok=True
136+
name=myfile.filename
137+
data=myfile.stream.read(),
138+
type=myfile.content_type)
139+
```

flask_graphql/graphqlview.py

+91-1
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,22 @@ def parse_body(self):
135135
elif content_type == 'application/json':
136136
return load_json_body(request.data.decode('utf8'))
137137

138-
elif content_type in ('application/x-www-form-urlencoded', 'multipart/form-data'):
138+
elif content_type in 'application/x-www-form-urlencoded':
139139
return request.form
140140

141+
elif content_type == 'multipart/form-data':
142+
# --------------------------------------------------------
143+
# See spec: https://github.com/jaydenseric/graphql-multipart-request-spec
144+
#
145+
# When processing multipart/form-data, we need to take
146+
# files (from "parts") and place them in the "operations"
147+
# data structure (list or dict) according to the "map".
148+
# --------------------------------------------------------
149+
operations = load_json_body(request.form['operations'])
150+
files_map = load_json_body(request.form['map'])
151+
return place_files_in_operations(
152+
operations, files_map, request.files)
153+
141154
return {}
142155

143156
def should_display_graphiql(self):
@@ -152,3 +165,80 @@ def request_wants_html(self):
152165
return best == 'text/html' and \
153166
request.accept_mimetypes[best] > \
154167
request.accept_mimetypes['application/json']
168+
169+
170+
def place_files_in_operations(operations, files_map, files):
171+
"""Place files from multipart reuqests inside operations.
172+
173+
Args:
174+
175+
operations:
176+
Either a dict or a list of dicts, containing GraphQL
177+
operations to be run.
178+
179+
files_map:
180+
A dictionary defining the mapping of files into "paths"
181+
inside the operations data structure.
182+
183+
Keys are file names from the "files" dict, values are
184+
lists of dotted paths describing where files should be
185+
placed.
186+
187+
files:
188+
A dictionary mapping file names to FileStorage instances.
189+
190+
Returns:
191+
192+
A structure similar to operations, but with FileStorage
193+
instances placed appropriately.
194+
"""
195+
196+
# operations: dict or list
197+
# files_map: {filename: [path, path, ...]}
198+
# files: {filename: FileStorage}
199+
200+
fmap = []
201+
for key, values in files_map.items():
202+
for val in values:
203+
path = val.split('.')
204+
fmap.append((path, key))
205+
206+
return _place_files_in_operations(operations, fmap, files)
207+
208+
209+
def _place_files_in_operations(ops, fmap, fobjs):
210+
for path, fkey in fmap:
211+
ops = _place_file_in_operations(ops, path, fobjs[fkey])
212+
return ops
213+
214+
215+
def _place_file_in_operations(ops, path, obj):
216+
217+
if len(path) == 0:
218+
return obj
219+
220+
if isinstance(ops, list):
221+
key = int(path[0])
222+
sub = _place_file_in_operations(ops[key], path[1:], obj)
223+
return _insert_in_list(ops, key, sub)
224+
225+
if isinstance(ops, dict):
226+
key = path[0]
227+
sub = _place_file_in_operations(ops[key], path[1:], obj)
228+
return _insert_in_dict(ops, key, sub)
229+
230+
raise TypeError('Expected ops to be list or dict')
231+
232+
233+
def _insert_in_dict(dct, key, val):
234+
new_dict = dct.copy()
235+
new_dict[key] = val
236+
return new_dict
237+
238+
239+
def _insert_in_list(lst, key, val):
240+
new_list = []
241+
new_list.extend(lst[:key])
242+
new_list.append(val)
243+
new_list.extend(lst[key + 1:])
244+
return new_list

tests/schema.py

+49-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from graphql.type.definition import GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType
1+
from graphql.type.definition import (
2+
GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType,
3+
GraphQLScalarType)
24
from graphql.type.scalars import GraphQLString
35
from graphql.type.schema import GraphQLSchema
46

@@ -25,13 +27,58 @@ def resolve_raises(*_):
2527
}
2628
)
2729

30+
31+
FileUploadTestResult = GraphQLObjectType(
32+
name='FileUploadTestResult',
33+
fields={
34+
'data': GraphQLField(GraphQLString),
35+
'name': GraphQLField(GraphQLString),
36+
'type': GraphQLField(GraphQLString),
37+
}
38+
)
39+
40+
GraphQLFileUpload = GraphQLScalarType(
41+
name='FileUpload',
42+
description='File upload',
43+
serialize=lambda x: None,
44+
parse_value=lambda value: value,
45+
parse_literal=lambda node: None,
46+
)
47+
48+
49+
def to_object(dct):
50+
class MyObject(object):
51+
pass
52+
53+
obj = MyObject()
54+
for key, val in dct.items():
55+
setattr(obj, key, val)
56+
return obj
57+
58+
59+
def resolve_file_upload_test(obj, info, file):
60+
data = file.stream.read().decode()
61+
62+
# Need to return an object, not a dict
63+
return to_object({
64+
'data': data,
65+
'name': file.filename,
66+
'type': file.content_type,
67+
})
68+
69+
2870
MutationRootType = GraphQLObjectType(
2971
name='MutationRoot',
3072
fields={
3173
'writeTest': GraphQLField(
3274
type=QueryRootType,
3375
resolver=lambda *_: QueryRootType
34-
)
76+
),
77+
'fileUploadTest': GraphQLField(
78+
type=FileUploadTestResult,
79+
args={'file': GraphQLArgument(GraphQLFileUpload)},
80+
resolver=resolve_file_upload_test,
81+
),
3582
}
3683
)
3784

0 commit comments

Comments
 (0)