Compare commits

7 Commits

Author SHA1 Message Date
Vincent Maillol
25fcac18ec Fix wrong link in OAS components with nested pydantic.BaseModel 2020-11-28 19:46:36 +01:00
Vincent Maillol
f2b16a46b5 Update version 1.6.0 2020-11-28 12:42:06 +01:00
Vincent Maillol
c4c18ee4a1 increase pydantic integration with headers, query string and url path 2020-11-28 12:39:50 +01:00
MAILLOL Vincent
93ec0f6c80 Update README.rst 2020-11-21 22:50:28 +01:00
Vincent Maillol
a6d96d711b Update version 1.5.1 2020-11-21 18:07:04 +01:00
MAILLOL Vincent
8aee135f95 Merge pull request #5 from Maillol/fix_bug_oas_generation
Fix bug query string appear as required in generated Open API specifi…
2020-11-21 17:57:36 +01:00
Vincent Maillol
462d8d8b98 Fix bug query string appear as required in generated Open API specification. 2020-11-21 17:23:01 +01:00
20 changed files with 693 additions and 101 deletions

View File

@@ -8,7 +8,7 @@ Aiohttp pydantic - Aiohttp View to validate and parse request
:target: https://img.shields.io/pypi/v/aiohttp-pydantic
:alt: Latest PyPI package version
.. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/add_code_coverage/graph/badge.svg
.. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg
:target: https://codecov.io/gh/Maillol/aiohttp-pydantic
:alt: codecov.io status for master branch
@@ -68,6 +68,7 @@ Example:
$ curl -X GET http://127.0.0.1:8080/article?with_comments=a
[
{
"in": "query string",
"loc": [
"with_comments"
],
@@ -82,6 +83,7 @@ Example:
$ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}'
[
{
"in": "body",
"loc": [
"name"
],
@@ -116,7 +118,7 @@ Example:
Inject Query String Parameters
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
To declare a query parameters, you must declare your argument as simple argument:
To declare a query parameters, you must declare your argument as a simple argument:
.. code-block:: python3
@@ -131,7 +133,7 @@ To declare a query parameters, you must declare your argument as simple argument
Inject Request Body
~~~~~~~~~~~~~~~~~~~
To declare a body parameters, you must declare your argument as a simple argument annotated with `pydantic Model`_.
To declare a body parameter, you must declare your argument as a simple argument annotated with `pydantic Model`_.
.. code-block:: python3

View File

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

View File

@@ -15,8 +15,16 @@ class AbstractInjector(metaclass=abc.ABCMeta):
An injector parse HTTP request and inject params to the view.
"""
@property
@abc.abstractmethod
def __init__(self, args_spec: dict):
def context(self) -> str:
"""
The name of part of parsed request
i.e "HTTP header", "URL path", ...
"""
@abc.abstractmethod
def __init__(self, args_spec: dict, default_values: dict):
"""
args_spec - ordered mapping: arg_name -> type
"""
@@ -33,8 +41,12 @@ class MatchInfoGetter(AbstractInjector):
Validates and injects the part of URL path inside the view positional args.
"""
def __init__(self, args_spec: dict):
self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec})
context = "path"
def __init__(self, args_spec: dict, default_values: dict):
attrs = {"__annotations__": args_spec}
attrs.update(default_values)
self.model = type("PathModel", (BaseModel,), attrs)
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
args_view.extend(self.model(**request.match_info).dict().values())
@@ -45,7 +57,9 @@ class BodyGetter(AbstractInjector):
Validates and injects the content of request body inside the view kwargs.
"""
def __init__(self, args_spec: dict):
context = "body"
def __init__(self, args_spec: dict, default_values: dict):
self.arg_name, self.model = next(iter(args_spec.items()))
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
@@ -54,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)
@@ -64,8 +78,12 @@ class QueryGetter(AbstractInjector):
Validates and injects the query string inside the view kwargs.
"""
def __init__(self, args_spec: dict):
self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec})
context = "query string"
def __init__(self, args_spec: dict, default_values: dict):
attrs = {"__annotations__": args_spec}
attrs.update(default_values)
self.model = type("QueryModel", (BaseModel,), attrs)
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
kwargs_view.update(self.model(**request.query).dict())
@@ -76,27 +94,33 @@ class HeadersGetter(AbstractInjector):
Validates and injects the HTTP headers inside the view kwargs.
"""
def __init__(self, args_spec: dict):
self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec})
context = "headers"
def __init__(self, args_spec: dict, default_values: dict):
attrs = {"__annotations__": args_spec}
attrs.update(default_values)
self.model = type("HeaderModel", (BaseModel,), attrs)
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()}
kwargs_view.update(self.model(**header).dict())
def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict, dict]:
"""
Analyse function signature and returns 4-tuple:
0 - arguments will be set from the url path
1 - argument will be set from the request body.
2 - argument will be set from the query string.
3 - argument will be set from the HTTP headers.
4 - Default value for each parameters
"""
path_args = {}
body_args = {}
qs_args = {}
header_args = {}
defaults = {}
for param_name, param_spec in signature(func).parameters.items():
if param_name == "self":
@@ -105,8 +129,12 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
if param_spec.annotation == param_spec.empty:
raise RuntimeError(f"The parameter {param_name} must have an annotation")
if param_spec.default is not param_spec.empty:
defaults[param_name] = param_spec.default
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:
if is_pydantic_base_model(param_spec.annotation):
body_args[param_name] = param_spec.annotation
@@ -117,4 +145,4 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
else:
raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters")
return path_args, body_args, qs_args, header_args
return path_args, body_args, qs_args, header_args, defaults

View File

@@ -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):
@@ -265,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"}
@@ -281,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

View File

@@ -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.
"""

View File

@@ -1,9 +1,11 @@
from inspect import getdoc
import typing
from inspect import getdoc
from itertools import count
from typing import List, Type
from aiohttp.web import Response, json_response
from aiohttp.web_app import Application
from pydantic import BaseModel
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
@@ -12,7 +14,20 @@ 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"}
def _handle_optional(type_):
"""
Returns the type wrapped in Optional or None.
>>> _handle_optional(int)
>>> _handle_optional(Optional[str])
<class '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:
@@ -21,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):
@@ -62,43 +80,54 @@ 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)
handler = getattr(view, http_method)
path_args, body_args, qs_args, header_args = _parse_func_signature(handler)
path_args, body_args, qs_args, header_args, defaults = _parse_func_signature(
handler
)
description = getdoc(handler)
if description:
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}
}
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_]}
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_)
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_]}
attrs = {"__annotations__": {"__root__": type_}}
if name in defaults:
attrs["__root__"] = defaults[name]
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_]}
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:
@@ -109,15 +138,17 @@ def generate_oas(apps: List[Application]) -> dict:
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)
if not is_pydantic_view(resource_route.handler):
continue
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(oas, path, method_name, view)
else:
_add_http_method_to_oas(oas, path, resource_route.method, view)
return oas.spec

View File

@@ -9,14 +9,8 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse
from pydantic import ValidationError
from .injectors import (
AbstractInjector,
BodyGetter,
HeadersGetter,
MatchInfoGetter,
QueryGetter,
_parse_func_signature,
)
from .injectors import (AbstractInjector, BodyGetter, HeadersGetter,
MatchInfoGetter, QueryGetter, _parse_func_signature)
class PydanticView(AbstractView):
@@ -50,16 +44,25 @@ class PydanticView(AbstractView):
@staticmethod
def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]:
path_args, body_args, qs_args, header_args = _parse_func_signature(func)
path_args, body_args, qs_args, header_args, defaults = _parse_func_signature(
func
)
injectors = []
def default_value(args: dict) -> dict:
"""
Returns the default values of args.
"""
return {name: defaults[name] for name in args if name in defaults}
if path_args:
injectors.append(MatchInfoGetter(path_args))
injectors.append(MatchInfoGetter(path_args, default_value(path_args)))
if body_args:
injectors.append(BodyGetter(body_args))
injectors.append(BodyGetter(body_args, default_value(body_args)))
if qs_args:
injectors.append(QueryGetter(qs_args))
injectors.append(QueryGetter(qs_args, default_value(qs_args)))
if header_args:
injectors.append(HeadersGetter(header_args))
injectors.append(HeadersGetter(header_args, default_value(header_args)))
return injectors
@@ -83,7 +86,11 @@ def inject_params(
else:
injector.inject(self.request, args, kwargs)
except ValidationError as error:
return json_response(text=error.json(), status=400)
errors = error.errors()
for error in errors:
error["in"] = injector.context
return json_response(data=errors, status=400)
return await handler(self, *args, **kwargs)

View File

@@ -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):

View File

@@ -31,7 +31,7 @@ packages = find:
python_requires = >=3.8
install_requires =
aiohttp
pydantic
pydantic>=1.7
swagger-ui-bundle
[options.extras_require]

View File

@@ -30,6 +30,7 @@ def test_show_oad_of_app(cmd_line, capfd):
"name": "a",
"required": true,
"schema": {
"title": "a",
"type": "integer"
}
}
@@ -44,6 +45,7 @@ def test_show_oad_of_app(cmd_line, capfd):
"name": "b",
"required": true,
"schema": {
"title": "b",
"type": "integer"
}
}
@@ -75,6 +77,7 @@ def test_show_oad_of_sub_app(cmd_line, capfd):
"name": "b",
"required": true,
"schema": {
"title": "b",
"type": "integer"
}
}
@@ -106,6 +109,7 @@ def test_show_oad_of_a_callable(cmd_line, capfd):
"name": "a",
"required": true,
"schema": {
"title": "a",
"type": "integer"
}
}

View File

View File

@@ -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",
}

View File

@@ -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"},
}
}
}

View File

@@ -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()

View File

@@ -1,4 +1,6 @@
from typing import List, Union
from enum import Enum
from typing import List, Optional, Union
from uuid import UUID
import pytest
from aiohttp import web
@@ -8,13 +10,27 @@ 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):
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
"""
@@ -50,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"]
@@ -57,21 +93,65 @@ 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": {"title": "format", "type": "string"},
},
{
"in": "query",
"name": "name",
"required": False,
"schema": {"title": "name", "type": "string"},
},
{
"in": "header",
"name": "promo",
"required": False,
"schema": {"format": "uuid", "title": "promo", "type": "string"},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"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",
},
"type": "array",
}
}
}
@@ -87,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",
}
}
}
@@ -103,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",
}
}
}
@@ -129,7 +253,7 @@ async def test_pets_id_route_should_have_delete_method(generated_oas):
"required": True,
"in": "path",
"name": "id",
"schema": {"type": "integer"},
"schema": {"title": "id", "type": "integer"},
}
],
"responses": {"204": {"content": {}}},
@@ -143,7 +267,7 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
"in": "path",
"name": "id",
"required": True,
"schema": {"type": "integer"},
"schema": {"title": "id", "type": "integer"},
}
],
"responses": {
@@ -151,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",
}
@@ -174,18 +320,40 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
"in": "path",
"name": "id",
"required": True,
"schema": {"type": "integer"},
"schema": {"title": "id", "type": "integer"},
}
],
"requestBody": {
"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",
}

View File

@@ -38,32 +38,42 @@ def test_parse_func_signature():
def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID):
pass
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {})
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {})
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {})
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID})
assert _parse_func_signature(path_and_qs) == ({"id": str}, {}, {"page": int}, {})
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {})
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {})
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {})
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}, {})
assert _parse_func_signature(path_and_qs) == (
{"id": str},
{},
{"page": int},
{},
{},
)
assert _parse_func_signature(path_and_header) == (
{"id": str},
{},
{},
{"auth": UUID},
{},
)
assert _parse_func_signature(qs_and_header) == (
{},
{},
{"page": int},
{"auth": UUID},
{},
)
assert _parse_func_signature(path_qs_and_header) == (
{"id": str},
{},
{"page": int},
{"auth": UUID},
{},
)
assert _parse_func_signature(path_body_qs_and_header) == (
{"id": str},
{"user": User},
{"page": int},
{"auth": UUID},
{},
)

View File

@@ -27,7 +27,12 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes
assert resp.status == 400
assert resp.content_type == "application/json"
assert await resp.json() == [
{"loc": ["name"], "msg": "field required", "type": "value_error.missing"}
{
"in": "body",
"loc": ["name"],
"msg": "field required",
"type": "value_error.missing",
}
]
@@ -43,6 +48,7 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "body",
"loc": ["nb_page"],
"msg": "value is not a valid integer",
"type": "type_error.integer",

View File

@@ -1,5 +1,6 @@
import json
from datetime import datetime
from enum import Enum
from aiohttp import web
@@ -21,6 +22,16 @@ class ArticleView(PydanticView):
)
class FormatEnum(str, Enum):
UTM = "UMT"
MGRS = "MGRS"
class ViewWithEnumType(PydanticView):
async def get(self, *, format: FormatEnum):
return web.json_response({"format": format}, dumps=JSONEncoder().encode)
async def test_get_article_without_required_header_should_return_an_error_message(
aiohttp_client, loop
):
@@ -33,6 +44,7 @@ async def test_get_article_without_required_header_should_return_an_error_messag
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "headers",
"loc": ["signature_expired"],
"msg": "field required",
"type": "value_error.missing",
@@ -52,6 +64,7 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "headers",
"loc": ["signature_expired"],
"msg": "invalid datetime format",
"type": "value_error.datetime",
@@ -87,3 +100,37 @@ async def test_get_article_with_valid_header_containing_hyphen_should_be_returne
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == {"signature": "2020-10-04T18:01:00"}
async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/coord", ViewWithEnumType)
client = await aiohttp_client(app)
resp = await client.get("/coord", headers={"format": "WGS84"})
assert (
await resp.json()
== [
{
"ctx": {"enum_values": ["UMT", "MGRS"]},
"in": "headers",
"loc": ["format"],
"msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'",
"type": "type_error.enum",
}
]
!= {"signature": "2020-10-04T18:01:00"}
)
assert resp.status == 400
assert resp.content_type == "application/json"
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loop):
app = web.Application()
app.router.add_view("/coord", ViewWithEnumType)
client = await aiohttp_client(app)
resp = await client.get("/coord", headers={"format": "UMT"})
assert await resp.json() == {"format": "UMT"}
assert resp.status == 200
assert resp.content_type == "application/json"

View File

@@ -8,7 +8,7 @@ class ArticleView(PydanticView):
return web.json_response({"path": [author_id, tag, date]})
async def test_get_article_without_required_qs_should_return_an_error_message(
async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path(
aiohttp_client, loop
):
app = web.Application()
@@ -19,3 +19,23 @@ async def test_get_article_without_required_qs_should_return_an_error_message(
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == {"path": ["1234", "music", 1980]}
async def test_get_article_with_wrong_path_parameters_should_return_error(
aiohttp_client, loop
):
app = web.Application()
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
client = await aiohttp_client(app)
resp = await client.get("/article/1234/tag/music/before/now")
assert resp.status == 400
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "path",
"loc": ["date"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]

View File

@@ -6,8 +6,12 @@ from aiohttp_pydantic import PydanticView
class ArticleView(PydanticView):
async def get(self, with_comments: bool, age: Optional[int] = None):
return web.json_response({"with_comments": with_comments, "age": age})
async def get(
self, with_comments: bool, age: Optional[int] = None, nb_items: int = 7
):
return web.json_response(
{"with_comments": with_comments, "age": age, "nb_items": nb_items}
)
async def test_get_article_without_required_qs_should_return_an_error_message(
@@ -22,6 +26,7 @@ async def test_get_article_without_required_qs_should_return_an_error_message(
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "query string",
"loc": ["with_comments"],
"msg": "field required",
"type": "value_error.missing",
@@ -41,6 +46,7 @@ async def test_get_article_with_wrong_qs_type_should_return_an_error_message(
assert resp.content_type == "application/json"
assert await resp.json() == [
{
"in": "query string",
"loc": ["with_comments"],
"msg": "value could not be parsed to a boolean",
"type": "type_error.bool",
@@ -59,10 +65,10 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
resp = await client.get("/article", params={"with_comments": "yes", "age": 3})
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True, "age": 3}
assert await resp.json() == {"with_comments": True, "age": 3, "nb_items": 7}
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none(
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value(
aiohttp_client, loop
):
app = web.Application()
@@ -71,6 +77,6 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none
client = await aiohttp_client(app)
resp = await client.get("/article", params={"with_comments": "yes"})
assert await resp.json() == {"with_comments": True, "age": None, "nb_items": 7}
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True, "age": None}