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/ | .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ | ||||||
| .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ | .. _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 | 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 | 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') |     oas.setup(app, url_prefix='/spec-api') | ||||||
|  |  | ||||||
| If you want generate the Open Api Specification from several aiohttp sub-application. | If you want generate the Open Api Specification from specific aiohttp sub-applications. | ||||||
| on the same route, you must use *apps_to_expose* parameters | on the same route, you must use *apps_to_expose* parameter. | ||||||
|  |  | ||||||
|  |  | ||||||
| .. code-block:: python3 | .. code-block:: python3 | ||||||
| @@ -179,9 +179,9 @@ on the same route, you must use *apps_to_expose* parameters | |||||||
|  |  | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     sub_app_1 = 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 | Add annotation to define response content | ||||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||||
| @@ -232,7 +232,6 @@ defined using a pydantic.BaseModel | |||||||
|             self.request.app["model"].remove_pet(id) |             self.request.app["model"].remove_pet(id) | ||||||
|             return web.Response(status=204) |             return web.Response(status=204) | ||||||
|  |  | ||||||
|  |  | ||||||
| Demo | Demo | ||||||
| ---- | ---- | ||||||
|  |  | ||||||
| @@ -247,5 +246,11 @@ Have a look at `demo`_ for a complete example | |||||||
|  |  | ||||||
| Go to http://127.0.0.1:8080/oas | 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 | .. _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 | import typing | ||||||
| from typing import Type | from typing import List, Type | ||||||
|  |  | ||||||
| from aiohttp.web import Response, json_response | from aiohttp.web import Response, json_response | ||||||
|  | from aiohttp.web_app import Application | ||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | 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) |         _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() |     oas = OpenApiSpec3() | ||||||
|     for app in apps: |     for app in apps: | ||||||
|         for resources in app.router.resources(): |         for resources in app.router.resources(): | ||||||
| @@ -125,7 +125,15 @@ async def get_oas(request): | |||||||
|                     else: |                     else: | ||||||
|                         _add_http_method_to_oas(path, resource_route.method, view) |                         _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): | async def oas_ui(request): | ||||||
|   | |||||||
| @@ -9,8 +9,14 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed | |||||||
| from aiohttp.web_response import StreamResponse | from aiohttp.web_response import StreamResponse | ||||||
| from pydantic import ValidationError | from pydantic import ValidationError | ||||||
|  |  | ||||||
| from .injectors import (AbstractInjector, BodyGetter, HeadersGetter, | from .injectors import ( | ||||||
|                         MatchInfoGetter, QueryGetter, _parse_func_signature) |     AbstractInjector, | ||||||
|  |     BodyGetter, | ||||||
|  |     HeadersGetter, | ||||||
|  |     MatchInfoGetter, | ||||||
|  |     QueryGetter, | ||||||
|  |     _parse_func_signature, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PydanticView(AbstractView): | class PydanticView(AbstractView): | ||||||
|   | |||||||
| @@ -1,25 +1,5 @@ | |||||||
| from aiohttp import web | from aiohttp import web | ||||||
| from aiohttp.web import middleware |  | ||||||
|  |  | ||||||
| from aiohttp_pydantic import oas | from .main import app | ||||||
|  |  | ||||||
| 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) |  | ||||||
|  |  | ||||||
| web.run_app(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