Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request response binary format support #710

Merged
merged 2 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions openapi_core/contrib/aiohttp/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Empty:
class AIOHTTPOpenAPIWebRequest:
__slots__ = ("request", "parameters", "_get_body", "_body")

def __init__(self, request: web.Request, *, body: str | None):
def __init__(self, request: web.Request, *, body: bytes | None):
if not isinstance(request, web.Request):
raise TypeError(
f"'request' argument is not type of {web.Request.__qualname__!r}"
Expand All @@ -45,7 +45,7 @@ def method(self) -> str:
return self.request.method.lower()

@property
def body(self) -> str | None:
def body(self) -> bytes | None:
return self._body

@property
Expand Down
8 changes: 4 additions & 4 deletions openapi_core/contrib/aiohttp/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
self.response = response

@property
def data(self) -> str:
def data(self) -> bytes:
if self.response.body is None:
return ""
return b""
if isinstance(self.response.body, bytes):
return self.response.body.decode("utf-8")
return self.response.body
assert isinstance(self.response.body, str)
return self.response.body
return self.response.body.encode("utf-8")

Check warning on line 22 in openapi_core/contrib/aiohttp/responses.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/contrib/aiohttp/responses.py#L22

Added line #L22 was not covered by tests

@property
def status_code(self) -> int:
Expand Down
4 changes: 2 additions & 2 deletions openapi_core/contrib/django/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ def method(self) -> str:
return self.request.method.lower()

@property
def body(self) -> str:
def body(self) -> bytes:
assert isinstance(self.request.body, bytes)
return self.request.body.decode("utf-8")
return self.request.body

@property
def content_type(self) -> str:
Expand Down
16 changes: 12 additions & 4 deletions openapi_core/contrib/django/responses.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
"""OpenAPI core contrib django responses module"""
from itertools import tee

from django.http.response import HttpResponse
from django.http.response import StreamingHttpResponse
from werkzeug.datastructures import Headers


class DjangoOpenAPIResponse:
def __init__(self, response: HttpResponse):
if not isinstance(response, HttpResponse):
if not isinstance(response, (HttpResponse, StreamingHttpResponse)):
raise TypeError(
f"'response' argument is not type of {HttpResponse}"
f"'response' argument is not type of {HttpResponse} or {StreamingHttpResponse}"
)
self.response = response

@property
def data(self) -> str:
def data(self) -> bytes:
if isinstance(self.response, StreamingHttpResponse):
resp_iter1, resp_iter2 = tee(self.response._iterator)
self.response.streaming_content = resp_iter1
content = b"".join(map(self.response.make_bytes, resp_iter2))
return content
assert isinstance(self.response.content, bytes)
return self.response.content.decode("utf-8")
return self.response.content

@property
def status_code(self) -> int:
Expand Down
11 changes: 6 additions & 5 deletions openapi_core/contrib/falcon/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@
return self.request.method.lower()

@property
def body(self) -> Optional[str]:
def body(self) -> Optional[bytes]:
# Falcon doesn't store raw request stream.
# That's why we need to revert deserialized data

# Support falcon-jsonify.
if hasattr(self.request, "json"):
return dumps(self.request.json)
return dumps(self.request.json).encode("utf-8")

Check warning on line 58 in openapi_core/contrib/falcon/requests.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/contrib/falcon/requests.py#L58

Added line #L58 was not covered by tests

# Falcon doesn't store raw request stream.
# That's why we need to revert serialized data
media = self.request.get_media(
default_when_empty=self.default_when_empty,
)
Expand All @@ -74,7 +75,7 @@
return None
else:
assert isinstance(body, bytes)
return body.decode("utf-8")
return body

@property
def content_type(self) -> str:
Expand Down
13 changes: 10 additions & 3 deletions openapi_core/contrib/falcon/responses.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""OpenAPI core contrib falcon responses module"""
from itertools import tee

from falcon.response import Response
from werkzeug.datastructures import Headers

Expand All @@ -10,11 +12,16 @@ def __init__(self, response: Response):
self.response = response

@property
def data(self) -> str:
def data(self) -> bytes:
if self.response.text is None:
return ""
if self.response.stream is None:
return b""
resp_iter1, resp_iter2 = tee(self.response.stream)
self.response.stream = resp_iter1
content = b"".join(resp_iter2)
return content
assert isinstance(self.response.text, str)
return self.response.text
return self.response.text.encode("utf-8")

@property
def status_code(self) -> int:
Expand Down
6 changes: 3 additions & 3 deletions openapi_core/contrib/requests/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@
return method and method.lower() or ""

@property
def body(self) -> Optional[str]:
def body(self) -> Optional[bytes]:
if self.request.body is None:
return None
if isinstance(self.request.body, bytes):
return self.request.body.decode("utf-8")
return self.request.body
assert isinstance(self.request.body, str)
# TODO: figure out if request._body_position is relevant
return self.request.body
return self.request.body.encode("utf-8")

Check warning on line 74 in openapi_core/contrib/requests/requests.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/contrib/requests/requests.py#L74

Added line #L74 was not covered by tests

@property
def content_type(self) -> str:
Expand Down
4 changes: 2 additions & 2 deletions openapi_core/contrib/requests/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ def __init__(self, response: Response):
self.response = response

@property
def data(self) -> str:
def data(self) -> bytes:
assert isinstance(self.response.content, bytes)
return self.response.content.decode("utf-8")
return self.response.content

@property
def status_code(self) -> int:
Expand Down
6 changes: 3 additions & 3 deletions openapi_core/contrib/starlette/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@
return self.request.method.lower()

@property
def body(self) -> Optional[str]:
def body(self) -> Optional[bytes]:
body = self._get_body()
if body is None:
return None
if isinstance(body, bytes):
return body.decode("utf-8")
return body
assert isinstance(body, str)
return body
return body.encode("utf-8")

Check warning on line 44 in openapi_core/contrib/starlette/requests.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/contrib/starlette/requests.py#L44

Added line #L44 was not covered by tests

@property
def content_type(self) -> str:
Expand Down
19 changes: 15 additions & 4 deletions openapi_core/contrib/starlette/responses.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
"""OpenAPI core contrib starlette responses module"""
from typing import Optional

from starlette.datastructures import Headers
from starlette.responses import Response
from starlette.responses import StreamingResponse


class StarletteOpenAPIResponse:
def __init__(self, response: Response):
def __init__(self, response: Response, data: Optional[bytes] = None):
if not isinstance(response, Response):
raise TypeError(f"'response' argument is not type of {Response}")
self.response = response

if data is None and isinstance(response, StreamingResponse):
raise RuntimeError(

Check warning on line 16 in openapi_core/contrib/starlette/responses.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/contrib/starlette/responses.py#L16

Added line #L16 was not covered by tests
f"'data' argument is required for {StreamingResponse}"
)
self._data = data

@property
def data(self) -> str:
def data(self) -> bytes:
if self._data is not None:
return self._data
if isinstance(self.response.body, bytes):
return self.response.body.decode("utf-8")
return self.response.body
assert isinstance(self.response.body, str)
return self.response.body
return self.response.body.encode("utf-8")

Check warning on line 28 in openapi_core/contrib/starlette/responses.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/contrib/starlette/responses.py#L28

Added line #L28 was not covered by tests

@property
def status_code(self) -> int:
Expand Down
4 changes: 2 additions & 2 deletions openapi_core/contrib/werkzeug/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def method(self) -> str:
return self.request.method.lower()

@property
def body(self) -> Optional[str]:
return self.request.get_data(as_text=True)
def body(self) -> Optional[bytes]:
return self.request.get_data(as_text=False)

@property
def content_type(self) -> str:
Expand Down
10 changes: 8 additions & 2 deletions openapi_core/contrib/werkzeug/responses.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""OpenAPI core contrib werkzeug responses module"""
from itertools import tee

from werkzeug.datastructures import Headers
from werkzeug.wrappers import Response

Expand All @@ -10,8 +12,12 @@ def __init__(self, response: Response):
self.response = response

@property
def data(self) -> str:
return self.response.get_data(as_text=True)
def data(self) -> bytes:
if not self.response.is_sequence:
resp_iter1, resp_iter2 = tee(self.response.iter_encoded())
self.response.response = resp_iter1
return b"".join(resp_iter2)
return self.response.get_data(as_text=False)

@property
def status_code(self) -> int:
Expand Down
2 changes: 1 addition & 1 deletion openapi_core/deserializing/media_types/datatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
from typing import Callable
from typing import Dict

DeserializerCallable = Callable[[Any], Any]
DeserializerCallable = Callable[[bytes], Any]
MediaTypeDeserializersDict = Dict[str, DeserializerCallable]
9 changes: 5 additions & 4 deletions openapi_core/deserializing/media_types/deserializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ def __init__(
extra_media_type_deserializers = {}
self.extra_media_type_deserializers = extra_media_type_deserializers

def deserialize(self, mimetype: str, value: Any, **parameters: str) -> Any:
def deserialize(
self, mimetype: str, value: bytes, **parameters: str
) -> Any:
deserializer_callable = self.get_deserializer_callable(mimetype)

try:
Expand Down Expand Up @@ -75,7 +77,7 @@ def __init__(
self.encoding = encoding
self.parameters = parameters

def deserialize(self, value: Any) -> Any:
def deserialize(self, value: bytes) -> Any:
deserialized = self.media_types_deserializer.deserialize(
self.mimetype, value, **self.parameters
)
Expand Down Expand Up @@ -192,5 +194,4 @@ def decode_property_content_type(
value = location.getlist(prop_name)
return list(map(prop_deserializer.deserialize, value))

value = location[prop_name]
return prop_deserializer.deserialize(value)
return prop_deserializer.deserialize(location[prop_name])
4 changes: 2 additions & 2 deletions openapi_core/deserializing/media_types/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ class MediaTypeDeserializeError(DeserializeError):
"""Media type deserialize operation error"""

mimetype: str
value: str
value: bytes

def __str__(self) -> str:
return (
"Failed to deserialize value with {mimetype} mimetype: {value}"
).format(value=self.value, mimetype=self.mimetype)
).format(value=self.value.decode("utf-8"), mimetype=self.mimetype)
37 changes: 19 additions & 18 deletions openapi_core/deserializing/media_types/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,11 @@
from werkzeug.datastructures import ImmutableMultiDict


def binary_loads(value: Union[str, bytes], **parameters: str) -> bytes:
charset = "utf-8"
if "charset" in parameters:
charset = parameters["charset"]
if isinstance(value, str):
return value.encode(charset)
def binary_loads(value: bytes, **parameters: str) -> bytes:
return value


def plain_loads(value: Union[str, bytes], **parameters: str) -> str:
def plain_loads(value: bytes, **parameters: str) -> str:
charset = "utf-8"
if "charset" in parameters:
charset = parameters["charset"]
Expand All @@ -32,30 +27,36 @@
return value


def json_loads(value: Union[str, bytes], **parameters: str) -> Any:
def json_loads(value: bytes, **parameters: str) -> Any:
return loads(value)


def xml_loads(value: Union[str, bytes], **parameters: str) -> Element:
return fromstring(value)
def xml_loads(value: bytes, **parameters: str) -> Element:
charset = "utf-8"
if "charset" in parameters:
charset = parameters["charset"]
return fromstring(value.decode(charset))


def urlencoded_form_loads(value: Any, **parameters: str) -> Mapping[str, Any]:
return ImmutableMultiDict(parse_qsl(value))
def urlencoded_form_loads(
value: bytes, **parameters: str
) -> Mapping[str, Any]:
# only UTF-8 is conforming
return ImmutableMultiDict(parse_qsl(value.decode("utf-8")))


def data_form_loads(
value: Union[str, bytes], **parameters: str
) -> Mapping[str, Any]:
if isinstance(value, bytes):
value = value.decode("ASCII", errors="surrogateescape")
def data_form_loads(value: bytes, **parameters: str) -> Mapping[str, Any]:
charset = "ASCII"
if "charset" in parameters:
charset = parameters["charset"]

Check warning on line 51 in openapi_core/deserializing/media_types/util.py

View check run for this annotation

Codecov / codecov/patch

openapi_core/deserializing/media_types/util.py#L51

Added line #L51 was not covered by tests
decoded = value.decode(charset, errors="surrogateescape")
boundary = ""
if "boundary" in parameters:
boundary = parameters["boundary"]
parser = Parser()
mimetype = "multipart/form-data"
header = f'Content-Type: {mimetype}; boundary="{boundary}"'
text = "\n\n".join([header, value])
text = "\n\n".join([header, decoded])
parts = parser.parsestr(text, headersonly=False)
return ImmutableMultiDict(
[
Expand Down
4 changes: 2 additions & 2 deletions openapi_core/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def method(self) -> str:
...

@property
def body(self) -> Optional[str]:
def body(self) -> Optional[bytes]:
...

@property
Expand Down Expand Up @@ -120,7 +120,7 @@ class Response(Protocol):
"""

@property
def data(self) -> str:
def data(self) -> Optional[bytes]:
...

@property
Expand Down
2 changes: 1 addition & 1 deletion openapi_core/testing/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __init__(
view_args: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
cookies: Optional[Dict[str, Any]] = None,
data: Optional[str] = None,
data: Optional[bytes] = None,
content_type: str = "application/json",
):
self.host_url = host_url
Expand Down
Loading
Loading