Add sub-app to generate open api spec

This commit is contained in:
Vincent Maillol
2020-10-23 19:44:44 +02:00
parent 1ffde607c9
commit d6b5fc26f3
24 changed files with 932 additions and 124 deletions

View File

@@ -1,3 +1,5 @@
from .view import PydanticView
__all__ = ("PydanticView",)
__version__ = "1.0.0"
__all__ = ("PydanticView", "__version__")

View File

@@ -94,6 +94,9 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
if param_name == "self":
continue
if param_spec.annotation == param_spec.empty:
raise RuntimeError(f"The parameter {param_name} must have an annotation")
if param_spec.kind is param_spec.POSITIONAL_ONLY:
path_args[param_name] = param_spec.annotation
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:

View File

@@ -0,0 +1,26 @@
from typing import Iterable
from importlib import resources
import jinja2
from aiohttp import web
from .view import get_oas, oas_ui
from swagger_ui_bundle import swagger_ui_path
def setup(
app: web.Application,
apps_to_expose: Iterable[web.Application] = (),
url_prefix: str = "/oas",
enable: bool = True,
):
if enable:
oas_app = web.Application()
oas_app["apps to expose"] = tuple(apps_to_expose) or (app,)
oas_app["index template"] = jinja2.Template(
resources.read_text("aiohttp_pydantic.oas", "index.j2")
)
oas_app.router.add_get("/spec", get_oas, name="spec")
oas_app.router.add_static("/static", swagger_ui_path, name="static")
oas_app.router.add_get("", oas_ui, name="index")
app.add_subapp(url_prefix, oas_app)

View File

@@ -0,0 +1,69 @@
{# This updated file is part of swagger_ui_bundle (https://github.com/dtkav/swagger_ui_bundle) #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title | default('Swagger UI') }}</title>
<link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" >
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js"> </script>
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "{{ openapi_spec_url }}",
validatorUrl: {{ validatorUrl | default('null') }},
{% if configUrl is defined %}
configUrl: "{{ configUrl }}",
{% endif %}
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
{% if initOAuth is defined %}
ui.initOAuth(
{{ initOAuth|tojson|safe }}
)
{% endif %}
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>

View File

@@ -0,0 +1,246 @@
class Info:
def __init__(self, spec: dict):
self._spec = spec.setdefault("info", {})
@property
def title(self):
return self._spec["title"]
@title.setter
def title(self, title):
self._spec["title"] = title
@property
def description(self):
return self._spec["description"]
@description.setter
def description(self, description):
self._spec["description"] = description
@property
def version(self):
return self._spec["version"]
@version.setter
def version(self, version):
self._spec["version"] = version
class RequestBody:
def __init__(self, spec: dict):
self._spec = spec.setdefault("requestBody", {})
@property
def description(self):
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def required(self):
return self._spec["required"]
@required.setter
def required(self, required: bool):
self._spec["required"] = required
@property
def content(self):
return self._spec["content"]
@content.setter
def content(self, content: dict):
self._spec["content"] = content
class Parameter:
def __init__(self, spec: dict):
self._spec = spec
@property
def name(self) -> str:
return self._spec["name"]
@name.setter
def name(self, name: str):
self._spec["name"] = name
@property
def in_(self) -> str:
return self._spec["in"]
@in_.setter
def in_(self, in_: str):
self._spec["in"] = in_
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def required(self) -> bool:
return self._spec["required"]
@required.setter
def required(self, required: bool):
self._spec["required"] = required
@property
def schema(self) -> dict:
return self._spec["schema"]
@schema.setter
def schema(self, schema: dict):
self._spec["schema"] = schema
class Parameters:
def __init__(self, spec):
self._spec = spec
self._spec.setdefault("parameters", [])
def __getitem__(self, item: int) -> Parameter:
if item == len(self._spec["parameters"]):
spec = {}
self._spec["parameters"].append(spec)
else:
spec = self._spec["parameters"][item]
return Parameter(spec)
class OperationObject:
def __init__(self, spec: dict):
self._spec = spec
@property
def summary(self) -> str:
return self._spec["summary"]
@summary.setter
def summary(self, summary: str):
self._spec["summary"] = summary
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def request_body(self) -> RequestBody:
return RequestBody(self._spec)
@property
def parameters(self) -> Parameters:
return Parameters(self._spec)
class PathItem:
def __init__(self, spec: dict):
self._spec = spec
@property
def get(self) -> OperationObject:
return OperationObject(self._spec.setdefault("get", {}))
@property
def put(self) -> OperationObject:
return OperationObject(self._spec.setdefault("put", {}))
@property
def post(self) -> OperationObject:
return OperationObject(self._spec.setdefault("post", {}))
@property
def delete(self) -> OperationObject:
return OperationObject(self._spec.setdefault("delete", {}))
@property
def options(self) -> OperationObject:
return OperationObject(self._spec.setdefault("options", {}))
@property
def head(self) -> OperationObject:
return OperationObject(self._spec.setdefault("head", {}))
@property
def patch(self) -> OperationObject:
return OperationObject(self._spec.setdefault("patch", {}))
@property
def trace(self) -> OperationObject:
return OperationObject(self._spec.setdefault("trace", {}))
class Paths:
def __init__(self, spec: dict):
self._spec = spec.setdefault("paths", {})
def __getitem__(self, path: str) -> PathItem:
spec = self._spec.setdefault(path, {})
return PathItem(spec)
class Server:
def __init__(self, spec: dict):
self._spec = spec
@property
def url(self) -> str:
return self._spec["url"]
@url.setter
def url(self, url: str):
self._spec["url"] = url
@property
def description(self) -> str:
return self._spec["url"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
class Servers:
def __init__(self, spec: dict):
self._spec = spec
self._spec.setdefault("servers", [])
def __getitem__(self, item: int) -> Server:
if item == len(self._spec["servers"]):
spec = {}
self._spec["servers"].append(spec)
else:
spec = self._spec["servers"][item]
return Server(spec)
class OpenApiSpec3:
def __init__(self):
self._spec = {"openapi": "3.0.0"}
@property
def info(self) -> Info:
return Info(self._spec)
@property
def servers(self) -> Servers:
return Servers(self._spec)
@property
def paths(self) -> Paths:
return Paths(self._spec)
@property
def spec(self):
return self._spec

View File

@@ -0,0 +1,85 @@
from aiohttp.web import json_response, Response
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
from typing import Type
from ..injectors import _parse_func_signature
from ..view import PydanticView, is_pydantic_view
JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"}
def _add_http_method_to_oas(oas_path: PathItem, method: str, view: Type[PydanticView]):
method = method.lower()
mtd: OperationObject = getattr(oas_path, method)
handler = getattr(view, method)
path_args, body_args, qs_args, header_args = _parse_func_signature(handler)
if body_args:
mtd.request_body.content = {
"application/json": {"schema": next(iter(body_args.values())).schema()}
}
i = 0
for i, (name, type_) in enumerate(path_args.items()):
mtd.parameters[i].required = True
mtd.parameters[i].in_ = "path"
mtd.parameters[i].name = name
mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
for i, (name, type_) in enumerate(qs_args.items(), i + 1):
mtd.parameters[i].required = False
mtd.parameters[i].in_ = "query"
mtd.parameters[i].name = name
mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
for i, (name, type_) in enumerate(header_args.items(), i + 1):
mtd.parameters[i].required = False
mtd.parameters[i].in_ = "header"
mtd.parameters[i].name = name
mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
async def get_oas(request):
"""
Generate 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():
for resource_route in resources:
if is_pydantic_view(resource_route.handler):
view: Type[PydanticView] = resource_route.handler
info = resource_route.get_info()
path = oas.paths[info.get("path", info.get("formatter"))]
if resource_route.method == "*":
for method_name in view.allowed_methods:
_add_http_method_to_oas(path, method_name, view)
else:
_add_http_method_to_oas(path, resource_route.method, view)
return json_response(oas.spec)
async def oas_ui(request):
"""
View to serve the swagger-ui to read open api specification of application.
"""
template = request.app["index template"]
static_url = request.app.router["static"].url_for(filename="")
spec_url = request.app.router["spec"].url_for()
host = request.url.origin()
return Response(
text=template.render(
{
"openapi_spec_url": host.with_path(str(spec_url)),
"static_url": host.with_path(str(static_url)),
}
),
content_type="text/html",
charset="utf-8",
)

View File

@@ -1,11 +1,10 @@
from inspect import iscoroutinefunction
from aiohttp.abc import AbstractView
from aiohttp.hdrs import METH_ALL
from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse
from pydantic import ValidationError
from typing import Generator, Any, Callable, List, Iterable
from typing import Generator, Any, Callable, Type, Iterable
from aiohttp.web import json_response
from functools import update_wrapper
@@ -34,21 +33,21 @@ class PydanticView(AbstractView):
return self._iter().__await__()
def __init_subclass__(cls, **kwargs):
allowed_methods = {
cls.allowed_methods = {
meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower())
}
async def raise_not_allowed(self):
raise HTTPMethodNotAllowed(self.request.method, allowed_methods)
for meth_name in METH_ALL:
if meth_name not in allowed_methods:
setattr(cls, meth_name.lower(), raise_not_allowed)
if meth_name not in cls.allowed_methods:
setattr(cls, meth_name.lower(), cls.raise_not_allowed)
else:
handler = getattr(cls, meth_name.lower())
decorated_handler = inject_params(handler, cls.parse_func_signature)
setattr(cls, meth_name.lower(), decorated_handler)
async def raise_not_allowed(self):
raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods)
@staticmethod
def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]:
path_args, body_args, qs_args, header_args = _parse_func_signature(func)
@@ -90,3 +89,13 @@ def inject_params(
update_wrapper(wrapped_handler, handler)
return wrapped_handler
def is_pydantic_view(obj) -> bool:
"""
Return True if obj is a PydanticView subclass else False.
"""
try:
return issubclass(obj, PydanticView)
except TypeError:
return False