From 25fcac18ec2875cbbfd25929d8d0058cbdbbe01f Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Sat, 28 Nov 2020 19:39:46 +0100 Subject: [PATCH] Fix wrong link in OAS components with nested pydantic.BaseModel --- aiohttp_pydantic/__init__.py | 2 +- aiohttp_pydantic/injectors.py | 2 +- aiohttp_pydantic/oas/struct.py | 13 +++ aiohttp_pydantic/oas/view.py | 46 +++++---- demo/model.py | 7 ++ setup.cfg | 2 +- tests/test_oas/test_view.py | 165 +++++++++++++++++++++++++++++++-- 7 files changed, 200 insertions(+), 37 deletions(-) diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index cbf1f9b..dabdbee 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.6.0" +__version__ = "1.6.1" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index caed0fa..75284ae 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -68,7 +68,7 @@ class BodyGetter(AbstractInjector): except JSONDecodeError: raise HTTPBadRequest( text='{"error": "Malformed JSON"}', content_type="application/json" - ) + ) from None kwargs_view[self.arg_name] = self.model(**body) diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index 74ad9cc..faa8358 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -293,6 +293,15 @@ class Servers: return Server(spec) +class Components: + def __init__(self, spec: dict): + self._spec = spec.setdefault("components", {}) + + @property + def schemas(self) -> dict: + return self._spec.setdefault("schemas", {}) + + class OpenApiSpec3: def __init__(self): self._spec = {"openapi": "3.0.0"} @@ -309,6 +318,10 @@ class OpenApiSpec3: def paths(self) -> Paths: return Paths(self._spec) + @property + def components(self) -> Components: + return Components(self._spec) + @property def spec(self): return self._spec diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 343117b..6482e9d 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,9 +1,7 @@ import typing -from datetime import date, datetime from inspect import getdoc from itertools import count from typing import List, Type -from uuid import UUID from aiohttp.web import Response, json_response from aiohttp.web_app import Application @@ -16,16 +14,6 @@ from ..utils import is_pydantic_base_model from ..view import PydanticView, is_pydantic_view from .typing import is_status_code_type -JSON_SCHEMA_TYPES = { - float: {"type": "number"}, - str: {"type": "string"}, - int: {"type": "integer"}, - UUID: {"type": "string", "format": "uuid"}, - bool: {"type": "boolean"}, - datetime: {"type": "string", "format": "date-time"}, - date: {"type": "string", "format": "date"}, -} - def _handle_optional(type_): """ @@ -48,13 +36,16 @@ class _OASResponseBuilder: generate the OAS operation response. """ - def __init__(self, oas_operation): + def __init__(self, oas: OpenApiSpec3, oas_operation): self._oas_operation = oas_operation + self._oas = oas - @staticmethod - def _handle_pydantic_base_model(obj): + def _handle_pydantic_base_model(self, obj): if is_pydantic_base_model(obj): - return obj.schema() + response_schema = obj.schema(ref_template="#/components/schemas/{model}") + if def_sub_schemas := response_schema.get("definitions", None): + self._oas.components.schemas.update(def_sub_schemas) + return response_schema return {} def _handle_list(self, obj): @@ -89,7 +80,7 @@ class _OASResponseBuilder: def _add_http_method_to_oas( - oas_path: PathItem, http_method: str, view: Type[PydanticView] + oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView] ): http_method = http_method.lower() oas_operation: OperationObject = getattr(oas_path, http_method) @@ -102,8 +93,14 @@ def _add_http_method_to_oas( oas_operation.description = description if body_args: + body_schema = next(iter(body_args.values())).schema( + ref_template="#/components/schemas/{model}" + ) + if def_sub_schemas := body_schema.get("definitions", None): + oas.components.schemas.update(def_sub_schemas) + oas_operation.request_body.content = { - "application/json": {"schema": next(iter(body_args.values())).schema()} + "application/json": {"schema": body_schema} } indexes = count() @@ -122,14 +119,15 @@ def _add_http_method_to_oas( if name in defaults: attrs["__root__"] = defaults[name] - oas_operation.parameters[i].schema = type( - name, (BaseModel,), attrs - ).schema() + oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema( + ref_template="#/components/schemas/{model}" + ) + oas_operation.parameters[i].required = optional_type is None return_type = handler.__annotations__.get("return") if return_type is not None: - _OASResponseBuilder(oas_operation).build(return_type) + _OASResponseBuilder(oas, oas_operation).build(return_type) def generate_oas(apps: List[Application]) -> dict: @@ -148,9 +146,9 @@ def generate_oas(apps: List[Application]) -> dict: 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) + _add_http_method_to_oas(oas, path, method_name, view) else: - _add_http_method_to_oas(path, resource_route.method, view) + _add_http_method_to_oas(oas, path, resource_route.method, view) return oas.spec diff --git a/demo/model.py b/demo/model.py index 5a5b425..9dc06b7 100644 --- a/demo/model.py +++ b/demo/model.py @@ -1,10 +1,17 @@ from pydantic import BaseModel +from typing import List + + +class Friend(BaseModel): + name: str + age: str class Pet(BaseModel): id: int name: str age: int + friends: Friend class Error(BaseModel): diff --git a/setup.cfg b/setup.cfg index 90ce68d..32dc85e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,7 +31,7 @@ packages = find: python_requires = >=3.8 install_requires = aiohttp - pydantic + pydantic>=1.7 swagger-ui-bundle [options.extras_require] diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index a3144e5..283acd0 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import List, Optional, Union from uuid import UUID @@ -9,9 +10,21 @@ from aiohttp_pydantic import PydanticView, oas from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 +class Color(str, Enum): + RED = "red" + GREEN = "green" + PINK = "pink" + + +class Toy(BaseModel): + name: str + color: Color + + class Pet(BaseModel): id: int name: str + toys: List[Toy] class PetCollectionView(PydanticView): @@ -53,6 +66,26 @@ async def generated_oas(aiohttp_client, loop) -> web.Application: return await response.json() +async def test_generated_oas_should_have_components_schemas(generated_oas): + assert generated_oas["components"]["schemas"] == { + "Color": { + "description": "An enumeration.", + "enum": ["red", "green", "pink"], + "title": "Color", + "type": "string", + }, + "Toy": { + "properties": { + "color": {"$ref": "#/components/schemas/Color"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name", "color"], + "title": "Toy", + "type": "object", + }, + } + + async def test_generated_oas_should_have_pets_paths(generated_oas): assert "/pets" in generated_oas["paths"] @@ -77,7 +110,7 @@ async def test_pets_route_should_have_get_method(generated_oas): "in": "header", "name": "promo", "required": False, - "schema": {"title": "promo", "format": "uuid", "type": "string"}, + "schema": {"format": "uuid", "title": "promo", "type": "string"}, }, ], "responses": { @@ -86,11 +119,35 @@ async def test_pets_route_should_have_get_method(generated_oas): "application/json": { "schema": { "items": { + "definitions": { + "Color": { + "description": "An enumeration.", + "enum": ["red", "green", "pink"], + "title": "Color", + "type": "string", + }, + "Toy": { + "properties": { + "color": { + "$ref": "#/components/schemas/Color" + }, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name", "color"], + "title": "Toy", + "type": "object", + }, + }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, + "toys": { + "items": {"$ref": "#/components/schemas/Toy"}, + "title": "Toys", + "type": "array", + }, }, - "required": ["id", "name"], + "required": ["id", "name", "toys"], "title": "Pet", "type": "object", }, @@ -110,13 +167,35 @@ async def test_pets_route_should_have_post_method(generated_oas): "content": { "application/json": { "schema": { - "title": "Pet", - "type": "object", + "definitions": { + "Color": { + "description": "An enumeration.", + "enum": ["red", "green", "pink"], + "title": "Color", + "type": "string", + }, + "Toy": { + "properties": { + "color": {"$ref": "#/components/schemas/Color"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name", "color"], + "title": "Toy", + "type": "object", + }, + }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, + "toys": { + "items": {"$ref": "#/components/schemas/Toy"}, + "title": "Toys", + "type": "array", + }, }, - "required": ["id", "name"], + "required": ["id", "name", "toys"], + "title": "Pet", + "type": "object", } } } @@ -126,13 +205,35 @@ async def test_pets_route_should_have_post_method(generated_oas): "content": { "application/json": { "schema": { - "title": "Pet", - "type": "object", + "definitions": { + "Color": { + "description": "An enumeration.", + "enum": ["red", "green", "pink"], + "title": "Color", + "type": "string", + }, + "Toy": { + "properties": { + "color": {"$ref": "#/components/schemas/Color"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name", "color"], + "title": "Toy", + "type": "object", + }, + }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, + "toys": { + "items": {"$ref": "#/components/schemas/Toy"}, + "title": "Toys", + "type": "array", + }, }, - "required": ["id", "name"], + "required": ["id", "name", "toys"], + "title": "Pet", + "type": "object", } } } @@ -174,11 +275,33 @@ async def test_pets_id_route_should_have_get_method(generated_oas): "content": { "application/json": { "schema": { + "definitions": { + "Color": { + "description": "An enumeration.", + "enum": ["red", "green", "pink"], + "title": "Color", + "type": "string", + }, + "Toy": { + "properties": { + "color": {"$ref": "#/components/schemas/Color"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name", "color"], + "title": "Toy", + "type": "object", + }, + }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, + "toys": { + "items": {"$ref": "#/components/schemas/Toy"}, + "title": "Toys", + "type": "array", + }, }, - "required": ["id", "name"], + "required": ["id", "name", "toys"], "title": "Pet", "type": "object", } @@ -204,11 +327,33 @@ async def test_pets_id_route_should_have_put_method(generated_oas): "content": { "application/json": { "schema": { + "definitions": { + "Color": { + "description": "An enumeration.", + "enum": ["red", "green", "pink"], + "title": "Color", + "type": "string", + }, + "Toy": { + "properties": { + "color": {"$ref": "#/components/schemas/Color"}, + "name": {"title": "Name", "type": "string"}, + }, + "required": ["name", "color"], + "title": "Toy", + "type": "object", + }, + }, "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, + "toys": { + "items": {"$ref": "#/components/schemas/Toy"}, + "title": "Toys", + "type": "array", + }, }, - "required": ["id", "name"], + "required": ["id", "name", "toys"], "title": "Pet", "type": "object", }