Skip to content

Commit

Permalink
Update response handling documenation (#1742)
Browse files Browse the repository at this point in the history
Contributes to #1531
  • Loading branch information
RobbeSneyders committed Oct 16, 2023
1 parent b102ad9 commit 17fcad0
Show file tree
Hide file tree
Showing 8 changed files with 277 additions and 130 deletions.
30 changes: 20 additions & 10 deletions connexion/decorators/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from connexion.decorators.response import (
AsyncResponseDecorator,
BaseResponseDecorator,
NoResponseDecorator,
SyncResponseDecorator,
)
from connexion.frameworks.abstract import Framework
Expand Down Expand Up @@ -94,10 +95,12 @@ def __call__(self, function: t.Callable) -> t.Callable:
raise NotImplementedError


class FlaskDecorator(BaseDecorator):
"""Decorator for usage with Flask. The parameter decorator works with a Flask request,
and provides Flask datastructures to the view function. The response decorator returns
a Flask response"""
class WSGIDecorator(BaseDecorator):
"""Decorator for usage with WSGI apps. The parameter decorator works with a Flask request,
and provides Flask datastructures to the view function. This works for any WSGI app, since
we get the request via the connexion context provided by WSGI middleware.
This decorator does not parse responses, but passes them directly to the WSGI App."""

framework = FlaskFramework

Expand All @@ -106,8 +109,8 @@ def _parameter_decorator_cls(self) -> t.Type[SyncParameterDecorator]:
return SyncParameterDecorator

@property
def _response_decorator_cls(self) -> t.Type[SyncResponseDecorator]:
return SyncResponseDecorator
def _response_decorator_cls(self) -> t.Type[BaseResponseDecorator]:
return NoResponseDecorator

@property
def _sync_async_decorator(self) -> t.Callable[[t.Callable], t.Callable]:
Expand All @@ -133,6 +136,17 @@ def wrapper(*args, **kwargs):
return wrapper


class FlaskDecorator(WSGIDecorator):
"""Decorator for usage with Connexion or Flask apps. The parameter decorator works with a
Flask request, and provides Flask datastructures to the view function.
The response decorator returns Flask responses."""

@property
def _response_decorator_cls(self) -> t.Type[SyncResponseDecorator]:
return SyncResponseDecorator


class ASGIDecorator(BaseDecorator):
"""Decorator for usage with ASGI apps. The parameter decorator works with a Starlette request,
and provides Starlette datastructures to the view function. This works for any ASGI app, since
Expand All @@ -148,10 +162,6 @@ def _parameter_decorator_cls(self) -> t.Type[AsyncParameterDecorator]:

@property
def _response_decorator_cls(self) -> t.Type[BaseResponseDecorator]:
class NoResponseDecorator(BaseResponseDecorator):
def __call__(self, function: t.Callable) -> t.Callable:
return lambda request: function(request)

return NoResponseDecorator

@property
Expand Down
98 changes: 58 additions & 40 deletions connexion/decorators/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
import typing as t
from enum import Enum

from connexion import utils
from connexion.context import operation
from connexion.datastructures import NoContent
from connexion.exceptions import NonConformingResponseHeaders
from connexion.frameworks.abstract import Framework
from connexion.lifecycle import ConnexionResponse
from connexion.utils import is_json_mimetype

logger = logging.getLogger(__name__)

Expand All @@ -27,27 +27,27 @@ def __call__(self, function: t.Callable) -> t.Callable:

def build_framework_response(self, handler_response):
data, status_code, headers = self._unpack_handler_response(handler_response)
content_type = self._deduct_content_type(data, headers)
content_type = self._infer_content_type(data, headers)
if not self.framework.is_framework_response(data):
data, status_code = self._prepare_body_and_status_code(
data, status_code=status_code, mimetype=content_type
)
data = self._serialize_data(data, content_type=content_type)
status_code = status_code or self._infer_status_code(data)
headers = self._update_headers(headers, content_type=content_type)
return self.framework.build_response(
data, content_type=content_type, status_code=status_code, headers=headers
)

@staticmethod
def _deduct_content_type(data: t.Any, headers: dict) -> str:
"""Deduct the response content type from the returned data, headers and operation spec.
def _infer_content_type(data: t.Any, headers: dict) -> t.Optional[str]:
"""Infer the response content type from the returned data, headers and operation spec.
:param data: Response data
:param headers: Headers returned by the handler.
:return: Deducted content type
:return: Inferred content type
:raises: NonConformingResponseHeaders if content type cannot be deducted.
"""
content_type = headers.get("Content-Type")
content_type = utils.extract_content_type(headers)

# TODO: don't default
produces = list(set(operation.produces))
Expand All @@ -66,44 +66,55 @@ def _deduct_content_type(data: t.Any, headers: dict) -> str:
pass
elif len(produces) == 1:
content_type = produces[0]
elif isinstance(data, str) and "text/plain" in produces:
content_type = "text/plain"
elif (
isinstance(data, bytes)
or isinstance(data, (types.GeneratorType, collections.abc.Iterator))
) and "application/octet-stream" in produces:
content_type = "application/octet-stream"
else:
raise NonConformingResponseHeaders(
"Multiple response content types are defined in the operation spec, but the "
"handler response did not specify which one to return."
)
if isinstance(data, str):
for produced_content_type in produces:
if "text/plain" in produced_content_type:
content_type = produced_content_type
elif isinstance(data, bytes) or isinstance(
data, (types.GeneratorType, collections.abc.Iterator)
):
for produced_content_type in produces:
if "application/octet-stream" in produced_content_type:
content_type = produced_content_type

if content_type is None:
raise NonConformingResponseHeaders(
"Multiple response content types are defined in the operation spec, but "
"the handler response did not specify which one to return."
)

return content_type

def _prepare_body_and_status_code(
self, data, *, status_code: int = None, mimetype: str
) -> tuple:
if data is NoContent:
data = None

if status_code is None:
if data is None:
status_code = 204
else:
status_code = 200
def _serialize_data(self, data: t.Any, *, content_type: str) -> t.Any:
"""Serialize the data based on the content type."""
if data is None or data is NoContent:
return None
# TODO: encode responses
mime_type, _ = utils.split_content_type(content_type)
if utils.is_json_mimetype(mime_type):
return self.jsonifier.dumps(data)
return data

if data is not None:
body = self._serialize_data(data, mimetype)
else:
body = data
@staticmethod
def _infer_status_code(data: t.Any) -> int:
"""Infer the status code from the returned data."""
if data is None:
return 204
return 200

return body, status_code
@staticmethod
def _update_headers(
headers: t.Dict[str, str], *, content_type: str
) -> t.Dict[str, str]:
# Check if Content-Type is in headers, taking into account case-insensitivity
for key, value in headers.items():
if key.lower() == "content-type":
return headers

def _serialize_data(self, data: t.Any, mimetype: str) -> t.Any:
if is_json_mimetype(mimetype):
return self.jsonifier.dumps(data)
return data
if content_type:
headers["Content-Type"] = content_type
return headers

@staticmethod
def _unpack_handler_response(
Expand Down Expand Up @@ -186,3 +197,10 @@ async def wrapper(*args, **kwargs):
return self.build_framework_response(handler_response)

return wrapper


class NoResponseDecorator(BaseResponseDecorator):
"""Dummy decorator to skip response serialization."""

def __call__(self, function: t.Callable) -> t.Callable:
return lambda request: function(request)
3 changes: 2 additions & 1 deletion connexion/middleware/request_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ def extract_content_type(
:return: A tuple of mime type, encoding
"""
mime_type, encoding = utils.extract_content_type(headers)
content_type = utils.extract_content_type(headers)
mime_type, encoding = utils.split_content_type(content_type)
if mime_type is None:
# Content-type header is not required. Take a best guess.
try:
Expand Down
3 changes: 2 additions & 1 deletion connexion/middleware/response_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def extract_content_type(
:return: A tuple of mime type, encoding
"""
mime_type, encoding = utils.extract_content_type(headers)
content_type = utils.extract_content_type(headers)
mime_type, encoding = utils.split_content_type(content_type)
if mime_type is None:
# Content-type header is not required. Take a best guess.
try:
Expand Down
55 changes: 40 additions & 15 deletions connexion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,31 +288,56 @@ def _delayed_error(*args, **kwargs):


def extract_content_type(
headers: t.List[t.Tuple[bytes, bytes]]
) -> t.Tuple[t.Optional[str], t.Optional[str]]:
headers: t.Union[t.List[t.Tuple[bytes, bytes]], t.Dict[str, str]]
) -> t.Optional[str]:
"""Extract the mime type and encoding from the content type headers.
:param headers: Headers from ASGI scope
:return: A tuple of mime type, encoding
:return: The content type if available in headers, otherwise None
"""
mime_type, encoding = None, None
for key, value in headers:
content_type: t.Optional[str] = None

header_pairs_type = t.Collection[t.Tuple[t.Union[str, bytes], t.Union[str, bytes]]]
header_pairs: header_pairs_type = headers.items() if isinstance(headers, dict) else headers # type: ignore
for key, value in header_pairs:
# Headers can always be decoded using latin-1:
# https://stackoverflow.com/a/27357138/4098821
decoded_key = key.decode("latin-1")
if isinstance(key, bytes):
decoded_key: str = key.decode("latin-1")
else:
decoded_key = key

if decoded_key.lower() == "content-type":
content_type = value.decode("latin-1")
if ";" in content_type:
mime_type, parameters = content_type.split(";", maxsplit=1)

prefix = "charset="
for parameter in parameters.split(";"):
if parameter.startswith(prefix):
encoding = parameter[len(prefix) :]
if isinstance(value, bytes):
content_type = value.decode("latin-1")
else:
mime_type = content_type
content_type = value
break

return content_type


def split_content_type(
content_type: t.Optional[str],
) -> t.Tuple[t.Optional[str], t.Optional[str]]:
"""Split the content type in mime_type and encoding. Other parameters are ignored."""
mime_type, encoding = None, None

if content_type is None:
return mime_type, encoding

# Check for parameters
if ";" in content_type:
mime_type, parameters = content_type.split(";", maxsplit=1)

# Find parameter describing the charset
prefix = "charset="
for parameter in parameters.split(";"):
if parameter.startswith(prefix):
encoding = parameter[len(prefix) :]
else:
mime_type = content_type
return mime_type, encoding


Expand Down
10 changes: 7 additions & 3 deletions docs/request.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,24 @@ Automatic parameter handling
To activate this behavior when using the ``ConnexionMiddleware`` wrapping a third party
application, you can leverage the following decorators provided by Connexion:

* FlaskDecorator: provides automatic parameter injection and response serialization for
* ``WSGIDecorator``: provides automatic parameter injection for WSGI applications. Note
that this decorator injects Werkzeug / Flask datastructures.

* ``FlaskDecorator``: provides automatic parameter injection and response serialization for
Flask applications.

* ASGIDecorator: provides automatic parameter injection for ASGI applications. Note that
* ``ASGIDecorator``: provides automatic parameter injection for ASGI applications. Note that
this decorator injects Starlette datastructures (such as UploadFile).

* StarletteDecorator: provides automatic parameter injection and response serialization
* ``StarletteDecorator``: provides automatic parameter injection and response serialization
for Starlette applications.

.. code-block:: python
:caption: **app.py**
from asgi_framework import App
from connexion import ConnexionMiddleware
from connexion.decorators import ASGIDecorator
@app.route("/greeting/<name>", methods=["POST"])
@ASGIDecorator()
Expand Down

0 comments on commit 17fcad0

Please sign in to comment.