From 655ea430ed9c54fb9c90fe222008a1daf9c69670 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Tue, 17 Oct 2023 23:57:06 +0200 Subject: [PATCH] Add validation documentation (#1743) Contributes to #1531 --- docs/conf.py | 2 + docs/index.rst | 1 + docs/request.rst | 19 +++ docs/validation.rst | 325 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 docs/validation.rst diff --git a/docs/conf.py b/docs/conf.py index bddf73dc0..c446d93ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,9 @@ 'sphinx.ext.autodoc', 'sphinx_copybutton', 'sphinx_design', + 'sphinx.ext.autosectionlabel', ] +autosectionlabel_prefix_document = True autoclass_content = 'both' diff --git a/docs/index.rst b/docs/index.rst index f7dfa86be..e788ae98d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -69,6 +69,7 @@ Documentation cli request response + validation security cookbook exceptions diff --git a/docs/request.rst b/docs/request.rst index 9b8eb44d7..18faaa3b1 100644 --- a/docs/request.rst +++ b/docs/request.rst @@ -220,6 +220,10 @@ The body will also be passed to your function. def foo_get(field1, field2) ... +Connexion will not automatically pass in the default values defined in your ``requestBody`` +definition, but you can activate this by configuring a different +:ref:`RequestBodyValidator`. + Optional arguments & Defaults ----------------------------- @@ -248,6 +252,21 @@ If you do define a ``**kwargs`` argument in your function signature, Connexion w arguments, and the ones not explicitly defined in your signature will be collected in the ``kwargs`` argument. +Parameter Name Sanitation +------------------------- + +The names of query and form parameters, as well as the name of the body +parameter are sanitized by removing characters that are not allowed in Python +symbols. I.e. all characters that are not letters, digits or the underscore are +removed, and finally characters are removed from the front until a letter or an +underscore is encountered. As an example: + +.. code-block:: python + + >>> re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z_]', '', '$top')) + 'top' + + Pythonic parameters ------------------- diff --git a/docs/validation.rst b/docs/validation.rst new file mode 100644 index 000000000..4e64e34da --- /dev/null +++ b/docs/validation.rst @@ -0,0 +1,325 @@ +Validation +========== + +One of the most powerful Connexion features is automatic validation based on your OpenAPI +specification. + +Connexion validates: + +- :ref:`Requests` + + - :ref:`Parameters` + - :ref:`Body` + - :ref:`Headers` + +- :ref:`Response` + + - :ref:`Body` + - :ref:`Headers` + +The validation behavior can easily be customized with :ref:`validation:Custom validators` + +Request validation +------------------ + +Connexion will validate any incoming requests against your specification and automatically +returns the correct 4XX error on failure. + +Parameter validation +```````````````````` + +By default, Connexion checks all the request for any parameters defined in your specification and +validates them against their definition. This includes their schema (``type``, ``format``, +``range``, ...) and whether or not they are required or whether they can be ``null``. + +You can turn on ``strict_validation`` if you want Connexion to disallow any extra parameters +that are not defined in your specification. You can set it either on the application or API level: + +.. tab-set:: + + .. tab-item:: AsyncApp + :sync: AsyncApp + + .. code-block:: python + :caption: **app.py** + + from connexion import AsyncApp + + app = AsyncApp(__name__, strict_validation=True) + app.add_api("openapi.yaml", strict_validation=True) + + + .. tab-item:: FlaskApp + :sync: FlaskApp + + .. code-block:: python + :caption: **app.py** + + from connexion import FlaskApp + + app = FlaskApp(__name__, strict_validation=True) + app.add_api("openapi.yaml", strict_validation=True) + + .. 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, strict_validation=True) + app.add_api("openapi.yaml", strict_validation=True) + +If parameter validation fails, Connexion will return a ``400 Bad Request`` response with +information on the failure in the description. + +For more information on how parameters are handled in general, see +:ref:`request:Request handling`. + +RequestBody validation +`````````````````````` + +Connexion can automatically validate a ``requestBody`` for ``json`` and ``formData`` content +types, for which it relies on `jsonschema`_. You can plug in your own validator for other content +types (see :ref:`validation:Custom validators`). + +.. note:: + If the ``Content-Type`` header is not set in the request, Connexion will check your + specification for which content types it accepts. If it only accepts a single content type, + Connexion assumes the request to have this content type and will validate it accordingly. If + your specification specifies no or multiple content types it accepts, Connexion will assume + the request to have content type ``application/octet-stream; charset=utf-8`` and will skip + ``requestBody`` validation. + +If ``requestBody`` validation fails, Connexion will return a ``400 Bad Request`` response with +information on the failure in the description. + +For more information on how the ``requestBody`` is handled in general, see +:ref:`request:Body`. + +Request headers validation +`````````````````````````` + +Headers and cookies are also validated against your specification. If their validation fails, +Connexion will return a ``400 Bad Request`` response with information on the failure in the +description. + +The ``Content-Type`` header is validated separately. If it fails validation, Connexion returns a +``415 Unsupported Media Type`` error. + +.. note:: + If the ``Content-Type`` header is not set in the request, Connexion will make an assumption + on the content type (see :ref:`validation:RequestBody validation`) and validate it against your + spec, which might fail. + +Response validation +------------------- + +Connexion **will not validate outgoing responses by default** , but you can activate this by passing +the ``validate_responses`` argument to either your application or API: + +.. tab-set:: + + .. tab-item:: AsyncApp + :sync: AsyncApp + + .. code-block:: python + :caption: **app.py** + + from connexion import AsyncApp + + app = AsyncApp(__name__, validate_responses=True) + app.add_api("openapi.yaml", validate_responses=True) + + + .. tab-item:: FlaskApp + :sync: FlaskApp + + .. code-block:: python + :caption: **app.py** + + from connexion import FlaskApp + + app = FlaskApp(__name__, validate_responses=True) + app.add_api("openapi.yaml", validate_responses=True) + + .. 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, validate_responses=True) + app.add_api("openapi.yaml", validate_responses=True) + +ResponseBody validation +``````````````````````` + +Connexion has built-in validators for the ``application/json`` and ``text/plain`` content types. +If the content type is not explicitly set, Connexion will infer it (see :ref:`response:Headers`), +and validate the body using the corresponding validator. + +Response headers validation +``````````````````````````` + +Connexion will check for any required response headers that are missing and will validate the +``Content-Type`` header against the responses defined in your specification. + +.. note:: + If the content type is not explicitly set, Connexion will infer it + (see :ref:`response:Headers`), and validate the inferred content type, which can still fail. + +Custom validators +----------------- + +Connexion provides a ``validator_map`` argument which you can use to pass in custom validators. +The default validators are defined in ``connexion.validators.VALIDATOR_MAP``: + +.. code-block:: python + :caption: **connexion.validators** + + VALIDATOR_MAP = { + "parameter": ParameterValidator, + "body": MediaTypeDict( + { + "*/*json": JSONRequestBodyValidator, + "application/x-www-form-urlencoded": FormDataValidator, + "multipart/form-data": MultiPartFormDataValidator, + } + ), + "response": MediaTypeDict( + { + "*/*json": JSONResponseBodyValidator, + "text/plain": TextResponseBodyValidator, + } + ), + } + +Note that the ``"body"`` and ``"response"`` values are instances of the special ``MediaTypeDict`` +datastructure, which can handle Media Type ranges: + +.. autoclass:: connexion.datastructures.MediaTypeDict + +You can create your own custom Validator by subclassing the +``connexion.validators.AbstractRequestBodyValidator`` or +``connexion.validators.AbstractResponseBodyValidator`` class and override the defaults by passing +in a custom ``validator_map`` to your application or API: + +.. code-block:: python + :caption: **app.py** + + from connexion.datastructures import MediaTypeDict + from connexion.validators import AbstractResponseBodyValidator, TextResponseBodyValidator + + + class MyCustomXMLResponseValidator(AbstractResponseBodyValidator): + + def _parse(self, stream: t.Generator[bytes, None, None]) -> t.Any: + ... + + def _validate(self, body: dict): + ... + + + validator_map = { + "response": MediaTypeDict( + { + "*/*json": JSONResponseBodyValidator, + "*/*xml": MyCustomXMLResponseValidator, + "text/plain": TextResponseBodyValidator, + } + ), + } + +.. tab-set:: + + .. tab-item:: AsyncApp + :sync: AsyncApp + + .. code-block:: python + :caption: **app.py** + + from connexion import AsyncApp + + app = AsyncApp(__name__, validator_map=validator_map) + app.add_api("openapi.yaml", validator_map=validator_map) + + + .. tab-item:: FlaskApp + :sync: FlaskApp + + .. code-block:: python + :caption: **app.py** + + from connexion import FlaskApp + + app = FlaskApp(__name__, validator_map=validator_map) + app.add_api("openapi.yaml", validator_map=validator_map) + + .. 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, validator_map=validator_map) + app.add_api("openapi.yaml", validator_map=validator_map) + + +Note that this will override the ``"response"`` section of the default ``VALIDATOR_MAP``, and +the ``"response"`` section only. This means that you need to include all ``ResponseValidators`` +that you want to be active, or they will be removed. + +If you want to deactivate request validation, you can pass in an empty dictionary: + +.. code-block:: python + + validator_map = { + "body": {} + } + +Which you then pass into your application or API as mentioned above. + +Inserting requestBody defaults +`````````````````````````````` + +You can let Connexion automatically insert default values as defined in your specification into +an incoming ``requestBody`` by configuring the ``DefaultsJSONRequestBodyValidator``: + +.. code-block:: python + :caption: **app.py** + + from connexion.datastructures import MediaTypeDict + from connexion.validators import ( + DefaultsJSONRequestBodyValidator, + FormDataValidator, + MultiPartFormDataValidator, + ) + + validator_map = { + "body": MediaTypeDict( + { + "*/*json": DefaultsJSONRequestBodyValidator, + "application/x-www-form-urlencoded": FormDataValidator, + "multipart/form-data": MultiPartFormDataValidator, + } + ), + } + +Which you then pass into your application or API as mentioned above. + +See our `enforce defaults`_ example for a full example. + +.. _enforce defaults: https://github.com/spec-first/connexion/tree/main/examples/enforcedefaults +.. _jsonschema: https://github.com/python-jsonschema/jsonschema \ No newline at end of file