diff --git a/aiohttp_pydantic/oas/struct.py b/aiohttp_pydantic/oas/struct.py index 6751c3f..74ad9cc 100644 --- a/aiohttp_pydantic/oas/struct.py +++ b/aiohttp_pydantic/oas/struct.py @@ -1,3 +1,7 @@ +""" +Utility to write Open Api Specifications using the Python language. +""" + from typing import Union @@ -7,7 +11,7 @@ class Info: @property def title(self): - return self._spec["title"] + return self._spec.get("title") @title.setter def title(self, title): @@ -15,7 +19,7 @@ class Info: @property def description(self): - return self._spec["description"] + return self._spec.get("description") @description.setter def description(self, description): @@ -23,12 +27,20 @@ class Info: @property def version(self): - return self._spec["version"] + return self._spec.get("version") @version.setter def version(self, version): self._spec["version"] = version + @property + def terms_of_service(self): + return self._spec.get("termsOfService") + + @terms_of_service.setter + def terms_of_service(self, terms_of_service): + self._spec["termsOfService"] = terms_of_service + class RequestBody: def __init__(self, spec: dict): @@ -43,8 +55,8 @@ class RequestBody: self._spec["description"] = description @property - def required(self): - return self._spec["required"] + def required(self) -> bool: + return self._spec.get("required", False) @required.setter def required(self, required: bool): @@ -220,6 +232,22 @@ class PathItem: def trace(self) -> OperationObject: return OperationObject(self._spec.setdefault("trace", {})) + @property + def description(self) -> str: + return self._spec["description"] + + @description.setter + def description(self, description: str): + self._spec["description"] = description + + @property + def summary(self) -> str: + return self._spec["summary"] + + @summary.setter + def summary(self, summary: str): + self._spec["summary"] = summary + class Paths: def __init__(self, spec: dict): @@ -244,7 +272,7 @@ class Server: @property def description(self) -> str: - return self._spec["url"] + return self._spec["description"] @description.setter def description(self, description: str): diff --git a/aiohttp_pydantic/oas/typing.py b/aiohttp_pydantic/oas/typing.py index 5be1cd7..a5f4708 100644 --- a/aiohttp_pydantic/oas/typing.py +++ b/aiohttp_pydantic/oas/typing.py @@ -13,7 +13,7 @@ Example: from functools import lru_cache from types import new_class -from typing import Protocol, TypeVar +from typing import Protocol, TypeVar, Optional, Type RespContents = TypeVar("RespContents", covariant=True) @@ -24,9 +24,10 @@ _status_code = frozenset(f"r{code}" for code in range(100, 600)) def _make_status_code_type(status_code): if status_code in _status_code: return new_class(status_code, (Protocol[RespContents],)) + return None -def is_status_code_type(obj): +def is_status_code_type(obj) -> bool: """ Return True if obj is a status code type such as _200 or _404. """ diff --git a/aiohttp_pydantic/oas/view.py b/aiohttp_pydantic/oas/view.py index 4c03ff0..de1f222 100644 --- a/aiohttp_pydantic/oas/view.py +++ b/aiohttp_pydantic/oas/view.py @@ -1,6 +1,9 @@ -from inspect import getdoc 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 @@ -12,7 +15,30 @@ 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: "number", str: "string", int: "integer"} +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_): + """ + Returns the type wrapped in Optional or None. + + >>> _handle_optional(int) + >>> _handle_optional(Optional[str]) + + """ + if typing.get_origin(type_) is typing.Union: + args = typing.get_args(type_) + if len(args) == 2 and type(None) in args: + return next(iter(set(args) - {type(None)})) + return None class _OASResponseBuilder: @@ -77,24 +103,23 @@ def _add_http_method_to_oas( "application/json": {"schema": next(iter(body_args.values())).schema()} } - i = 0 - for i, (name, type_) in enumerate(path_args.items()): - oas_operation.parameters[i].required = True - oas_operation.parameters[i].in_ = "path" - oas_operation.parameters[i].name = name - oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} - - for i, (name, type_) in enumerate(qs_args.items(), i + 1): - oas_operation.parameters[i].required = False - oas_operation.parameters[i].in_ = "query" - oas_operation.parameters[i].name = name - oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} - - for i, (name, type_) in enumerate(header_args.items(), i + 1): - oas_operation.parameters[i].required = False - oas_operation.parameters[i].in_ = "header" - oas_operation.parameters[i].name = name - oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} + indexes = count() + for args_location, args in ( + ("path", path_args.items()), + ("query", qs_args.items()), + ("header", header_args.items()), + ): + for name, type_ in args: + i = next(indexes) + oas_operation.parameters[i].in_ = args_location + oas_operation.parameters[i].name = name + optional_type = _handle_optional(type_) + if optional_type is None: + oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_] + oas_operation.parameters[i].required = True + else: + oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type] + oas_operation.parameters[i].required = False return_type = handler.__annotations__.get("return") if return_type is not None: diff --git a/tests/test_oas/test_struct/__init__.py b/tests/test_oas/test_struct/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oas/test_struct/test_info.py b/tests/test_oas/test_struct/test_info.py new file mode 100644 index 0000000..bcd1bf6 --- /dev/null +++ b/tests/test_oas/test_struct/test_info.py @@ -0,0 +1,54 @@ +import pytest + +from aiohttp_pydantic.oas.struct import OpenApiSpec3 + + +def test_info_title(): + oas = OpenApiSpec3() + assert oas.info.title is None + oas.info.title = "Info Title" + assert oas.info.title == "Info Title" + assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"} + + +def test_info_description(): + oas = OpenApiSpec3() + assert oas.info.description is None + oas.info.description = "info description" + assert oas.info.description == "info description" + assert oas.spec == {"info": {"description": "info description"}, "openapi": "3.0.0"} + + +def test_info_version(): + oas = OpenApiSpec3() + assert oas.info.version is None + oas.info.version = "3.14" + assert oas.info.version == "3.14" + assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"} + + +def test_info_terms_of_service(): + oas = OpenApiSpec3() + assert oas.info.terms_of_service is None + oas.info.terms_of_service = "http://example.com/terms/" + assert oas.info.terms_of_service == "http://example.com/terms/" + assert oas.spec == { + "info": {"termsOfService": "http://example.com/terms/"}, + "openapi": "3.0.0", + } + + +@pytest.mark.skip("Not yet implemented") +def test_info_license(): + oas = OpenApiSpec3() + oas.info.license.name = "Apache 2.0" + oas.info.license.url = "https://www.apache.org/licenses/LICENSE-2.0.html" + assert oas.spec == { + "info": { + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html", + } + }, + "openapi": "3.0.0", + } diff --git a/tests/test_oas/test_struct/test_paths.py b/tests/test_oas/test_struct/test_paths.py new file mode 100644 index 0000000..7db09f3 --- /dev/null +++ b/tests/test_oas/test_struct/test_paths.py @@ -0,0 +1,124 @@ +from aiohttp_pydantic.oas.struct import OpenApiSpec3 + + +def test_paths_description(): + oas = OpenApiSpec3() + oas.paths["/users/{id}"].description = "This route ..." + assert oas.spec == { + "openapi": "3.0.0", + "paths": {"/users/{id}": {"description": "This route ..."}}, + } + + +def test_paths_get(): + oas = OpenApiSpec3() + oas.paths["/users/{id}"].get + assert oas.spec == {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {}}}} + + +def test_paths_operation_description(): + oas = OpenApiSpec3() + operation = oas.paths["/users/{id}"].get + operation.description = "Long descriptions ..." + assert oas.spec == { + "openapi": "3.0.0", + "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, + } + + +def test_paths_operation_summary(): + oas = OpenApiSpec3() + operation = oas.paths["/users/{id}"].get + operation.summary = "Updates a pet in the store with form data" + assert oas.spec == { + "openapi": "3.0.0", + "paths": { + "/users/{id}": { + "get": {"summary": "Updates a pet in the store with form data"} + } + }, + } + + +def test_paths_operation_parameters(): + oas = OpenApiSpec3() + operation = oas.paths["/users/{petId}"].get + parameter = operation.parameters[0] + parameter.name = "petId" + parameter.description = "ID of pet that needs to be updated" + parameter.in_ = "path" + parameter.required = True + + assert oas.spec == { + "openapi": "3.0.0", + "paths": { + "/users/{petId}": { + "get": { + "parameters": [ + { + "description": "ID of pet that needs to be updated", + "in": "path", + "name": "petId", + "required": True, + } + ] + } + } + }, + } + + +def test_paths_operation_requestBody(): + oas = OpenApiSpec3() + request_body = oas.paths["/users/{petId}"].get.request_body + request_body.description = "user to add to the system" + request_body.content = { + "application/json": { + "schema": {"$ref": "#/components/schemas/User"}, + "examples": { + "user": { + "summary": "User Example", + "externalValue": "http://foo.bar/examples/user-example.json", + } + }, + } + } + request_body.required = True + assert oas.spec == { + "openapi": "3.0.0", + "paths": { + "/users/{petId}": { + "get": { + "requestBody": { + "content": { + "application/json": { + "examples": { + "user": { + "externalValue": "http://foo.bar/examples/user-example.json", + "summary": "User Example", + } + }, + "schema": {"$ref": "#/components/schemas/User"}, + } + }, + "description": "user to add to the system", + "required": True, + } + } + } + }, + } + + +def test_paths_operation_responses(): + oas = OpenApiSpec3() + response = oas.paths["/users/{petId}"].get.responses[200] + response.description = "A complex object array response" + response.content = { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/VeryComplexType"}, + } + } + } diff --git a/tests/test_oas/test_struct/test_servers.py b/tests/test_oas/test_struct/test_servers.py new file mode 100644 index 0000000..b8e0406 --- /dev/null +++ b/tests/test_oas/test_struct/test_servers.py @@ -0,0 +1,36 @@ +import pytest + +from aiohttp_pydantic.oas.struct import OpenApiSpec3 + + +def test_sever_url(): + oas = OpenApiSpec3() + oas.servers[0].url = "https://development.gigantic-server.com/v1" + oas.servers[1].url = "https://development.gigantic-server.com/v2" + assert oas.spec == { + "openapi": "3.0.0", + "servers": [ + {"url": "https://development.gigantic-server.com/v1"}, + {"url": "https://development.gigantic-server.com/v2"}, + ], + } + + +def test_sever_description(): + oas = OpenApiSpec3() + oas.servers[0].url = "https://development.gigantic-server.com/v1" + oas.servers[0].description = "Development server" + assert oas.spec == { + "openapi": "3.0.0", + "servers": [ + { + "url": "https://development.gigantic-server.com/v1", + "description": "Development server", + } + ], + } + + +@pytest.mark.skip("Not yet implemented") +def test_sever_variables(): + oas = OpenApiSpec3() diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index f3017a8..a789435 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -1,4 +1,5 @@ -from typing import List, Union +from typing import List, Optional, Union +from uuid import UUID import pytest from aiohttp import web @@ -14,7 +15,9 @@ class Pet(BaseModel): class PetCollectionView(PydanticView): - async def get(self) -> r200[List[Pet]]: + async def get( + self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None + ) -> r200[List[Pet]]: """ Get a list of pets """ @@ -57,21 +60,41 @@ async def test_generated_oas_should_have_pets_paths(generated_oas): async def test_pets_route_should_have_get_method(generated_oas): assert generated_oas["paths"]["/pets"]["get"] == { "description": "Get a list of pets", + "parameters": [ + { + "in": "query", + "name": "format", + "required": True, + "schema": {"type": "string"}, + }, + { + "in": "query", + "name": "name", + "required": False, + "schema": {"type": "string"}, + }, + { + "in": "header", + "name": "promo", + "required": False, + "schema": {"format": "uuid", "type": "string"}, + }, + ], "responses": { "200": { "content": { "application/json": { "schema": { - "type": "array", "items": { - "title": "Pet", - "type": "object", "properties": { "id": {"title": "Id", "type": "integer"}, "name": {"title": "Name", "type": "string"}, }, "required": ["id", "name"], + "title": "Pet", + "type": "object", }, + "type": "array", } } }