Add a command line tool to generate OAS in a file
This commit is contained in:
		
							
								
								
									
										19
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								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 | ||||
|   | ||||
							
								
								
									
										8
									
								
								aiohttp_pydantic/oas/__main__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								aiohttp_pydantic/oas/__main__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										45
									
								
								aiohttp_pydantic/oas/cmd.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								aiohttp_pydantic/oas/cmd.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										22
									
								
								demo/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								demo/main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
							
								
								
									
										0
									
								
								tests/test_oas/test_cmd/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_oas/test_cmd/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										27
									
								
								tests/test_oas/test_cmd/sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								tests/test_oas/test_cmd/sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										120
									
								
								tests/test_oas/test_cmd/test_cmd.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								tests/test_oas/test_cmd/test_cmd.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
		Reference in New Issue
	
	Block a user