From ec709daf582f7eaac449eda3e6537593da2f5c15 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Sun, 15 Oct 2023 13:05:12 +0200 Subject: [PATCH] Update response handling documenation --- connexion/decorators/main.py | 30 ++- connexion/decorators/response.py | 98 ++++++---- connexion/middleware/request_validation.py | 3 +- connexion/middleware/response_validation.py | 3 +- connexion/utils.py | 55 ++++-- docs/request.rst | 10 +- docs/response.rst | 206 ++++++++++++++------ tests/api/test_responses.py | 2 +- 8 files changed, 277 insertions(+), 130 deletions(-) diff --git a/connexion/decorators/main.py b/connexion/decorators/main.py index c94557ec1..dece43353 100644 --- a/connexion/decorators/main.py +++ b/connexion/decorators/main.py @@ -16,6 +16,7 @@ from connexion.decorators.response import ( AsyncResponseDecorator, BaseResponseDecorator, + NoResponseDecorator, SyncResponseDecorator, ) from connexion.frameworks.abstract import Framework @@ -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 @@ -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]: @@ -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 @@ -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 diff --git a/connexion/decorators/response.py b/connexion/decorators/response.py index b3485b5d9..a84d74901 100644 --- a/connexion/decorators/response.py +++ b/connexion/decorators/response.py @@ -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__) @@ -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)) @@ -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: dict[str, str], *, content_type: str + ) -> 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( @@ -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) diff --git a/connexion/middleware/request_validation.py b/connexion/middleware/request_validation.py index 83e0a8040..a30bf4157 100644 --- a/connexion/middleware/request_validation.py +++ b/connexion/middleware/request_validation.py @@ -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: diff --git a/connexion/middleware/response_validation.py b/connexion/middleware/response_validation.py index 1978084ec..ea0baf7cf 100644 --- a/connexion/middleware/response_validation.py +++ b/connexion/middleware/response_validation.py @@ -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: diff --git a/connexion/utils.py b/connexion/utils.py index 550b12dd2..259e45753 100644 --- a/connexion/utils.py +++ b/connexion/utils.py @@ -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 diff --git a/docs/request.rst b/docs/request.rst index 5c775a32d..9b8eb44d7 100644 --- a/docs/request.rst +++ b/docs/request.rst @@ -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/", methods=["POST"]) @ASGIDecorator() diff --git a/docs/response.rst b/docs/response.rst index 6cb49c233..9d675fb66 100644 --- a/docs/response.rst +++ b/docs/response.rst @@ -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 ---------------------- -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. +.. tab-set:: -If the endpoint produces a single non-JSON mimetype then Connexion will -automatically set the right content type in the HTTP header. + .. tab-item:: AsyncApp + :sync: AsyncApp -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`. + When working with Connexion, you can return ordinary Python types, and connexion will serialize + them into a network response. -For more information on the `JSONEncoder`, see the `Flask documentation`_. + .. tab-item:: FlaskApp + :sync: FlaskApp -.. _Flask Documentation: https://flask.palletsprojects.com/en/2.0.x/api/#flask.json.JSONEncoder + When working with Connexion, you can return ordinary Python types, and connexion will serialize + them into a network response. -Returning status codes ----------------------- -There are two ways of returning a specific status code. + .. tab-item:: ConnexionMiddleware + :sync: ConnexionMiddleware -One way is to return a `Response` object that will be used unchanged. + When working with Connexion, you can return ordinary Python types, and connexion will serialize + them into a network response. -The other is returning it as a second return value in the response. For example + To activate this behavior when using the ``ConnexionMiddleware`` wrapping a third party + application, you can leverage the following decorators provided by Connexion: -.. code-block:: python + * ``FlaskDecorator``: provides automatic parameter injection and response serialization for + Flask applications. - def my_endpoint(): - return 'Not Found', 404 + .. code-block:: python + :caption: **app.py** -Returning Headers ------------------ -There are two ways to return headers from your endpoints. + from connexion import ConnexionMiddleware + from connexion.decorators import FlaskDecorator + from flask import Flask -One way is to return a `Response` object that will be used unchanged. + app = Flask(__name__) + app = ConnexionMiddleware(app) + app.add_api("openapi.yaml") -The other is returning a dict with the header values as the third return value -in the response: + @app.route("/endpoint") + @FlaskDecorator() + def endpoint(name): + ... -For example + * ``StarletteDecorator``: provides automatic parameter injection and response serialization + for Starlette applications. -.. code-block:: python + .. 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 - def my_endpoint(): - return 'Not Found', 404, {'x-error': 'not found'} + @StarletteDecorator() + def endpoint(name): + ... + app = Starlette(routes=[Route('/endpoint', endpoint)]) + app = ConnexionMiddleware(app) + app.add_api("openapi.yaml") -Response Validation -------------------- -While, by default Connexion doesn't validate the responses it's possible to -do so by opting in when adding the API: + 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** - import connexion + def endpoint(): + data = "success" + status_code = 200 + headers = {"Content-Type": "text/plain} + return data, status_code, headers - app = connexion.FlaskApp(__name__, specification_dir='swagger/') - app.add_api('my_api.yaml', validate_responses=True) - app.run(port=8080) +Data +```` -This will validate all the responses using `jsonschema` and is specially useful -during development. +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** -Custom Validator ------------------ +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: -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: +.. tab-set:: -.. code-block:: python + .. 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) - validator_map = { - 'response': CustomResponseValidator - } - app = connexion.FlaskApp(__name__) - app.add_api('api.yaml', ..., validator_map=validator_map) + .. tab-item:: FlaskApp + :sync: FlaskApp -Error Handling --------------- -By default connexion error messages are JSON serialized according to -`Problem Details for HTTP APIs`_ + .. code-block:: python + :caption: **app.py** -Application can return errors using ``connexion.problem``. + 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. -.. _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 diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index f28a09e84..e9649bf0c 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -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):