diff --git a/README.rst b/README.rst index 6cea178..acce3b0 100644 --- a/README.rst +++ b/README.rst @@ -145,8 +145,8 @@ To declare a HTTP headers parameters, you must declare your argument as a `keywo .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ -Add route to generate Open Api Specification --------------------------------------------- +Add route to generate Open Api Specification (OAS) +-------------------------------------------------- aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application @@ -168,8 +168,8 @@ By default, the route to display the Open Api Specification is /oas but you can oas.setup(app, url_prefix='/spec-api') -If you want generate the Open Api Specification from several aiohttp sub-application. -on the same route, you must use *apps_to_expose* parameters +If you want generate the Open Api Specification from specific aiohttp sub-applications. +on the same route, you must use *apps_to_expose* parameter. .. code-block:: python3 @@ -179,9 +179,9 @@ on the same route, you must use *apps_to_expose* parameters app = web.Application() sub_app_1 = web.Application() + sub_app_2 = web.Application() - oas.setup(app, apps_to_expose=[app, sub_app_1]) - + oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) Add annotation to define response content ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -232,7 +232,6 @@ defined using a pydantic.BaseModel self.request.app["model"].remove_pet(id) return web.Response(status=204) - Demo ---- @@ -247,5 +246,11 @@ Have a look at `demo`_ for a complete example Go to http://127.0.0.1:8080/oas +You can generate the OAS in a json file using the command: + +.. code-block:: bash + + python -m aiohttp_pydantic.oas demo.main + .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo diff --git a/aiohttp_pydantic/oas/__main__.py b/aiohttp_pydantic/oas/__main__.py new file mode 100644 index 0000000..410cf7d --- /dev/null +++ b/aiohttp_pydantic/oas/__main__.py @@ -0,0 +1,8 @@ +import argparse + +from .cmd import setup + +parser = argparse.ArgumentParser(description="Generate Open API Specification") +setup(parser) +args = parser.parse_args() +args.func(args) diff --git a/aiohttp_pydantic/oas/cmd.py b/aiohttp_pydantic/oas/cmd.py new file mode 100644 index 0000000..b0ee01b --- /dev/null +++ b/aiohttp_pydantic/oas/cmd.py @@ -0,0 +1,45 @@ +import argparse +import importlib +import json + +from .view import generate_oas + + +def application_type(value): + """ + Return aiohttp application defined in the value. + """ + try: + module_name, app_name = value.split(":") + except ValueError: + module_name, app_name = value, "app" + + module = importlib.import_module(module_name) + try: + if app_name.endswith("()"): + app_name = app_name.strip("()") + factory_app = getattr(module, app_name) + return factory_app() + return getattr(module, app_name) + + except AttributeError as error: + raise argparse.ArgumentTypeError(error) from error + + +def setup(parser: argparse.ArgumentParser): + parser.add_argument( + "apps", + metavar="APP", + type=application_type, + nargs="*", + help="The name of the module containing the asyncio.web.Application." + " By default the variable named 'app' is loaded but you can define" + " an other variable name ending the name of module with : characters" + " and the name of variable. Example: my_package.my_module:my_app", + ) + + parser.set_defaults(func=show_oas) + + +def show_oas(args: argparse.Namespace): + print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4)) diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 7daa830..56d2467 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,7 +1,8 @@ import typing -from typing import Type +from typing import List, Type from aiohttp.web import Response, json_response +from aiohttp.web_app import Application from pydantic import BaseModel from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem @@ -106,11 +107,10 @@ def _add_http_method_to_oas( _OASResponseBuilder(oas_operation).build(return_type) -async def get_oas(request): +def generate_oas(apps: List[Application]) -> dict: """ - Generate Open Api Specification from PydanticView in application. + Generate and return Open Api Specification from PydanticView in application. """ - apps = request.app["apps to expose"] oas = OpenApiSpec3() for app in apps: for resources in app.router.resources(): @@ -125,7 +125,15 @@ async def get_oas(request): else: _add_http_method_to_oas(path, resource_route.method, view) - return json_response(oas.spec) + return oas.spec + + +async def get_oas(request): + """ + View to generate the Open Api Specification from PydanticView in application. + """ + apps = request.app["apps to expose"] + return json_response(generate_oas(apps)) async def oas_ui(request): diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index a899542..e04f71e 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -9,8 +9,14 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed from aiohttp.web_response import StreamResponse from pydantic import ValidationError -from .injectors import (AbstractInjector, BodyGetter, HeadersGetter, - MatchInfoGetter, QueryGetter, _parse_func_signature) +from .injectors import ( + AbstractInjector, + BodyGetter, + HeadersGetter, + MatchInfoGetter, + QueryGetter, + _parse_func_signature, +) class PydanticView(AbstractView): diff --git a/demo/__main__.py b/demo/__main__.py index 5ca79a0..aea4cda 100644 --- a/demo/__main__.py +++ b/demo/__main__.py @@ -1,25 +1,5 @@ from aiohttp import web -from aiohttp.web import middleware -from aiohttp_pydantic import oas - -from .model import Model -from .view import PetCollectionView, PetItemView - - -@middleware -async def pet_not_found_to_404(request, handler): - try: - return await handler(request) - except Model.NotFound as key: - return web.json_response({"error": f"Pet {key} does not exist"}, status=404) - - -app = web.Application(middlewares=[pet_not_found_to_404]) -oas.setup(app) - -app["model"] = Model() -app.router.add_view("/pets", PetCollectionView) -app.router.add_view("/pets/{id}", PetItemView) +from .main import app web.run_app(app) diff --git a/demo/main.py b/demo/main.py new file mode 100644 index 0000000..ee4902a --- /dev/null +++ b/demo/main.py @@ -0,0 +1,22 @@ +from aiohttp.web import Application, json_response, middleware + +from aiohttp_pydantic import oas + +from .model import Model +from .view import PetCollectionView, PetItemView + + +@middleware +async def pet_not_found_to_404(request, handler): + try: + return await handler(request) + except Model.NotFound as key: + return json_response({"error": f"Pet {key} does not exist"}, status=404) + + +app = Application(middlewares=[pet_not_found_to_404]) +oas.setup(app) + +app["model"] = Model() +app.router.add_view("/pets", PetCollectionView) +app.router.add_view("/pets/{id}", PetItemView) diff --git a/tests/test_oas/test_cmd/__init__.py b/tests/test_oas/test_cmd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oas/test_cmd/sample.py b/tests/test_oas/test_cmd/sample.py new file mode 100644 index 0000000..2c24c7e --- /dev/null +++ b/tests/test_oas/test_cmd/sample.py @@ -0,0 +1,27 @@ +from aiohttp import web + +from aiohttp_pydantic import PydanticView + + +class View1(PydanticView): + async def get(self, a: int, /): + return web.json_response() + + +class View2(PydanticView): + async def post(self, b: int, /): + return web.json_response() + + +sub_app = web.Application() +sub_app.router.add_view("/route-2/{b}", View2) + +app = web.Application() +app.router.add_view("/route-1/{a}", View1) +app.add_subapp("/sub-app", sub_app) + + +def make_app(): + app = web.Application() + app.router.add_view("/route-3/{a}", View1) + return app diff --git a/tests/test_oas/test_cmd/test_cmd.py b/tests/test_oas/test_cmd/test_cmd.py new file mode 100644 index 0000000..754413a --- /dev/null +++ b/tests/test_oas/test_cmd/test_cmd.py @@ -0,0 +1,120 @@ +import argparse +from textwrap import dedent + +import pytest + +from aiohttp_pydantic.oas import cmd + + +@pytest.fixture +def cmd_line(): + parser = argparse.ArgumentParser() + cmd.setup(parser) + return parser + + +def test_show_oad_of_app(cmd_line, capfd): + args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) + args.func(args) + captured = capfd.readouterr() + expected = dedent( + """ + { + "openapi": "3.0.0", + "paths": { + "/route-1/{a}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "a", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + }, + "/sub-app/route-2/{b}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "b", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + } + } + } + """ + ) + + assert captured.out.strip() == expected.strip() + + +def test_show_oad_of_sub_app(cmd_line, capfd): + args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) + args.func(args) + captured = capfd.readouterr() + expected = dedent( + """ + { + "openapi": "3.0.0", + "paths": { + "/sub-app/route-2/{b}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "b", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + } + } + } + """ + ) + + assert captured.out.strip() == expected.strip() + + +def test_show_oad_of_a_callable(cmd_line, capfd): + args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"]) + args.func(args) + captured = capfd.readouterr() + expected = dedent( + """ + { + "openapi": "3.0.0", + "paths": { + "/route-3/{a}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "a", + "required": true, + "schema": { + "type": "integer" + } + } + ] + } + } + } + } + """ + ) + + assert captured.out.strip() == expected.strip()