From be39d4814f11d32012e5e2c511f999936f1298cd Mon Sep 17 00:00:00 2001 From: Igor Nemilentsev Date: Tue, 27 Feb 2018 11:21:18 +0300 Subject: [PATCH] Added swagger_merge_with_file parameter. Allows redefine the swagger description in the code for needed endpoints --- .gitignore | 3 + aiohttp_swagger/__init__.py | 39 ++++++++-- aiohttp_swagger/helpers/builders.py | 39 ++++++---- doc/source/customizing.rst | 44 +++++++++++ tests/conftest.py | 22 ++++++ tests/test_swagger.py | 116 +++++++++++++++++++++++----- 6 files changed, 225 insertions(+), 38 deletions(-) create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore index 72aa6fb..49e78eb 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ ENV/ # Pycharm .idea/ + +# pytest +.pytest_cache/ diff --git a/aiohttp_swagger/__init__.py b/aiohttp_swagger/__init__.py index b9a8079..fa714dd 100644 --- a/aiohttp_swagger/__init__.py +++ b/aiohttp_swagger/__init__.py @@ -1,11 +1,18 @@ import asyncio -from os.path import abspath, dirname, join +from os.path import ( + abspath, + dirname, + join, +) from types import FunctionType from aiohttp import web -from .helpers import (generate_doc_from_each_end_point, - load_doc_from_yaml_file, swagger_path) +from .helpers import ( + generate_doc_from_each_end_point, + load_doc_from_yaml_file, + swagger_path, +) try: import ujson as json @@ -47,6 +54,8 @@ def setup_swagger(app: web.Application, contact: str = "", swagger_home_decor: FunctionType = None, swagger_def_decor: FunctionType = None, + swagger_merge_with_file: bool = False, + swagger_validate_schema: bool = False, swagger_info: dict = None): _swagger_url = ("/{}".format(swagger_url) if not swagger_url.startswith("/") @@ -54,17 +63,35 @@ def setup_swagger(app: web.Application, _base_swagger_url = _swagger_url.rstrip('/') _swagger_def_url = '{}/swagger.json'.format(_base_swagger_url) - # Build Swagget Info + # Build Swagger Info if swagger_info is None: if swagger_from_file: swagger_info = load_doc_from_yaml_file(swagger_from_file) + if swagger_merge_with_file: + swagger_end_points_info = generate_doc_from_each_end_point( + app, api_base_url=api_base_url, description=description, + api_version=api_version, title=title, contact=contact + ) + paths = swagger_end_points_info.pop('paths', None) + swagger_info.update(swagger_end_points_info) + if paths is not None: + if 'paths' not in swagger_info: + swagger_info['paths'] = {} + for ph, description in paths.items(): + for method, desc in description.items(): + if ph not in swagger_info['paths']: + swagger_info['paths'][ph] = {} + swagger_info['paths'][ph][method] = desc else: swagger_info = generate_doc_from_each_end_point( app, api_base_url=api_base_url, description=description, api_version=api_version, title=title, contact=contact ) - else: - swagger_info = json.dumps(swagger_info) + + if swagger_validate_schema: + pass + + swagger_info = json.dumps(swagger_info) _swagger_home_func = _swagger_home _swagger_def_func = _swagger_def diff --git a/aiohttp_swagger/helpers/builders.py b/aiohttp_swagger/helpers/builders.py index e272094..7ca29e4 100644 --- a/aiohttp_swagger/helpers/builders.py +++ b/aiohttp_swagger/helpers/builders.py @@ -1,5 +1,13 @@ +from typing import ( + MutableMapping, + Mapping, +) from collections import defaultdict -from os.path import abspath, dirname, join +from os.path import ( + abspath, + dirname, + join, +) import yaml from aiohttp import web @@ -8,7 +16,7 @@ try: import ujson as json -except ImportError: # pragma: no cover +except ImportError: # pragma: no cover import json @@ -36,29 +44,33 @@ def _extract_swagger_docs(end_point_doc, method="get"): } return {method: end_point_swagger_doc} + def _build_doc_from_func_doc(route): out = {} if issubclass(route.handler, web.View) and route.method == METH_ANY: method_names = { - attr for attr in dir(route.handler) \ + attr for attr in dir(route.handler) if attr.upper() in METH_ALL } for method_name in method_names: method = getattr(route.handler, method_name) if method.__doc__ is not None and "---" in method.__doc__: end_point_doc = method.__doc__.splitlines() - out.update(_extract_swagger_docs(end_point_doc, method=method_name)) + out.update( + _extract_swagger_docs(end_point_doc, method=method_name)) else: try: end_point_doc = route.handler.__doc__.splitlines() except AttributeError: return {} - out.update(_extract_swagger_docs(end_point_doc)) + out.update(_extract_swagger_docs( + end_point_doc, method=route.method.lower())) return out + def generate_doc_from_each_end_point( app: web.Application, *, @@ -66,7 +78,7 @@ def generate_doc_from_each_end_point( description: str = "Swagger API definition", api_version: str = "1.0.0", title: str = "Swagger API", - contact: str = ""): + contact: str = "") -> MutableMapping: # Clean description _start_desc = 0 for i, word in enumerate(description): @@ -92,8 +104,6 @@ def generate_doc_from_each_end_point( for route in app.router.routes(): - end_point_doc = None - # If route has a external link to doc, we use it, not function doc if getattr(route.handler, "swagger_file", False): try: @@ -133,13 +143,14 @@ def generate_doc_from_each_end_point( url = url_info.get("formatter") swagger["paths"][url].update(end_point_doc) - - return json.dumps(swagger) + return swagger -def load_doc_from_yaml_file(doc_path: str): - loaded_yaml = yaml.load(open(doc_path, "r").read()) - return json.dumps(loaded_yaml) +def load_doc_from_yaml_file(doc_path: str) -> MutableMapping: + return yaml.load(open(doc_path, "r").read()) -__all__ = ("generate_doc_from_each_end_point", "load_doc_from_yaml_file") +__all__ = ( + "generate_doc_from_each_end_point", + "load_doc_from_yaml_file" +) diff --git a/doc/source/customizing.rst b/doc/source/customizing.rst index 77443d2..3906cbe 100644 --- a/doc/source/customizing.rst +++ b/doc/source/customizing.rst @@ -168,6 +168,50 @@ Global Swagger YAML web.run_app(app, host="127.0.0.1") + +:samp:`aiohttp-swagger` also allow to build an external YAML Swagger file + and merge swagger endpoint definitions to it: + +.. code-block:: python + + from aiohttp import web + from aiohttp_swagger import * + + async def ping(request): + """ + --- + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: examples.api.api.createUser + produces: + - application/json + parameters: + - in: body + name: body + description: Created user object + required: false + + responses: + "201": + description: successful operation + """ + return web.Response(text="pong") + + app = web.Application() + + app.router.add_route('GET', "/ping", ping) + + setup_swagger( + app, + swagger_from_file="example_swagger.yaml", # <-- Loaded Swagger from external YAML file + swagger_merge_with_file=True # <-- Merge + ) + + web.run_app(app, host="127.0.0.1") + + Nested applications +++++++++++++++++++ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..674a853 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +from os.path import ( + abspath, + dirname, + join, +) + +import yaml +import pytest + + +@pytest.fixture +def swagger_file(): + tests_path = abspath(join(dirname(__file__))) + return join(tests_path, "data", "example_swagger.yaml") + + +@pytest.fixture +def swagger_info(): + filename = abspath(join(dirname(__file__))) + "/data/example_swagger.yaml" + return yaml.load(open(filename).read()) + + diff --git a/tests/test_swagger.py b/tests/test_swagger.py index f0fe181..ceddfd1 100644 --- a/tests/test_swagger.py +++ b/tests/test_swagger.py @@ -1,15 +1,17 @@ import asyncio import json -import pytest -import yaml -from os.path import join, dirname, abspath +from os.path import ( + join, + dirname, + abspath, +) from aiohttp import web from aiohttp_swagger import * @asyncio.coroutine -def ping(request): +def ping(_): """ --- description: This end-point allow to test that service is up. @@ -27,7 +29,7 @@ def ping(request): @asyncio.coroutine -def undoc_ping(request): +def undoc_ping(_): return web.Response(text="pong") @@ -77,9 +79,49 @@ def patch(self): return web.Response(text="OK") +class ClassViewWithSwaggerDoc(web.View): + + def _irrelevant_method(self): + pass + + @asyncio.coroutine + def get(self): + """ + --- + description: Get resources + tags: + - Class View + produces: + - application/json + consumes: + - application/json + parameters: + - in: body + name: body + description: Created user object + required: false + schema: + type: object + properties: + id: + type: integer + format: int64 + username: + type: + - "string" + - "null" + responses: + "200": + description: successful operation. + "405": + description: invalid HTTP Method + """ + return web.Response(text="OK") + + @swagger_path(abspath(join(dirname(__file__))) + '/data/partial_swagger.yaml') @asyncio.coroutine -def ping_partial(request): +def ping_partial(_): return web.Response(text="pong") @@ -96,12 +138,9 @@ def test_ping(test_client, loop): @asyncio.coroutine -def test_swagger_file_url(test_client, loop): - TESTS_PATH = abspath(join(dirname(__file__))) - +def test_swagger_file_url(test_client, loop, swagger_file): app = web.Application(loop=loop) - setup_swagger(app, - swagger_from_file=TESTS_PATH + "/data/example_swagger.yaml") + setup_swagger(app, swagger_from_file=swagger_file) client = yield from test_client(app) resp1 = yield from client.get('/api/doc/swagger.json') @@ -192,17 +231,10 @@ def test_swagger_def_decorator(test_client, loop): assert 'Test Custom Title' in result['info']['title'] -@pytest.fixture -def swagger_info(): - filename = abspath(join(dirname(__file__))) + "/data/example_swagger.yaml" - return yaml.load(open(filename).read()) - - @asyncio.coroutine def test_swagger_info(test_client, loop, swagger_info): app = web.Application(loop=loop) app.router.add_route('GET', "/ping", ping) - description = "Test Custom Swagger" setup_swagger(app, swagger_url="/api/v1/doc", swagger_info=swagger_info) @@ -231,6 +263,7 @@ def test_undocumented_fn(test_client, loop): result = json.loads(text) assert not result['paths'] + @asyncio.coroutine def test_class_view(test_client, loop): app = web.Application(loop=loop) @@ -294,3 +327,50 @@ def test_sub_app(test_client, loop): assert "/class_view" in result['paths'] assert "get" in result['paths']["/class_view"] assert "post" in result['paths']["/class_view"] + + +@asyncio.coroutine +def test_class_merge_swagger_view(test_client, loop, swagger_file): + app = web.Application(loop=loop) + app.router.add_route('*', "/example2", ClassViewWithSwaggerDoc) + setup_swagger( + app, + swagger_merge_with_file=True, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + resp = yield from client.get('/example2') + assert resp.status == 200 + text = yield from resp.text() + assert 'OK' in text + swagger_resp1 = yield from client.get('/api/doc/swagger.json') + assert swagger_resp1.status == 200 + text = yield from swagger_resp1.text() + result = json.loads(text) + assert "/example2" in result['paths'] + assert "get" in result['paths']["/example2"] + assert result['paths']["/example2"]["get"]["parameters"][0]["schema"][ + "properties"]["id"]["type"] == "integer" + + +@asyncio.coroutine +def test_class_no_merge_swagger_view(test_client, loop, swagger_file): + app = web.Application(loop=loop) + app.router.add_route('*', "/example2", ClassViewWithSwaggerDoc) + setup_swagger( + app, + swagger_merge_with_file=False, + swagger_from_file=swagger_file, + ) + client = yield from test_client(app) + resp = yield from client.get('/example2') + assert resp.status == 200 + text = yield from resp.text() + assert 'OK' in text + swagger_resp1 = yield from client.get('/api/doc/swagger.json') + assert swagger_resp1.status == 200 + text = yield from swagger_resp1.text() + result = json.loads(text) + assert "/example2" in result['paths'] + assert "get" in result['paths']["/example2"] + assert "schema" not in result['paths']["/example2"]["get"]["parameters"][0]