diff --git a/connexion/decorators/main.py b/connexion/decorators/main.py index c94557e..dece433 100644 --- a/connexion/decorators/main.py +++ b/connexion/decorators/main.py @@ -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 diff --git a/connexion/decorators/response.py b/connexion/decorators/response.py index b3485b5..160ace6 100644 --- a/connexion/decorators/response.py +++ b/connexion/decorators/response.py @@ -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) diff --git a/connexion/middleware/request_validation.py b/connexion/middleware/request_validation.py index 83e0a80..a30bf41 100644 --- a/connexion/middleware/request_validation.py +++ b/connexion/middleware/request_validation.py @@ -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: diff --git a/connexion/middleware/response_validation.py b/connexion/middleware/response_validation.py index 1978084..ea0baf7 100644 --- a/connexion/middleware/response_validation.py +++ b/connexion/middleware/response_validation.py @@ -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: diff --git a/connexion/utils.py b/connexion/utils.py index 550b12d..259e457 100644 --- a/connexion/utils.py +++ b/connexion/utils.py @@ -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 diff --git a/docs/request.rst b/docs/request.rst index 5c775a3..9b8eb44 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 6cb49c2..9d675fb 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 ---------------------- + +.. 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 diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index f28a09e..e9649bf 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):