From 7c8593f81d863f0783d728378d9161f2bbeb2e17 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 19 May 2021 12:03:15 +0300 Subject: [PATCH] CBV method support and OAS3 autodoc (#234) * Add autodoc and CBV support * Add CBV support to oas2 * Version and changelod --- CHANGELOG.md | 36 ++++++++++++- sanic_openapi/__init__.py | 2 +- sanic_openapi/openapi2/blueprint.py | 50 ++++++++++++++---- sanic_openapi/openapi3/blueprint.py | 24 +++++++-- sanic_openapi/openapi3/builders.py | 33 ++++++++++-- sanic_openapi/openapi3/types.py | 22 ++++++-- tests/test_decorators.py | 80 ++++++++++++++++++++++++++--- tests/test_oas3.py | 36 ++++++++++--- 8 files changed, 246 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe20efa..6ab062e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog + +## 21.3.2 (2021-05-19) + +### Features + +* [#234](https://github.com/sanic-org/sanic-openapi/pull/234) - CBV method support; OAS3 autodoc + +## 21.3 (2021-05-05) + +### Features + +* [#189](https://github.com/sanic-org/sanic-openapi/pull/189) - Path parameter description +* [#198](https://github.com/sanic-org/sanic-openapi/pull/198) - Update With Raw Dictionary +* [#207](https://github.com/sanic-org/sanic-openapi/pull/207) - Using both produces and response +* [#208](https://github.com/sanic-org/sanic-openapi/pull/208) - Docstring parsing +* [#210](https://github.com/sanic-org/sanic-openapi/pull/210) - OAS3 support for sanic-openapi3 +* [#218](https://github.com/sanic-org/sanic-openapi/pull/218) - Sanic v21.3 Support + +### Bug fixes + +* [#192](https://github.com/sanic-org/sanic-openapi/pull/192) - Fix getattr default +* [#202](https://github.com/sanic-org/sanic-openapi/pull/202) - Fix consumes_content_type multiple times + +### Build system + +* [#204](https://github.com/sanic-org/sanic-openapi/pull/204) - Fix the broken build +* [#214](https://github.com/sanic-org/sanic-openapi/pull/214) - add OS related section to .gitignore + +### Documentation + +* [#183](https://github.com/sanic-org/sanic-openapi/pull/183) - Fix README +* [#197](https://github.com/sanic-org/sanic-openapi/pull/197) - Fix README +* [#206](https://github.com/sanic-org/sanic-openapi/pull/206) - Fix badges in README + ### 0.6.2 (2020-06-01) ### Features @@ -11,7 +45,7 @@ * TypeError when Spec obj is not JSON serializable ([fe29d0](https://github.com/sanic-org/sanic-openapi/commit/fe29d07ccb0e02ec0be6496e971946269b2d7907)) * Attribute name "name" conflict in consumes body ([67aaf3](https://github.com/sanic-org/sanic-openapi/commit/67aaf34eca5e339c349ef65bd0392cb8a97f184e)) -### 0.6.1 (2020-01-03) +## 0.6.1 (2020-01-03) ### Features diff --git a/sanic_openapi/__init__.py b/sanic_openapi/__init__.py index 282aefc..2bfe4e1 100644 --- a/sanic_openapi/__init__.py +++ b/sanic_openapi/__init__.py @@ -3,7 +3,7 @@ swagger_blueprint = openapi2_blueprint -__version__ = "21.3.1" +__version__ = "21.3.2" __all__ = [ "openapi2_blueprint", "swagger_blueprint", diff --git a/sanic_openapi/openapi2/blueprint.py b/sanic_openapi/openapi2/blueprint.py index 27179ac..444cb02 100644 --- a/sanic_openapi/openapi2/blueprint.py +++ b/sanic_openapi/openapi2/blueprint.py @@ -21,7 +21,9 @@ def blueprint_factory(): dir_path = dirname(dirname(realpath(__file__))) dir_path = abspath(dir_path + "/ui") - swagger_blueprint.static("/", dir_path + "/index.html", strict_slashes=True) + swagger_blueprint.static( + "/", dir_path + "/index.html", strict_slashes=True + ) swagger_blueprint.static("/", dir_path) # Redirect "/swagger" to "/swagger/" @@ -39,7 +41,9 @@ def spec(request): @swagger_blueprint.route("/swagger-config") def config(request): - return json(getattr(request.app.config, "SWAGGER_UI_CONFIGURATION", {})) + return json( + getattr(request.app.config, "SWAGGER_UI_CONFIGURATION", {}) + ) @swagger_blueprint.listener("after_server_start") def build_spec(app, loop): @@ -55,7 +59,12 @@ def build_spec(app, loop): paths = {} - for (uri, route_name, route_parameters, method_handlers) in get_all_routes(app, swagger_blueprint.url_prefix): + for ( + uri, + route_name, + route_parameters, + method_handlers, + ) in get_all_routes(app, swagger_blueprint.url_prefix): # --------------------------------------------------------------- # # Methods @@ -64,16 +73,33 @@ def build_spec(app, loop): methods = {} for _method, _handler in method_handlers: + if hasattr(_handler, "view_class"): + _handler = getattr(_handler.view_class, _method.lower()) + route_spec = route_specs.get(_handler) or RouteSpec() if route_spec.exclude: continue - api_consumes_content_types = getattr(app.config, "API_CONSUMES_CONTENT_TYPES", ["application/json"]) - consumes_content_types = route_spec.consumes_content_type or api_consumes_content_types + api_consumes_content_types = getattr( + app.config, + "API_CONSUMES_CONTENT_TYPES", + ["application/json"], + ) + consumes_content_types = ( + route_spec.consumes_content_type + or api_consumes_content_types + ) - api_produces_content_types = getattr(app.config, "API_PRODUCES_CONTENT_TYPES", ["application/json"]) - produces_content_types = route_spec.produces_content_type or api_produces_content_types + api_produces_content_types = getattr( + app.config, + "API_PRODUCES_CONTENT_TYPES", + ["application/json"], + ) + produces_content_types = ( + route_spec.produces_content_type + or api_produces_content_types + ) # Parameters - Path & Query String route_parameters = [] @@ -103,7 +129,8 @@ def build_spec(app, loop): "required": consumer.required, "in": consumer.location, "name": consumer.field.name - if not isinstance(consumer.field, type) and hasattr(consumer.field, "name") + if not isinstance(consumer.field, type) + and hasattr(consumer.field, "name") else "body", } @@ -176,7 +203,12 @@ def build_spec(app, loop): _spec = Swagger2Spec(app=app) - _spec.add_definitions(definitions={obj.object_name: definition for obj, definition in definitions.values()}) + _spec.add_definitions( + definitions={ + obj.object_name: definition + for obj, definition in definitions.values() + } + ) # --------------------------------------------------------------- # # Tags diff --git a/sanic_openapi/openapi3/blueprint.py b/sanic_openapi/openapi3/blueprint.py index c3f958a..b51ff92 100644 --- a/sanic_openapi/openapi3/blueprint.py +++ b/sanic_openapi/openapi3/blueprint.py @@ -1,12 +1,17 @@ +import inspect from os.path import abspath, dirname, realpath from sanic.blueprints import Blueprint from sanic.response import json, redirect +from ..autodoc import YamlStyleParametersParser from ..utils import get_all_routes, get_blueprinted_routes from . import operations, specification -DEFAULT_SWAGGER_UI_CONFIG = {"apisSorter": "alpha", "operationsSorter": "alpha"} +DEFAULT_SWAGGER_UI_CONFIG = { + "apisSorter": "alpha", + "operationsSorter": "alpha", +} def blueprint_factory(): @@ -51,7 +56,12 @@ def build_spec(app, loop): # --------------------------------------------------------------- # # Operations # --------------------------------------------------------------- # - for uri, route_name, route_parameters, method_handlers in get_all_routes(app, oas3_blueprint.url_prefix): + for ( + uri, + route_name, + route_parameters, + method_handlers, + ) in get_all_routes(app, oas3_blueprint.url_prefix): # --------------------------------------------------------------- # # Methods @@ -64,7 +74,13 @@ def build_spec(app, loop): if method == "OPTIONS": continue + if hasattr(_handler, "view_class"): + _handler = getattr(_handler.view_class, method.lower()) operation = operations[_handler] + docstring = inspect.getdoc(_handler) + + if docstring: + operation.autodoc(docstring) # operation ID must be unique, and it isnt currently used for # anything in UI, so dont add something meaningless @@ -72,7 +88,9 @@ def build_spec(app, loop): # operation.operationId = "%s_%s" % (method.lower(), route.name) for _parameter in route_parameters: - operation.parameter(_parameter.name, _parameter.cast, "path") + operation.parameter( + _parameter.name, _parameter.cast, "path" + ) specification.operation(uri, method, operation) diff --git a/sanic_openapi/openapi3/builders.py b/sanic_openapi/openapi3/builders.py index dbbed4c..6315746 100644 --- a/sanic_openapi/openapi3/builders.py +++ b/sanic_openapi/openapi3/builders.py @@ -6,6 +6,7 @@ """ from collections import defaultdict +from ..autodoc import YamlStyleParametersParser from ..utils import remove_nulls, remove_nulls_from_kwargs from .definitions import ( Any, @@ -44,6 +45,7 @@ def __init__(self): self.security = [] self.parameters = [] self.responses = {} + self._autodoc = None def name(self, value: str): self.operationId = value @@ -68,10 +70,16 @@ def deprecate(self): def body(self, content: Any, **kwargs): self.requestBody = RequestBody.make(content, **kwargs) - def parameter(self, name: str, schema: Any, location: str = "query", **kwargs): - self.parameters.append(Parameter.make(name, schema, location, **kwargs)) + def parameter( + self, name: str, schema: Any, location: str = "query", **kwargs + ): + self.parameters.append( + Parameter.make(name, schema, location, **kwargs) + ) - def response(self, status, content: Any = None, description: str = None, **kwargs): + def response( + self, status, content: Any = None, description: str = None, **kwargs + ): self.responses[status] = Response.make(content, description, **kwargs) def secured(self, *args, **kwargs): @@ -90,8 +98,15 @@ def build(self): # todo -- look into more consistent default response format operation_dict["responses"]["default"] = {"description": "OK"} + if self._autodoc: + operation_dict.update(self._autodoc) + return Operation(**operation_dict) + def autodoc(self, docstring: str): + y = YamlStyleParametersParser(docstring) + self._autodoc = y.to_openAPI_3() + class SpecificationBuilder: _urls: List[str] @@ -115,7 +130,13 @@ def __init__(self): def url(self, value: str): self._urls.append(value) - def describe(self, title: str, version: str, description: str = None, terms: str = None): + def describe( + self, + title: str, + version: str, + description: str = None, + terms: str = None, + ): self._title = title self._version = version self._description = description @@ -174,6 +195,8 @@ def _build_paths(self) -> Dict: paths = {} for path, operations in self._paths.items(): - paths[path] = PathItem(**{k: v.build() for k, v in operations.items()}) + paths[path] = PathItem( + **{k: v.build() for k, v in operations.items()} + ) return paths diff --git a/sanic_openapi/openapi3/types.py b/sanic_openapi/openapi3/types.py index 5876d97..794e4e4 100644 --- a/sanic_openapi/openapi3/types.py +++ b/sanic_openapi/openapi3/types.py @@ -14,7 +14,11 @@ def fields(self): return self.__fields def guard(self, fields): - return {k: v for k, v in fields.items() if k in _properties(self).keys() or k.startswith("x-")} + return { + k: v + for k, v in fields.items() + if k in _properties(self).keys() or k.startswith("x-") + } def serialize(self): return _serialize(self.fields) @@ -98,9 +102,14 @@ def make(value, **kwargs): return Array(schema, **kwargs) elif _type == dict: - return Object({k: Schema.make(v) for k, v in value.items()}, **kwargs) + return Object( + {k: Schema.make(v) for k, v in value.items()}, **kwargs + ) else: - return Object({k: Schema.make(v) for k, v in _properties(value).items()}, **kwargs) + return Object( + {k: Schema.make(v) for k, v in _properties(value).items()}, + **kwargs, + ) class Boolean(Schema): @@ -201,6 +210,11 @@ def _serialize(value) -> Any: def _properties(value: object) -> Dict: - fields = {x: v for x, v in value.__dict__.items() if not x.startswith("_")} + try: + fields = { + x: v for x, v in value.__dict__.items() if not x.startswith("_") + } + except AttributeError: + return {} return {**get_type_hints(value.__class__), **fields} diff --git a/tests/test_decorators.py b/tests/test_decorators.py index bbfb874..081a8f2 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,6 +1,6 @@ import pytest from sanic.response import text - +from sanic.views import HTTPMethodView from sanic_openapi import doc @@ -11,8 +11,14 @@ ({"description": "test"}, {"description": "test"}), ({"consumes": [doc.RouteField(str)]}, {}), ({"produces": doc.RouteField(str)}, {}), - ({"consumes_content_type": ["text/html"]}, {"consumes": ["text/html"]}), - ({"produces_content_type": ["text/html"]}, {"produces": ["text/html"]}), + ( + {"consumes_content_type": ["text/html"]}, + {"consumes": ["text/html"]}, + ), + ( + {"produces_content_type": ["text/html"]}, + {"produces": ["text/html"]}, + ), ({"response": [{400, doc.RouteField(str)}]}, {}), ], ) @@ -33,6 +39,43 @@ def test(request): ) +@pytest.mark.parametrize( + "route_kwargs, route_fields", + [ + ({"summary": "test"}, {"summary": "test"}), + ({"description": "test"}, {"description": "test"}), + ({"consumes": [doc.RouteField(str)]}, {}), + ({"produces": doc.RouteField(str)}, {}), + ( + {"consumes_content_type": ["text/html"]}, + {"consumes": ["text/html"]}, + ), + ( + {"produces_content_type": ["text/html"]}, + {"produces": ["text/html"]}, + ), + ({"response": [{400, doc.RouteField(str)}]}, {}), + ], +) +def test_cbv_route(app, route_kwargs, route_fields): + class CBVRoute(HTTPMethodView): + @doc.route(**route_kwargs) + def post(request): + return text("") + + app.add_route(CBVRoute.as_view(), "/") + + _, response = app.test_client.get("/swagger/swagger.json") + assert response.status == 200 + assert response.content_type == "application/json" + + swagger_json = response.json + assert all( + item in swagger_json["paths"]["/"]["post"].items() + for item in route_fields.items() + ) + + @pytest.mark.parametrize("exclude", [True, False]) def test_exclude(app, exclude): @app.get("/") @@ -73,7 +116,10 @@ def test(request): assert response.content_type == "application/json" swagger_json = response.json - assert swagger_json["paths"]["/"]["get"]["description"] == "This is test route" + assert ( + swagger_json["paths"]["/"]["get"]["description"] + == "This is test route" + ) class TestSchema: @@ -86,12 +132,27 @@ class TestSchema: ([], {"location": "body", "required": False}, []), ( [doc.String()], - {"location": "header", "required": True, "content_type": "text/html"}, - [{"type": "string", "required": True, "in": "header", "name": None}], + { + "location": "header", + "required": True, + "content_type": "text/html", + }, + [ + { + "type": "string", + "required": True, + "in": "header", + "name": None, + } + ], ), ( [TestSchema], - {"location": "body", "required": True, "content_type": "application/json"}, + { + "location": "body", + "required": True, + "content_type": "application/json", + }, [ { "required": True, @@ -225,4 +286,7 @@ def test(request): assert response.content_type == "application/json" swagger_json = response.json - assert swagger_json["paths"]["/"]["get"]["operationId"] == "This is test operation" + assert ( + swagger_json["paths"]["/"]["get"]["operationId"] + == "This is test operation" + ) diff --git a/tests/test_oas3.py b/tests/test_oas3.py index 6bf31c6..b1bb02b 100644 --- a/tests/test_oas3.py +++ b/tests/test_oas3.py @@ -2,7 +2,8 @@ from sanic import Sanic from sanic.response import json as json_response - +from sanic.response import text +from sanic.views import HTTPMethodView from sanic_openapi import openapi, openapi3_blueprint @@ -14,9 +15,11 @@ def test_documentation(): _, app_response = app.test_client.get("/swagger/swagger.json") post_operation = app_response.json["paths"]["/garage"]["post"] - body_props = post_operation["requestBody"]["content"]["application/json"]["schema"][ - "properties" - ] + get_operation = app_response.json["paths"][r"/garage/{garage_id}"]["get"] + body_props = post_operation["requestBody"]["content"]["application/json"][ + "schema" + ]["properties"] + assert post_operation["description"] == "Create a new garage" car_schema_in_body = body_props["cars"] assert car_schema_in_body["required"] is False @@ -26,9 +29,15 @@ def test_documentation(): assert spaces_schema_in_body["required"] is True assert spaces_schema_in_body["format"] == "int32" assert spaces_schema_in_body["type"] == "integer" - assert spaces_schema_in_body["description"] == "Space available in the garage" + assert ( + spaces_schema_in_body["description"] == "Space available in the garage" + ) assert app_response.status == 200 + assert get_operation["description"] == "Query the cars in the garage" + assert get_operation["summary"] == "Get a list of all the cars in a garage" + assert len(get_operation["parameters"]) == 2 + app_ID = itertools.count() @@ -42,7 +51,9 @@ def get_app(): app.blueprint(openapi3_blueprint) class Car: - manufacturer = openapi.String(description="Car manufacturer", required=True) + manufacturer = openapi.String( + description="Car manufacturer", required=True + ) model = openapi.String(description="Car model", required=True) production_date = openapi.Date(description="Car year", required=True) @@ -66,4 +77,17 @@ class Garage: async def create_garage(request): return json_response(request.json, 201) + class CarView(HTTPMethodView): + @openapi.parameter("make", str, location="query") + async def get(self, request, garage_id): + """ + openapi: + --- + summary: Get a list of all the cars in a garage + description: Query the cars in the garage + """ + return text("Fetching some cars.") + + app.add_route(CarView.as_view(), "/garage/") + return app