From abc1da750e6f40113a36c7d87ed7f658602c5871 Mon Sep 17 00:00:00 2001 From: Robbe Sneyders Date: Thu, 12 Oct 2023 01:37:45 +0200 Subject: [PATCH] Update routing documentation (#1738) Works towards #1531 Some parts of the old outing docs will need to be included on the `parameters` and `swagger-ui` pages which we still need to add. --- connexion/apps/abstract.py | 4 +- connexion/lifecycle.py | 28 +- connexion/resolver.py | 73 +-- docs/_static/css/default.css | 4 + docs/conf.py | 17 +- docs/index.rst | 2 +- docs/routing.rst | 717 ++++++++++++++++-------------- tests/test_resolver.py | 2 +- tests/test_resolver3.py | 2 +- tests/test_resolver_methodview.py | 19 +- tox.ini | 2 +- 11 files changed, 506 insertions(+), 364 deletions(-) create mode 100644 docs/_static/css/default.css diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index b544064..296d32a 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -214,7 +214,7 @@ class AbstractApp: :param endpoint: the name of the endpoint for the registered URL rule, which is used for reverse lookup. Flask defaults to the name of the view function. :param view_func: the function to call when serving a request to the provided endpoint. - :param options: the options to be forwarded to the underlying `werkzeug.routing.Rule` + :param options: the options to be forwarded to the underlying ``werkzeug.routing.Rule`` object. A change to Werkzeug is handling of method options. methods is a list of methods this rule should be limited to (`GET`, `POST` etc.). By default a rule just listens for `GET` (and implicitly `HEAD`). @@ -231,7 +231,7 @@ class AbstractApp: return 'Hello World' :param rule: the URL rule as string - :param options: the options to be forwarded to the underlying `werkzeug.routing.Rule` + :param options: the options to be forwarded to the underlying ``werkzeug.routing.Rule`` object. A change to Werkzeug is handling of method options. methods is a list of methods this rule should be limited to (`GET`, `POST` etc.). By default a rule just listens for `GET` (and implicitly `HEAD`). diff --git a/connexion/lifecycle.py b/connexion/lifecycle.py index d7b63c4..ca672b1 100644 --- a/connexion/lifecycle.py +++ b/connexion/lifecycle.py @@ -22,27 +22,41 @@ class _RequestInterface: @property def content_type(self) -> str: + """The content type included in the request headers.""" raise NotImplementedError @property def mimetype(self) -> str: + """The content type included in the request headers stripped from any optional character + set encoding""" raise NotImplementedError @property def path_params(self) -> t.Dict[str, t.Any]: + """Path parameters exposed as a dictionary""" raise NotImplementedError @property def query_params(self) -> t.Dict[str, t.Any]: + """Query parameters exposed as a dictionary""" raise NotImplementedError def form(self) -> t.Union[t.Dict[str, t.Any], t.Awaitable[t.Dict[str, t.Any]]]: + """Form data, including files.""" raise NotImplementedError def files(self) -> t.Dict[str, t.Any]: + """Files included in the request.""" + raise NotImplementedError + + def json(self) -> dict: + """Json data included in the request.""" raise NotImplementedError def get_body(self) -> t.Any: + """Get body based on the content type. This returns json data for json content types, + form data for form content types, and bytes for all others. If the bytes data is emtpy, + :code:`None` is returned instead.""" raise NotImplementedError @@ -98,8 +112,10 @@ class WSGIRequest(_RequestInterface): def files(self): return self._werkzeug_request.files.to_dict(flat=False) + def json(self): + return self.get_json(silent=True) + def get_body(self): - """Get body based on content type""" if self._body is None: if is_json_mimetype(self.content_type): self._body = self.get_json(silent=True) @@ -115,7 +131,15 @@ class WSGIRequest(_RequestInterface): class ASGIRequest(_RequestInterface): - """Wraps starlette Request so it can easily be extended.""" + """ + Implementation of the Connexion :code:`_RequestInterface` representing an ASGI request. + + .. attribute:: _starlette_request + + This class wraps a Starlette `Request `_, + and provides access to its attributes by proxy. + + """ def __init__(self, *args, uri_parser=None, **kwargs): self._starlette_request = StarletteRequest(*args, **kwargs) diff --git a/connexion/resolver.py b/connexion/resolver.py index 01dad85..524d686 100644 --- a/connexion/resolver.py +++ b/connexion/resolver.py @@ -114,10 +114,12 @@ class RestyResolver(Resolver): Resolves endpoint functions using REST semantics (unless overridden by specifying operationId) """ - def __init__(self, default_module_name, collection_endpoint_name="search"): + def __init__( + self, default_module_name: str, *, collection_endpoint_name: str = "search" + ): """ :param default_module_name: Default module name for operations - :type default_module_name: str + :param collection_endpoint_name: Name of function to resolve collection endpoints to """ super().__init__() self.default_module_name = default_module_name @@ -185,18 +187,23 @@ class RestyResolver(Resolver): class MethodResolverBase(RestyResolver): """ - Resolves endpoint functions based on Flask's MethodView semantics, e.g. :: + Resolves endpoint functions based on Flask's MethodView semantics, e.g. - paths: - /foo_bar: - get: - # Implied function call: api.FooBarView().get + .. code-block:: yaml + + paths: + /foo_bar: + get: + # Implied function call: api.FooBarView().get + + .. code-block:: python + + class FooBarView(MethodView): + def get(self): + return ... + def post(self): + return ... - class FooBarView(MethodView): - def get(self): - return ... - def post(self): - return ... """ _class_arguments_type = t.Dict[ @@ -205,24 +212,22 @@ class MethodResolverBase(RestyResolver): def __init__(self, *args, class_arguments: _class_arguments_type = None, **kwargs): """ - :param class_arguments: Arguments to instantiate the View Class in the format # noqa - { - "ViewName": { - "args": (positional arguments,) - "kwargs": { - "keyword": "argument" - } - } - } + :param args: Arguments passed to :class:`~RestyResolver` + :param class_arguments: Arguments to instantiate the View Class in the format below + :param kwargs: Keywords arguments passed to :class:`~RestyResolver` + + .. code-block:: python + + { + "ViewName": { + "args": (positional arguments,) + "kwargs": { + "keyword": "argument" + } + } + } """ self.class_arguments = class_arguments or {} - if "collection_endpoint_name" in kwargs: - del kwargs["collection_endpoint_name"] - # Dispatch of request is done by Flask - logger.warning( - "collection_endpoint_name is ignored by the MethodViewResolver. " - "Requests to a collection endpoint will be routed to .get()" - ) super(MethodResolverBase, self).__init__(*args, **kwargs) self.initialized_views: list = [] @@ -280,7 +285,7 @@ class MethodResolverBase(RestyResolver): class MethodResolver(MethodResolverBase): """ - A generic method resolver that instantiates a class a extracts the method + A generic method resolver that instantiates a class and extracts the method from it, based on the operation id. """ @@ -310,6 +315,16 @@ class MethodViewResolver(MethodResolverBase): It resolves the method by calling as_view on the class. """ + def __init__(self, *args, **kwargs): + if "collection_endpoint_name" in kwargs: + del kwargs["collection_endpoint_name"] + # Dispatch of request is done by Flask + logger.warning( + "collection_endpoint_name is ignored by the MethodViewResolver. " + "Requests to a collection endpoint will be routed to .get()" + ) + super().__init__(*args, **kwargs) + def resolve_method_from_class(self, view_name, meth_name, view_cls): view = None for v in self.initialized_views: diff --git a/docs/_static/css/default.css b/docs/_static/css/default.css new file mode 100644 index 0000000..cacda89 --- /dev/null +++ b/docs/_static/css/default.css @@ -0,0 +1,4 @@ +.rst-content .code-block-caption { + text-align: left; + padding: 0px, 0px, 5px, 5px; +} diff --git a/docs/conf.py b/docs/conf.py index 2455271..bddf73d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -111,6 +111,17 @@ try: html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + + html_theme_options = { + 'navigation_depth': 2 + } + + html_context = { + 'display_github': True, + 'github_user': 'spec-first', + 'github_repo': 'connexion', + 'github_version': 'main/docs/', + } except: pass @@ -141,7 +152,11 @@ except: # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] +html_static_path = ['_static'] + +html_css_files = [ + 'css/default.css', +] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied diff --git a/docs/index.rst b/docs/index.rst index 5dbacf1..3ba31eb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,7 @@ Welcome to Connexion's documentation! ===================================== -Connexion is a Python web framework that makes spec-first and api-first development easy. You +Connexion is a modern Python web framework that makes spec-first and api-first development easy. You describe your API in an `OpenAPI`_ (or swagger) specification with as much detail as you want and Connexion will guarantee that it works as you specified. diff --git a/docs/routing.rst b/docs/routing.rst index 0568e2d..018b182 100644 --- a/docs/routing.rst +++ b/docs/routing.rst @@ -1,40 +1,35 @@ Routing ======= -Endpoint Routing to Your Python Views -------------------------------------- +Connexion leverages your OpenAPI contract to route requests to your python functions. This can +be done in two ways: -Connexion uses the ``operationId`` from each `Operation Object`_ to -identify which Python function should handle each URL. +* `Explicitly <#explicit-routing>`_ +* `Automatically <#automatic-routing>`_ -**Explicit Routing**: +Explicit routing +---------------- -.. code-block:: yaml +Connexion uses the :code:`operation_id` to link each `operation`_ in your API contract to +the python function that should handle it. + +.. code-block:: python + :caption: **openapi.yaml** paths: /hello_world: post: operationId: myapp.api.hello_world -If you provided this path in your specification POST requests to -``http://MYHOST/hello_world``, it would be handled by the function -``hello_world`` in ``myapp.api`` module. +Based on the :code:`operationId` above, any :code:`POST` request to +:code:`http://{HOST}/hello_world`, will be handled by the :code:`hello_world` function in the +:code:`myapp.api` module. -Optionally, you can include ``x-swagger-router-controller`` in your operation -definition, making ``operationId`` relative: +Optionally, you can include :code:`x-openapi-router-controller` or +:code:`x-swagger-router-controller` in your :code:`operationId` to make your `operationId` relative: -.. code-block:: yaml - - paths: - /hello_world: - post: - x-swagger-router-controller: myapp.api - operationId: hello_world - -NOTE: If you are using an OpenAPI spec, you should use ``x-openapi-router-controller`` -in your operation definition, making ``operationId`` relative: - -.. code-block:: yaml +.. code-block:: python + :caption: **openapi.yaml** paths: /hello_world: @@ -42,22 +37,298 @@ in your operation definition, making ``operationId`` relative: x-openapi-router-controller: myapp.api operationId: hello_world -If all your operations are relative, you can use the ``RelativeResolver`` class -instead of repeating the same ``x-swagger-router-controller`` or -``x-openapi-router-controller`` in every operation: + +If all your operations are relative, you can use the :code:`RelativeResolver` class when +registering your API instead of repeating the same :code:`x-openapi-router-controller` in every +operation: .. code-block:: python + :caption: **app.py** + import connexion from connexion.resolver import RelativeResolver - + + app = connexion.AsyncApp(__name__) + app.add_api('openapi.yaml', resolver=RelativeResolver('myapp.api')) + + +.. dropdown:: View a detailed reference of the :code:`RelativeResolver` class + :icon: eye + + .. autoclass:: connexion.resolver.RelativeResolver + +Note that :code:`HEAD` requests will be handled by the :code:`operationId` specified under the +:code:`GET` operation in the specification. :code:`Connexion.request.method` can be used to +determine which request was made. + +.. dropdown:: View a detailed reference of the :code:`connexion.request` class + :icon: eye + + .. autoclass:: connexion.lifecycle.ASGIRequest + :members: + :undoc-members: + :inherited-members: + +Automatic routing +----------------- + +Connexion can also automate the routing for you. You can choose from different :code:`Resolvers` +implementing different resolution strategies. + +RestyResolver +````````````` + +The :code:`RestyResolver` will infer an :code:`operationId` based on the path and HTTP method of +each operation in your specification: + +.. code-block:: python + :caption: **app.py** + + import connexion + from connexion.resolver import RestyResolver + app = connexion.FlaskApp(__name__) - app.add_api('swagger.yaml', resolver=RelativeResolver('api')) + app.add_api('openapi.yaml', resolver=RestyResolver('api')) + +.. code-block:: yaml + :caption: **openapi.yaml** + + paths: + /: + get: + # Implied operationId: api.get + /foo: + get: + # Implied operationId: api.foo.search + post: + # Implied operationId: api.foo.post + /foo/{id}: + get: + # Implied operationId: api.foo.get + put: + # Implied operationId: api.foo.put + copy: + # Implied operationId: api.foo.copy + delete: + # Implied operationId: api.foo.delete + /foo/{id}/bar: + get: + # Implied operationId: api.foo.bar.search + /foo/{id}/bar/{name}: + get: + # Implied operationId: api.foo.bar.get + +``RestyResolver`` will give precedence to any ``operationId`` encountered in the specification and +respects ``x-openapi-router-controller`` and ``x-swagger-router-controller``. + +.. dropdown:: View a detailed reference of the :code:`RestyResolver` class + :icon: eye + + .. autoclass:: connexion.resolver.RestyResolver + +MethodResolver +`````````````` + +The ``MethodResolver`` works like a ``RestyResolver``, but routes to class methods instead of +functions. + +.. code-block:: python + :caption: **app.py** + + import connexion + from connexion.resolver import MethodResolver + + app = connexion.FlaskApp(__name__) + app.add_api('openapi.yaml', resolver=MethodResolver('api')) -Keep in mind that Connexion follows how `HTTP methods work in Flask`_ -and therefore HEAD requests will be handled by the ``operationId`` specified -under GET in the specification. If both methods are supported, -``connexion.request.method`` can be used to determine which request was made. +.. code-block:: yaml + :caption: **openapi.yaml** + + paths: + /foo: + get: + # Implied operationId: api.FooView.search + post: + # Implied operationId: api.FooView.post + '/foo/{id}': + get: + # Implied operationId: api.FooView.get + put: + # Implied operationId: api.FooView.put + copy: + # Implied operationId: api.FooView.copy + delete: + # Implied operationId: api.FooView.delete + + +The structure expects a Class to exists inside the ``api`` module with the name +``<>View``. + +.. code-block:: python + :caption: **api.py** + + class PetsView: + + def post(self, body: dict): + ... + + def put(self, petId, body: dict): + ... + + def delete(self, petId): + ... + + def get(self, petId=None): + ... + + def search(limit=100): + ... + +It is possible to use decorators for the Method view by listing them in the +decorator attribute of the class: + +.. code-block:: python + :caption: **api.py** + + def example_decorator(f): + + def decorator(*args, **kwargs): + return f(*args, **kwargs) + + return decorator + + class PetsView: + """Create Pets service""" + + decorators = [example_decorator] + + ... + + +Additionally, you may inject dependencies into the class by declaring parameters +for this class in the ``__init__`` method and providing the arguments in the +``MethodViewResolver()`` call. The arguments are passed down to the class when +``as_view`` is called. + +A class might look like this: + +.. code-block:: python + :caption: **api.py** + + class PetsView: + def __init__(self, pets): + self.pets = pets + + +And the arguments are provided like this: + +.. code-block:: python + :caption: **app.py** + + MethodViewResolver("api", class_arguments={"PetsView": {"kwargs": {"pets": zoo}}}) + +``MethodResolver`` will give precedence to any ``operationId`` encountered in the specification and +respects ``x-openapi-router-controller`` and ``x-swagger-router-controller``. + +.. dropdown:: View a detailed reference of the :code:`MethodResolver` class + :icon: eye + + .. autoclass:: connexion.resolver.MethodResolver + +MethodViewResolver +`````````````````` + +The ``MethodResolver`` works like a ``MethodViewResolver``, but routes to class methods of a +Flask ``MethodView`` subclass. + +.. note:: + If you migrate from connexion v2 you may want to use the ``MethodResolver`` in order to maintain + the old behavior. The behavior described here is the new behavior, introduced in connexion v3. + Previously, in v2, the ``MethodViewResolver`` worked like the ``MethodResolver`` in v3. + +Another difference is that the ``MethodResolver`` will look for ``search`` and ``get`` +methods for `collection` and `single item` operations respectively, while ``MethodViewResolver`` +handles both `collection` and `single item` operations via the same ``get`` method. + +.. code-block:: python + :caption: **app.py** + + import connexion + from connexion.resolver import MethodResolver + + app = connexion.FlaskApp(__name__) + app.add_api('openapi.yaml', resolver=MethodViewResolver('api')) + + +.. code-block:: yaml + :caption: **openapi.yaml** + + paths: + /foo: + get: + # Implied operationId: api.FooView.get + post: + # Implied operationId: api.FooView.post + '/foo/{id}': + get: + # Implied operationId: api.FooView.get + put: + # Implied operationId: api.FooView.put + copy: + # Implied operationId: api.FooView.copy + delete: + # Implied operationId: api.FooView.delete + + +The structure expects a Class to exists inside the ``api`` module with the name +``<>View``. + +.. code-block:: python + :caption: **api.py** + + from flask.views import MethodView + + + class PetsView(MethodView): + + def post(self, body: dict): + ... + + def put(self, petId, body: dict): + ... + + def delete(self, petId): + ... + + def get(self, petId=None, limit=100): + ... + +.. dropdown:: View a detailed reference of the :code:`MethodViewResolver` class + :icon: eye + + .. autoclass:: connexion.resolver.MethodViewResolver + +Custom resolver +``````````````` + +You can import and extend ``connexion.resolver.Resolver`` to implement your own +``operationId`` and function resolution algorithm. + +.. dropdown:: View a detailed reference of the :code:`RestyResolver` class + :icon: eye + + .. autoclass:: connexion.resolver.Resolver + :members: + +.. note:: + + If you implement a custom ``Resolver``, and think it would be valuable for other users, we + would appreciate it as a contribution. + + +Resolver error +-------------- By default, Connexion strictly enforces the presence of a handler function for any path defined in your specification. Because of this, adding @@ -68,293 +339,125 @@ added to your specification, e.g. in an API design first workflow, set the paths that are not yet implemented: .. code-block:: python + :caption: **app.py** app = connexion.FlaskApp(__name__) - app.add_api('swagger.yaml', resolver_error=501) + app.add_api('openapi.yaml', resolver_error=501) -.. code-block:: yaml -Automatic Routing ------------------ +Path parameters +--------------- -To customize this behavior, Connexion can use alternative -``Resolvers``—for example, ``RestyResolver``. The ``RestyResolver`` -will compose an ``operationId`` based on the path and HTTP method of -the endpoints in your specification: +`Path parameters`_ are variable parts of a URL path denoted with curly braces ``{ }`` in the +specification. -.. code-block:: python +.. tab-set:: - from connexion.resolver import RestyResolver + .. tab-item:: OpenAPI 3 - app = connexion.FlaskApp(__name__) - app.add_api('swagger.yaml', resolver=RestyResolver('api')) + .. code-block:: yaml -.. code-block:: yaml + paths: + /users/{id}: + parameters: + - in: path + name: id # Note the name is the same as in the path + required: true + schema: + type: integer + description: The user ID - paths: - /: - get: - # Implied operationId: api.get - /foo: - get: - # Implied operationId: api.foo.search - post: - # Implied operationId: api.foo.post + .. tab-item:: Swagger 2 - '/foo/{id}': - get: - # Implied operationId: api.foo.get - put: - # Implied operationId: api.foo.put - copy: - # Implied operationId: api.foo.copy - delete: - # Implied operationId: api.foo.delete - '/foo/{id}/bar': - get: - # Implied operationId: api.foo.bar.search - '/foo/{id}/bar/{name}': - get: - # Implied operationId: api.foo.bar.get - # Handler signature: `def get(id, name): ...` + .. code-block:: yaml -``RestyResolver`` will give precedence to any ``operationId`` -encountered in the specification. It will also respect -``x-swagger-router-controller`` and ``x-openapi-router-controller``. -You may import and extend ``connexion.resolver.Resolver`` to implement your own -``operationId`` (and function) resolution algorithm. -Note that when using multiple parameters in the path, they will be -collected and all passed to the endpoint handlers. + paths: + /users/{id}: + parameters: + - in: path + name: id + required: true + type: integer + description: The user ID. -Automatic Routing with MethodViewResolver -------------------------------------------- +By default this will capture characters up to the end of the path or the next `/`. -.. note:: - If you migrate from connexion v2 you may want to use the `MethodResolver` - in order to maintain the old behavior. The behavior described here is the new behavior, - introduced in connexion v3. The difference is that the `MethodResolver` works with any - class, while the `MethodViewResolver` is specifically designed to work with flask's - `MethodView`. Previously, in v2, the `MethodViewResolver` worked like the `MethodResolver` - in v3. One consequence is that the `MethodResolver` will look for `search` and `get` - methods for list and single operations respectively, while `MethodViewResolver` uses - the `dispatch_request` method of the given class and therefore handles both, list and - single operations via the same `get` method. +You can use convertors to modify what is captured. The available convertors are: -``MethodViewResolver`` is an customised Resolver based on ``RestyResolver`` -to take advantage of MethodView structure of building Flask APIs. -The ``MethodViewResolver`` will compose an ``operationId`` based on the path and HTTP method of -the endpoints in your specification. The path will be based on the path you provide in the app.add_api and the path provided in the URL endpoint (specified in the swagger or openapi3). +* `str` returns a string, and is the default. +* `int` returns a Python integer. +* `float` returns a Python float. +* `path` returns the rest of the path, including any additional `/` characters. -.. code-block:: python +Convertors are used by defining them as the ``format`` in the parameter specification - from connexion.resolver import MethodViewResolver - - app = connexion.FlaskApp(__name__) - app.add_api('swagger.yaml', resolver=MethodViewResolver('api')) - -And associated YAML - -.. code-block:: yaml - - paths: - /foo: - get: - # Implied operationId: api.FooView.search - post: - # Implied operationId: api.FooView.post - - '/foo/{id}': - get: - # Implied operationId: api.FooView.get - put: - # Implied operationId: api.FooView.put - copy: - # Implied operationId: api.FooView.copy - delete: - # Implied operationId: api.FooView.delete - - -The structure expects a Class to exists inside the directory ``api`` that conforms to the naming ``<>View``. -In the above yaml the necessary MethodView implementation is as follows: - -.. code-block:: python - - import datetime - - from connexion import NoContent - from flask.views import MethodView - - - class PetsView(MethodView): - """Create Pets service""" - - pets = {} - - def post(self, body: dict): - name = body.get("name") - tag = body.get("tag") - count = len(self.pets) - pet = {} - pet["id"] = count + 1 - pet["tag"] = tag - pet["name"] = name - pet["last_updated"] = datetime.datetime.now() - self.pets[pet["id"]] = pet - return pet, 201 - - def put(self, petId, body: dict): - name = body["name"] - tag = body.get("tag") - pet = self.pets.get(petId, {"id": petId}) - pet["name"] = name - pet["tag"] = tag - pet["last_updated"] = datetime.datetime.now() - self.pets[petId] = pet - return self.pets[petId], 201 - - def delete(self, petId): - id_ = int(petId) - if self.pets.get(id_) is None: - return NoContent, 404 - del self.pets[id_] - return NoContent, 204 - - def get(self, petId=None, limit=100): - if petId is None: - # NOTE: we need to wrap it with list for Python 3 as - # dict_values is not JSON serializable - return list(self.pets.values())[0:limit] - if self.pets.get(petId) is None: - return NoContent, 404 - return self.pets[petId] - - -and a __init__.py file to make the Class visible in the api directory. - -.. code-block:: python - - from .petsview import PetsView - - -The `as_view` method of the class is called to create the view function. -Its `dispatch_request` method is used to route requests based on the HTTP method. -Therefore it is required to use the same `get` method for both, collection and -single resources. I.E. `/pets` and `/pets/{id}`. - -It is possible to use decorators for the Method view by listing them in the -decorator attribute of the class: - -.. code-block:: python - - def example_decorator(f): - - def decorator(*args, **kwargs): - return f(*args, **kwargs) - - return decorator - - class PetsView(MethodView): - """Create Pets service""" - - decorators = [example_decorator] - - ... - - -Additionally, you may inject dependencies into the class by declaring parameters -for this class in the `__init__` method and providing the arguments in the -`MethodViewResolver` call. The arguments are passed down to the class when -`as_view` is called. - -A class might look like this: - -.. code-block:: python - - class PetsView(MethodView): - def __init__(self, pets): - self.pets = pets - - -And the arguments are provided like this: - -.. code-block:: python - - MethodViewResolver("api", class_arguments={"PetsView": {"kwargs": {"pets": zoo}}}) - - -``MethodViewResolver`` will give precedence to any ``operationId`` -encountered in the specification. It will also respect -``x-swagger-router-controller`` and ``x-openapi-router-controller``. -You may import and extend ``connexion.resolver.MethodViewResolver`` to implement -your own ``operationId`` (and function) resolution algorithm. - -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 -under-score is encountered. As an example: - -.. code-block:: python - - >>> re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z_]', '', '$top')) - 'top' - -Without this sanitation it would e.g. be impossible to implement an -`OData -`_ API. - -You can also convert *CamelCase* parameters to *snake_case* automatically using `pythonic_params` option: - -.. code-block:: python - - app = connexion.FlaskApp(__name__) - app.add_api('api.yaml', ..., pythonic_params=True) - -With this option enabled, Connexion firstly converts *CamelCase* names -to *snake_case*. Secondly it looks to see if the name matches a known built-in -and if it does it appends an underscore to the name. - -Parameter Variable Converters ------------------------------ - -Connexion supports Flask's ``int``, ``float``, and ``path`` route parameter -`variable converters -`_. Specify a route parameter's type as ``integer`` or ``number`` or its type as -``string`` and its format as ``path`` to use these converters. For example: +``string`` and its format as ``path`` to use these converters. -.. code-block:: yaml +Path parameters are passed as arguments to your python function, see :doc:`parameters`. - paths: - /greeting/{name}: - # ... - parameters: - - name: name - in: path - required: true - type: string - format: path +Individual paths +---------------- + +You can also add individual paths to your application which are not described in your API +contract. This can be useful for eg. ``/healthz`` or similar endpoints. + +.. code-block:: python + :caption: **api.py** + + @app.route("/healthz") + def healthz(): + return 200 + + # Or as alternative to the decorator + app.add_url_rule("/healthz", "healthz", healthz) + +.. tab-set:: + + .. tab-item:: AsyncApp + :sync: AsyncApp + + .. dropdown:: View a detailed reference of the ``route`` and ``add_url_rule`` methods + :icon: eye + + .. automethod:: connexion.AsyncApp.route + :noindex: + .. automethod:: connexion.AsyncApp.add_url_rule + :noindex: + + .. tab-item:: FlaskApp + :sync: FlaskApp + + .. dropdown:: View a detailed reference of the ``route`` and ``add_url_rule`` methods + :icon: eye + + .. automethod:: connexion.FlaskApp.route + :noindex: + .. automethod:: connexion.FlaskApp.add_url_rule + :noindex: + + .. tab-item:: ConnexionMiddleware + :sync: ConnexionMiddleware + + When using the ``ConnexionMiddleware`` around an ASGI or WSGI application, you can + register individual routes on the wrapped application. -will create an equivalent Flask route ``/greeting/``, allowing -requests to include forward slashes in the ``name`` url variable. API Versioning and basePath --------------------------- Setting a base path is useful for versioned APIs. An example of -a base path would be the ``1.0`` in ``http://MYHOST/1.0/hello_world``. +a base path would be the ``1.0`` in ``http://{HOST}/1.0/hello_world``. -If you are using OpenAPI 3.x.x, you set your base URL path in the +If you are using OpenAPI 3, you set your base URL path in the servers block of the specification. You can either specify a full URL, or just a relative path. .. code-block:: yaml + :caption: **openapi.yaml** servers: - - url: https://MYHOST/1.0 + - url: https://{{HOST}}/1.0 description: full url example - url: /1.0 description: relative path example @@ -362,10 +465,11 @@ URL, or just a relative path. paths: ... -If you are using OpenAPI 2.0, you can define a ``basePath`` on the top level -of your OpenAPI 2.0 specification. +If you are using Swagger 2.0, you can define a ``basePath`` on the top level +of your Swagger 2.0 specification. .. code-block:: yaml + :caption: **swagger.yaml** basePath: /1.0 @@ -376,40 +480,9 @@ If you don't want to include the base path in your specification, you can provide it when adding the API to your application: .. code-block:: python + :caption: **app.py** app.add_api('my_api.yaml', base_path='/1.0') -Swagger UI path ---------------- - -Swagger UI is available at ``/ui/`` by default. - -You can choose another path through options: - -.. code-block:: python - - options = {'swagger_url': '/'} - app = connexion.App(__name__, options=options) - -Swagger JSON ------------- -Connexion makes the OpenAPI/Swagger specification in JSON format -available from ``swagger.json`` in the base path of the API. - -You can disable the Swagger JSON at the application level: - -.. code-block:: python - - app = connexion.FlaskApp(__name__, specification_dir='swagger/', - swagger_json=False) - app.add_api('my_api.yaml') - -You can also disable it at the API level: - -.. code-block:: python - - app = connexion.FlaskApp(__name__, specification_dir='swagger/') - app.add_api('my_api.yaml', swagger_json=False) - -.. _Operation Object: https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#operation-object -.. _HTTP Methods work in Flask: http://flask.pocoo.org/docs/1.0/quickstart/#http-methods +.. _operation: https://swagger.io/docs/specification/paths-and-operations/#operations +.. _Path parameters: https://swagger.io/docs/specification/describing-parameters/#path-parameters diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 4ef1fb1..5a73cad 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -290,7 +290,7 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_co app_produces=["application/json"], app_consumes=["application/json"], definitions={}, - resolver=RestyResolver("fakeapi", "api_list"), + resolver=RestyResolver("fakeapi", collection_endpoint_name="api_list"), ) assert operation.operation_id == "fakeapi.hello.api_list" diff --git a/tests/test_resolver3.py b/tests/test_resolver3.py index 6d7647b..23f62a7 100644 --- a/tests/test_resolver3.py +++ b/tests/test_resolver3.py @@ -210,7 +210,7 @@ def test_resty_resolve_with_default_module_name_will_resolve_resource_root_as_co path_parameters=[], operation={}, components=COMPONENTS, - resolver=RestyResolver("fakeapi", "api_list"), + resolver=RestyResolver("fakeapi", collection_endpoint_name="api_list"), ) assert operation.operation_id == "fakeapi.hello.api_list" diff --git a/tests/test_resolver_methodview.py b/tests/test_resolver_methodview.py index 24e812a..3295305 100644 --- a/tests/test_resolver_methodview.py +++ b/tests/test_resolver_methodview.py @@ -151,20 +151,31 @@ def test_methodview_resolve_with_default_module_name_and_x_router_controller_wil assert operation.operation_id == "fakeapi.PetsView.search" -def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_as_configured( - method_view_resolver, -): +def test_method_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): operation = OpenAPIOperation( method="GET", path="/pets", path_parameters=[], operation={}, components=COMPONENTS, - resolver=method_view_resolver("fakeapi", "api_list"), + resolver=MethodResolver("fakeapi", collection_endpoint_name="api_list"), ) assert operation.operation_id == "fakeapi.PetsView.api_list" +def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_as_configured(): + operation = OpenAPIOperation( + method="GET", + path="/pets", + path_parameters=[], + operation={}, + components=COMPONENTS, + resolver=MethodViewResolver("fakeapi", collection_endpoint_name="api_list"), + ) + # The collection_endpoint_name is ignored + assert operation.operation_id == "fakeapi.PetsView.search" + + def test_methodview_resolve_with_default_module_name_will_resolve_resource_root_post_as_post( method_view_resolver, ): diff --git a/tox.ini b/tox.ini index 8edc74c..d752579 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ rst-roles=class,mod,obj # https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 # Longest docstring in current code base max-line-length=137 -extend-ignore=E203 +extend-ignore=E203,RST303 [tox] isolated_build = True