Update response handling documenation (#1742)

Contributes to #1531
This commit is contained in:
Robbe Sneyders
2023-10-16 23:51:21 +02:00
committed by GitHub
parent b102ad9f6c
commit 17fcad0c30
8 changed files with 309 additions and 162 deletions

View File

@@ -16,6 +16,7 @@ from connexion.decorators.parameter import (
from connexion.decorators.response import (
AsyncResponseDecorator,
BaseResponseDecorator,
NoResponseDecorator,
SyncResponseDecorator,
)
from connexion.frameworks.abstract import Framework
@@ -94,10 +95,12 @@ class BaseDecorator:
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
@@ -106,8 +109,8 @@ class FlaskDecorator(BaseDecorator):
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]:
@@ -133,6 +136,17 @@ class FlaskDecorator(BaseDecorator):
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
@@ -148,10 +162,6 @@ class ASGIDecorator(BaseDecorator):
@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

View File

@@ -6,12 +6,12 @@ import types
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__)
@@ -27,27 +27,27 @@ class BaseResponseDecorator:
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))
@@ -66,45 +66,56 @@ class BaseResponseDecorator:
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
if data is not None:
body = self._serialize_data(data, mimetype)
else:
body = data
return body, status_code
def _serialize_data(self, data: t.Any, mimetype: str) -> t.Any:
if is_json_mimetype(mimetype):
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
@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
@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
if content_type:
headers["Content-Type"] = content_type
return headers
@staticmethod
def _unpack_handler_response(
handler_response: t.Union[str, bytes, dict, list, tuple]
@@ -186,3 +197,10 @@ class AsyncResponseDecorator(BaseResponseDecorator):
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)

View File

@@ -40,7 +40,8 @@ class RequestValidationOperation:
: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:

View File

@@ -38,7 +38,8 @@ class ResponseValidationOperation:
: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:

View File

@@ -288,31 +288,56 @@ def not_installed_error(exc, *, msg=None): # pragma: no cover
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 decoded_key.lower() == "content-type":
content_type = value.decode("latin-1")
if ";" in content_type:
mime_type, parameters = content_type.split(";", maxsplit=1)
if isinstance(key, bytes):
decoded_key: str = key.decode("latin-1")
else:
decoded_key = key
prefix = "charset="
for parameter in parameters.split(";"):
if parameter.startswith(prefix):
encoding = parameter[len(prefix) :]
if decoded_key.lower() == "content-type":
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

View File

@@ -43,13 +43,16 @@ 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
@@ -57,6 +60,7 @@ Automatic parameter handling
from asgi_framework import App
from connexion import ConnexionMiddleware
from connexion.decorators import ASGIDecorator
@app.route("/greeting/<name>", methods=["POST"])
@ASGIDecorator()

View File

@@ -1,97 +1,185 @@
Response Handling
=================
When your application returns a response, Connexion provides the following functionality based on
your OpenAPI spec:
- It automatically translates Python errors into HTTP problem responses (see :doc:`exceptions`)
- It automatically serializes the response for certain content types
- It validates the response body and headers (see :doc:`validation`)
On this page, we zoom in on the response serialization.
Response Serialization
----------------------
.. tab-set::
.. tab-item:: AsyncApp
:sync: AsyncApp
When working with Connexion, you can return ordinary Python types, and connexion will serialize
them into a network response.
.. tab-item:: FlaskApp
:sync: FlaskApp
When working with Connexion, you can return ordinary Python types, and connexion will serialize
them into a network response.
.. tab-item:: ConnexionMiddleware
:sync: ConnexionMiddleware
When working with Connexion, you can return ordinary Python types, and connexion will serialize
them into a network response.
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
Flask applications.
.. code-block:: python
:caption: **app.py**
from connexion import ConnexionMiddleware
from connexion.decorators import FlaskDecorator
from flask import Flask
app = Flask(__name__)
app = ConnexionMiddleware(app)
app.add_api("openapi.yaml")
@app.route("/endpoint")
@FlaskDecorator()
def endpoint(name):
...
* ``StarletteDecorator``: provides automatic parameter injection and response serialization
for Starlette applications.
.. code-block:: python
:caption: **app.py**
from connexion import ConnexionMiddleware
from connexion.decorators import StarletteDecorator
from starlette.applications import Starlette
from starlette.routing import Route
@StarletteDecorator()
def endpoint(name):
...
app = Starlette(routes=[Route('/endpoint', endpoint)])
app = ConnexionMiddleware(app)
app.add_api("openapi.yaml")
For a full example, see our `Frameworks`_ example.
The generic ``connexion.decorators.WSGIDecorator`` and
``connexion.decorators.ASGIDecorator`` unfortunately don't support response
serialization, but you can extend them to implement your own decorator for a specific
WSGI or ASGI framework respectively.
.. note::
If you implement a custom decorator, and think it would be valuable for other users, we
would appreciate it as a contribution.
.. code-block:: python
:caption: **api.py**
def endpoint():
data = "success"
status_code = 200
headers = {"Content-Type": "text/plain}
return data, status_code, headers
Data
````
If your API returns responses with the ``application/json`` content type, you can return
a simple ``dict`` or ``list`` and Connexion will serialize (``json.dumps``) the data for you.
**Customizing JSON serialization**
Connexion allows you to customize the ``Jsonifier`` used to serialize json data by subclassing the
``connexion.jsonifier.Jsonifier`` class and passing it when instantiating your app or registering
an API:
.. tab-set::
.. tab-item:: AsyncApp
:sync: AsyncApp
.. code-block:: python
:caption: **app.py**
from connexion import AsyncApp
app = AsyncApp(__name__, jsonifier=)
app.add_api("openapi.yaml", jsonifier=c)
.. tab-item:: FlaskApp
:sync: FlaskApp
.. code-block:: python
:caption: **app.py**
from connexion import FlaskApp
app = FlaskApp(__name__, jsonifier=...)
app.add_api("openapi.yaml", jsonifier=...):
.. tab-item:: ConnexionMiddleware
:sync: ConnexionMiddleware
.. code-block:: python
:caption: **app.py**
from asgi_framework import App
from connexion import ConnexionMiddleware
app = App(__name__)
app = ConnexionMiddleware(app, jsonifier=...)
app.add_api("openapi.yaml", jsonifier=...)
Status code
```````````
If no status code is provided, Connexion will automatically set it as ``200`` if data is
returned, or as ``204`` if ``None`` or ``connexion.datastructures.NoContent`` is returned.
Headers
```````
The headers can be used to define any response headers to return. If your OpenAPI specification
defines multiple responses with different content types, you can explicitly set the
``Content-Type`` header to tell Connexion which response to validate against.
If you do not explicitly return a ``Content-Type`` header, Connexion's behavior depends on the
Responses defined in your OpenAPI spec:
* If you have defined a single response content type in your OpenAPI specification, Connexion
will automatically set it.
* If you have defined multiple response content types in your OpenAPI specification, Connexion
will try to infer which one matches your response and set it. If it cannot infer the content
type, an error is raised.
* If you have not defined a response content type in your OpenAPI specification, Connexion will
automatically set it to ``application/json`` unless you don't return any data. This is mostly
because of backward-compatibility, and can be circumvented easily by defining a response
content type in your OpenAPI specification.
Skipping response serialization
-------------------------------
If your endpoint returns an instance of ``connexion.lifecycle.ConnexionResponse``, or a
framework-specific response (``flask.Response`` or ``starlette.responses.Response``), response
serialization is skipped, and the response is passed directly to the underlying framework.
If your endpoint returns a `Response`
If the endpoint returns a `Response` object this response will be used as is.
Otherwise, and by default and if the specification defines that an endpoint
produces only JSON, connexion will automatically serialize the return value
for you and set the right content type in the HTTP header.
If the endpoint produces a single non-JSON mimetype then Connexion will
automatically set the right content type in the HTTP header.
Customizing JSON encoder
^^^^^^^^^^^^^^^^^^^^^^^^
Connexion allows you to customize the `JSONEncoder` class in the Flask app
instance `json_encoder` (`connexion.App:app`). If you wanna reuse the
Connexion's date-time serialization, inherit your custom encoder from
`connexion.apps.flask_app.FlaskJSONEncoder`.
For more information on the `JSONEncoder`, see the `Flask documentation`_.
.. _Flask Documentation: https://flask.palletsprojects.com/en/2.0.x/api/#flask.json.JSONEncoder
Returning status codes
----------------------
There are two ways of returning a specific status code.
One way is to return a `Response` object that will be used unchanged.
The other is returning it as a second return value in the response. For example
.. code-block:: python
def my_endpoint():
return 'Not Found', 404
Returning Headers
-----------------
There are two ways to return headers from your endpoints.
One way is to return a `Response` object that will be used unchanged.
The other is returning a dict with the header values as the third return value
in the response:
For example
.. code-block:: python
def my_endpoint():
return 'Not Found', 404, {'x-error': 'not found'}
Response Validation
-------------------
While, by default Connexion doesn't validate the responses it's possible to
do so by opting in when adding the API:
.. code-block:: python
import connexion
app = connexion.FlaskApp(__name__, specification_dir='swagger/')
app.add_api('my_api.yaml', validate_responses=True)
app.run(port=8080)
This will validate all the responses using `jsonschema` and is specially useful
during development.
Custom Validator
-----------------
By default, response body contents are validated against OpenAPI schema
via ``connexion.decorators.response.ResponseValidator``, if you want to change
the validation, you can override the default class with:
.. code-block:: python
validator_map = {
'response': CustomResponseValidator
}
app = connexion.FlaskApp(__name__)
app.add_api('api.yaml', ..., validator_map=validator_map)
Error Handling
--------------
By default connexion error messages are JSON serialized according to
`Problem Details for HTTP APIs`_
Application can return errors using ``connexion.problem``.
.. _Problem Details for HTTP APIs: https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
.. _Frameworks: https://github.com/spec-first/connexion/tree/main/examples/frameworks

View File

@@ -81,7 +81,7 @@ def test_produce_decorator(simple_app):
app_client = simple_app.test_client()
get_bye = app_client.get("/v1.0/bye/jsantos")
assert get_bye.headers.get("content-type") == "text/plain; charset=utf-8"
assert get_bye.headers.get("content-type", "").startswith("text/plain")
def test_returning_response_tuple(simple_app):