Skip to content

Commit 74e71f7

Browse files
committed
Enable Chalice multipart uploads
1 parent 308e2d7 commit 74e71f7

File tree

3 files changed

+76
-30
lines changed

3 files changed

+76
-30
lines changed

src/graphql_server/chalice/views.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import warnings
4+
from io import BytesIO
45
from typing import TYPE_CHECKING, Any, Optional, Union, cast
56

67
from chalice.app import Request, Response
@@ -23,6 +24,8 @@
2324
class ChaliceHTTPRequestAdapter(SyncHTTPRequestAdapter):
2425
def __init__(self, request: Request) -> None:
2526
self.request = request
27+
self._post_data: Optional[dict[str, Union[str, bytes]]] = None
28+
self._files: Optional[dict[str, Any]] = None
2629

2730
@property
2831
def query_params(self) -> QueryParams:
@@ -42,11 +45,49 @@ def headers(self) -> Mapping[str, str]:
4245

4346
@property
4447
def post_data(self) -> Mapping[str, Union[str, bytes]]:
45-
raise NotImplementedError
48+
if self._post_data is None:
49+
self._parse_body()
50+
return self._post_data or {}
4651

4752
@property
4853
def files(self) -> Mapping[str, Any]:
49-
raise NotImplementedError
54+
if self._files is None:
55+
self._parse_body()
56+
return self._files or {}
57+
58+
def _parse_body(self) -> None:
59+
self._post_data = {}
60+
self._files = {}
61+
62+
content_type = self.content_type or ""
63+
64+
if "multipart/form-data" in content_type:
65+
import cgi
66+
67+
fp = BytesIO(self.request.raw_body)
68+
environ = {
69+
"REQUEST_METHOD": "POST",
70+
"CONTENT_TYPE": content_type,
71+
"CONTENT_LENGTH": str(len(self.request.raw_body)),
72+
}
73+
fs = cgi.FieldStorage(fp=fp, environ=environ, keep_blank_values=True)
74+
for key in fs.keys():
75+
field = fs[key]
76+
if isinstance(field, list):
77+
field = field[0]
78+
if getattr(field, "filename", None):
79+
data = field.file.read()
80+
self._files[key] = BytesIO(data)
81+
else:
82+
self._post_data[key] = field.value
83+
elif "application/x-www-form-urlencoded" in content_type:
84+
from urllib.parse import parse_qs
85+
86+
data = parse_qs(self.request.raw_body.decode())
87+
self._post_data = {k: v[0] for k, v in data.items()}
88+
else:
89+
self._post_data = {}
90+
self._files = {}
5091

5192
@property
5293
def content_type(self) -> Optional[str]:
@@ -65,9 +106,11 @@ def __init__(
65106
graphiql: Optional[bool] = None,
66107
graphql_ide: Optional[GraphQL_IDE] = "graphiql",
67108
allow_queries_via_get: bool = True,
109+
multipart_uploads_enabled: bool = False,
68110
) -> None:
69111
self.allow_queries_via_get = allow_queries_via_get
70112
self.schema = schema
113+
self.multipart_uploads_enabled = multipart_uploads_enabled
71114
if graphiql is not None:
72115
warnings.warn(
73116
"The `graphiql` argument is deprecated in favor of `graphql_ide`",

src/tests/http/clients/chalice.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing_extensions import Literal
88

99
from graphql import ExecutionResult
10+
from urllib3 import encode_multipart_formdata
1011

1112
from chalice.app import Chalice
1213
from chalice.app import Request as ChaliceRequest
@@ -60,11 +61,14 @@ def __init__(
6061
graphiql=graphiql,
6162
graphql_ide=graphql_ide,
6263
allow_queries_via_get=allow_queries_via_get,
64+
multipart_uploads_enabled=multipart_uploads_enabled,
6365
)
6466
view.result_override = result_override
6567

6668
@self.app.route(
67-
"/graphql", methods=["GET", "POST"], content_types=["application/json"]
69+
"/graphql",
70+
methods=["GET", "POST"],
71+
content_types=["application/json", "multipart/form-data"],
6872
)
6973
def handle_graphql():
7074
assert self.app.current_request is not None
@@ -80,7 +84,7 @@ async def _graphql_request(
8084
headers: Optional[dict[str, str]] = None,
8185
extensions: Optional[dict[str, Any]] = None,
8286
**kwargs: Any,
83-
) -> Response:
87+
) -> Response:
8488
body = self._build_body(
8589
query=query,
8690
operation_name=operation_name,
@@ -90,27 +94,27 @@ async def _graphql_request(
9094
extensions=extensions,
9195
)
9296

93-
data: Union[dict[str, object], str, None] = None
94-
95-
if body and files:
96-
body.update({name: (file, name) for name, file in files.items()})
97-
9897
url = "/graphql"
98+
headers = self._get_headers(method=method, headers=headers, files=files)
9999

100100
if method == "get":
101101
body_encoded = urllib.parse.urlencode(body or {})
102102
url = f"{url}?{body_encoded}"
103-
else:
104-
if body:
105-
data = body if files else dumps(body)
106-
kwargs["body"] = data
103+
elif body:
104+
if files:
105+
fields = {"operations": body["operations"], "map": body["map"]}
106+
for filename, file in files.items():
107+
fields[filename] = (filename, file.read(), "text/plain")
108+
data, content_type = encode_multipart_formdata(fields)
109+
headers.update(
110+
{"Content-Type": content_type, "Content-Length": f"{len(data)}"}
111+
)
112+
kwargs["body"] = data
113+
else:
114+
kwargs["body"] = dumps(body)
107115

108116
with Client(self.app) as client:
109-
response = getattr(client.http, method)(
110-
url,
111-
headers=self._get_headers(method=method, headers=headers, files=files),
112-
**kwargs,
113-
)
117+
response = getattr(client.http, method)(url, headers=headers, **kwargs)
114118

115119
return Response(
116120
status_code=response.status_code,

src/tests/http/test_upload.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,11 @@
1010

1111
@pytest.fixture
1212
def http_client(http_client_class: type[HttpClient]) -> HttpClient:
13-
with contextlib.suppress(ImportError):
14-
from .clients.chalice import ChaliceHttpClient
15-
16-
if http_client_class is ChaliceHttpClient:
17-
pytest.xfail(reason="Chalice does not support uploads")
18-
1913
return http_client_class()
2014

2115

2216
@pytest.fixture
2317
def enabled_http_client(http_client_class: type[HttpClient]) -> HttpClient:
24-
with contextlib.suppress(ImportError):
25-
from .clients.chalice import ChaliceHttpClient
26-
27-
if http_client_class is ChaliceHttpClient:
28-
pytest.xfail(reason="Chalice does not support uploads")
29-
3018
return http_client_class(multipart_uploads_enabled=True)
3119

3220

@@ -46,6 +34,17 @@ async def test_multipart_uploads_are_disabled_by_default(http_client: HttpClient
4634
)
4735

4836
assert response.status_code == 400
37+
38+
with contextlib.suppress(ImportError):
39+
from .clients.chalice import ChaliceHttpClient
40+
41+
if isinstance(http_client, ChaliceHttpClient):
42+
assert response.json == {
43+
"Code": "BadRequestError",
44+
"Message": "Unsupported content type",
45+
}
46+
return
47+
4948
assert response.data == b"Unsupported content type"
5049

5150

0 commit comments

Comments
 (0)