From 6d47141e7589ef658d92d6b0d2782290d68e3062 Mon Sep 17 00:00:00 2001 From: am-on Date: Mon, 11 Mar 2024 18:22:15 +0100 Subject: [PATCH] Fix multipart/form-data requests Multipart/form-data wasn't working because `boundary` wasn't included in `content type`. Openapi-core needs `boundary`[1][2] to parse the body. [1] https://github.com/python-openapi/openapi-core/blob/0.19.0/openapi_core/protocols.py#L50 [2] https://github.com/python-openapi/openapi-core/blob/0.19.0/openapi_core/deserializing/media_types/util.py#L54 --- pyramid_openapi3/tests/test_contenttypes.py | 85 +++++++++++++++++++-- pyramid_openapi3/tests/test_wrappers.py | 16 ---- pyramid_openapi3/wrappers.py | 8 +- 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/pyramid_openapi3/tests/test_contenttypes.py b/pyramid_openapi3/tests/test_contenttypes.py index cf6904b..3b73ee6 100644 --- a/pyramid_openapi3/tests/test_contenttypes.py +++ b/pyramid_openapi3/tests/test_contenttypes.py @@ -3,6 +3,7 @@ from pyramid.config import Configurator from pyramid.request import Request from pyramid.router import Router +from webob.multidict import MultiDict from webtest.app import TestApp import tempfile @@ -10,13 +11,39 @@ import unittest -def app(spec: str, view: t.Callable, route: str) -> Router: +def app(spec: str) -> Router: """Prepare a Pyramid app.""" + + def foo_view(request: Request) -> t.Dict[str, str]: + """Return reversed string.""" + return {"bar": request.openapi_validated.body["bar"][::-1]} + + def multipart_view(request: Request) -> t.Dict[str, t.Union[str, t.List[str]]]: + """Return reversed string.""" + body = request.openapi_validated.body + return { + "key1": body["key1"][::-1], + "key2": [x[::-1] for x in body["key2"]], + "key3": body["key3"].decode("utf-8")[::-1], + } + with Configurator() as config: config.include("pyramid_openapi3") config.pyramid_openapi3_spec(spec) - config.add_route("foo", route) - config.add_view(openapi=True, renderer="json", view=view, route_name="foo") + config.add_route("foo", "/foo") + config.add_view( + openapi=True, + renderer="json", + view=foo_view, + route_name="foo", + ) + config.add_route("multipart", "/multipart") + config.add_view( + openapi=True, + renderer="json", + view=multipart_view, + route_name="multipart", + ) return config.make_wsgi_app() @@ -32,6 +59,18 @@ def app(spec: str, view: t.Callable, route: str) -> Router: properties: bar: type: string + BarObject: + type: object + properties: + key1: + type: string + key2: + type: array + items: + type: string + key3: + type: string + format: binary paths: /foo: post: @@ -46,6 +85,16 @@ def app(spec: str, view: t.Callable, route: str) -> Router: responses: 200: description: OK + /multipart: + post: + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/BarObject" + responses: + 200: + description: OK """ @@ -56,15 +105,11 @@ def _testapp(self) -> TestApp: """Start up the app so that tests can send requests to it.""" from webtest import TestApp - def foo_view(request: Request) -> t.Dict[str, str]: - """Return reversed string.""" - return {"bar": request.openapi_validated.body["bar"][::-1]} - with tempfile.NamedTemporaryFile() as document: document.write(OPENAPI_YAML.encode()) document.seek(0) - return TestApp(app(document.name, foo_view, "/foo")) + return TestApp(app(document.name)) def test_post_json(self) -> None: """Post with `application/json`.""" @@ -77,3 +122,27 @@ def test_post_form(self) -> None: # pragma: no cover res = self._testapp().post("/foo", params={"bar": "baz"}, status=200) self.assertEqual(res.json, {"bar": "zab"}) + + def test_post_multipart(self) -> None: + """Post with `multipart/form-data`.""" + + multi_dict = MultiDict() + multi_dict.add("key1", "value1") + multi_dict.add("key2", "value2.1") + multi_dict.add("key2", "value2.2") + multi_dict.add("key3", b"value3") + + res = self._testapp().post( + "/multipart", + multi_dict, + content_type="multipart/form-data", + status=200, + ) + self.assertEqual( + res.json, + { + "key1": "1eulav", + "key2": ["1.2eulav", "2.2eulav"], + "key3": "3eulav", + }, + ) diff --git a/pyramid_openapi3/tests/test_wrappers.py b/pyramid_openapi3/tests/test_wrappers.py index 167b95f..5bae1ba 100644 --- a/pyramid_openapi3/tests/test_wrappers.py +++ b/pyramid_openapi3/tests/test_wrappers.py @@ -6,7 +6,6 @@ from pyramid.testing import DummyRequest from pyramid_openapi3.wrappers import PyramidOpenAPIRequest from pyramid_openapi3.wrappers import PyramidOpenAPIResponse -from webob.multidict import MultiDict @dataclass @@ -77,21 +76,6 @@ def test_relative_app_request() -> None: assert openapi_request.content_type == "text/html" -def test_form_data_request() -> None: - """Test that request.POST is used as the body in case of form-data.""" - multi_dict = MultiDict() - multi_dict.add("key1", "value1") - multi_dict.add("key2", "value2.1") - multi_dict.add("key2", "value2.2") - pyramid_request = DummyRequest(path="/foo", post=multi_dict) - pyramid_request.matched_route = DummyRoute(name="foo", pattern="/foo") - pyramid_request.content_type = "multipart/form-data" - - openapi_request = PyramidOpenAPIRequest(pyramid_request) - - assert openapi_request.body == {"key1": "value1", "key2": ["value2.1", "value2.2"]} - - def test_no_matched_route() -> None: """Test path_pattern when no route is matched.""" pyramid_request = DummyRequest(path="/foo") diff --git a/pyramid_openapi3/wrappers.py b/pyramid_openapi3/wrappers.py index 00ebc3f..3762d36 100644 --- a/pyramid_openapi3/wrappers.py +++ b/pyramid_openapi3/wrappers.py @@ -51,13 +51,17 @@ def method(self) -> str: @property def body(self) -> t.Optional[t.Union[bytes, str, t.Dict]]: """The request body.""" # noqa D401 - if "multipart/form-data" == self.request.content_type: - return self.request.POST.mixed() return self.request.body @property def content_type(self) -> str: """The content type of the request.""" # noqa D401 + if "multipart/form-data" == self.request.content_type: + # Pyramid does not include boundary in request.content_type, but + # openapi-core needs it to parse the request body. + return self.request.headers.environ.get( + "CONTENT_TYPE", "multipart/form-data" + ) return self.request.content_type @property