Compare commits

..

3 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
17 changed files with 392 additions and 114 deletions

View File

@@ -68,6 +68,7 @@ Example:
$ curl -X GET http://127.0.0.1:8080/article?with_comments=a $ curl -X GET http://127.0.0.1:8080/article?with_comments=a
[ [
{ {
"in": "query string",
"loc": [ "loc": [
"with_comments" "with_comments"
], ],
@@ -82,6 +83,7 @@ Example:
$ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}'
[ [
{ {
"in": "body",
"loc": [ "loc": [
"name" "name"
], ],
@@ -116,7 +118,7 @@ Example:
Inject Query String Parameters 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 .. code-block:: python3
@@ -131,7 +133,7 @@ To declare a query parameters, you must declare your argument as simple argument
Inject Request Body 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 .. code-block:: python3

View File

@@ -1,5 +1,5 @@
from .view import PydanticView from .view import PydanticView
__version__ = "1.5.1" __version__ = "1.6.1"
__all__ = ("PydanticView", "__version__") __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. An injector parse HTTP request and inject params to the view.
""" """
@property
@abc.abstractmethod @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 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. Validates and injects the part of URL path inside the view positional args.
""" """
def __init__(self, args_spec: dict): context = "path"
self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec})
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): def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
args_view.extend(self.model(**request.match_info).dict().values()) 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. 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())) self.arg_name, self.model = next(iter(args_spec.items()))
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
@@ -54,7 +68,7 @@ class BodyGetter(AbstractInjector):
except JSONDecodeError: except JSONDecodeError:
raise HTTPBadRequest( raise HTTPBadRequest(
text='{"error": "Malformed JSON"}', content_type="application/json" text='{"error": "Malformed JSON"}', content_type="application/json"
) ) from None
kwargs_view[self.arg_name] = self.model(**body) 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. Validates and injects the query string inside the view kwargs.
""" """
def __init__(self, args_spec: dict): context = "query string"
self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec})
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): def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
kwargs_view.update(self.model(**request.query).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. Validates and injects the HTTP headers inside the view kwargs.
""" """
def __init__(self, args_spec: dict): context = "headers"
self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec})
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): def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()}
kwargs_view.update(self.model(**header).dict()) 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: Analyse function signature and returns 4-tuple:
0 - arguments will be set from the url path 0 - arguments will be set from the url path
1 - argument will be set from the request body. 1 - argument will be set from the request body.
2 - argument will be set from the query string. 2 - argument will be set from the query string.
3 - argument will be set from the HTTP headers. 3 - argument will be set from the HTTP headers.
4 - Default value for each parameters
""" """
path_args = {} path_args = {}
body_args = {} body_args = {}
qs_args = {} qs_args = {}
header_args = {} header_args = {}
defaults = {}
for param_name, param_spec in signature(func).parameters.items(): for param_name, param_spec in signature(func).parameters.items():
if param_name == "self": 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: if param_spec.annotation == param_spec.empty:
raise RuntimeError(f"The parameter {param_name} must have an annotation") 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: if param_spec.kind is param_spec.POSITIONAL_ONLY:
path_args[param_name] = param_spec.annotation path_args[param_name] = param_spec.annotation
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
if is_pydantic_base_model(param_spec.annotation): if is_pydantic_base_model(param_spec.annotation):
body_args[param_name] = 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: else:
raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") 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

@@ -13,13 +13,10 @@ def setup(
apps_to_expose: Iterable[web.Application] = (), apps_to_expose: Iterable[web.Application] = (),
url_prefix: str = "/oas", url_prefix: str = "/oas",
enable: bool = True, enable: bool = True,
raise_validation_errors: bool = False,
): ):
if enable: if enable:
oas_app = web.Application() oas_app = web.Application()
oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) oas_app["apps to expose"] = tuple(apps_to_expose) or (app,)
for a in oas_app["apps to expose"]:
a['raise_validation_errors'] = raise_validation_errors
oas_app["index template"] = jinja2.Template( oas_app["index template"] = jinja2.Template(
resources.read_text("aiohttp_pydantic.oas", "index.j2") resources.read_text("aiohttp_pydantic.oas", "index.j2")
) )

View File

@@ -293,6 +293,15 @@ class Servers:
return Server(spec) 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: class OpenApiSpec3:
def __init__(self): def __init__(self):
self._spec = {"openapi": "3.0.0"} self._spec = {"openapi": "3.0.0"}
@@ -310,10 +319,9 @@ class OpenApiSpec3:
return Paths(self._spec) return Paths(self._spec)
@property @property
def spec(self): def components(self) -> Components:
return self._spec return Components(self._spec)
@property @property
def definitions(self): def spec(self):
self._spec.setdefault('definitions', {}) return self._spec
return self._spec['definitions']

View File

@@ -13,7 +13,7 @@ Example:
from functools import lru_cache from functools import lru_cache
from types import new_class from types import new_class
from typing import Protocol, TypeVar, Optional, Type from typing import Protocol, TypeVar
RespContents = TypeVar("RespContents", covariant=True) RespContents = TypeVar("RespContents", covariant=True)

View File

@@ -1,12 +1,11 @@
import typing import typing
from datetime import date, datetime
from inspect import getdoc from inspect import getdoc
from itertools import count from itertools import count
from typing import List, Type from typing import List, Type
from uuid import UUID
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from aiohttp.web_app import Application from aiohttp.web_app import Application
from pydantic import BaseModel
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
@@ -15,16 +14,6 @@ from ..utils import is_pydantic_base_model
from ..view import PydanticView, is_pydantic_view from ..view import PydanticView, is_pydantic_view
from .typing import is_status_code_type 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_): def _handle_optional(type_):
""" """
@@ -47,21 +36,16 @@ class _OASResponseBuilder:
generate the OAS operation response. generate the OAS operation response.
""" """
def __init__(self, oas_operation, definitions): def __init__(self, oas: OpenApiSpec3, oas_operation):
self._oas_operation = oas_operation self._oas_operation = oas_operation
self._definitions = definitions self._oas = oas
def _process_definitions(self, schema):
if 'definitions' in schema:
for k, v in schema['definitions'].items():
self._definitions[k] = v
return {i:schema[i] for i in schema if i!='definitions'}
def _handle_pydantic_base_model(self, obj): def _handle_pydantic_base_model(self, obj):
if is_pydantic_base_model(obj): if is_pydantic_base_model(obj):
return self._process_definitions(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 {} return {}
def _handle_list(self, obj): def _handle_list(self, obj):
@@ -96,19 +80,27 @@ class _OASResponseBuilder:
def _add_http_method_to_oas( def _add_http_method_to_oas(
oas_path: PathItem, http_method: str, view: Type[PydanticView], definitions: dict oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView]
): ):
http_method = http_method.lower() http_method = http_method.lower()
oas_operation: OperationObject = getattr(oas_path, http_method) oas_operation: OperationObject = getattr(oas_path, http_method)
handler = getattr(view, 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) description = getdoc(handler)
if description: if description:
oas_operation.description = description oas_operation.description = description
if body_args: 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 = { oas_operation.request_body.content = {
"application/json": {"schema": next(iter(body_args.values())).schema()} "application/json": {"schema": body_schema}
} }
indexes = count() indexes = count()
@@ -122,16 +114,20 @@ def _add_http_method_to_oas(
oas_operation.parameters[i].in_ = args_location oas_operation.parameters[i].in_ = args_location
oas_operation.parameters[i].name = name oas_operation.parameters[i].name = name
optional_type = _handle_optional(type_) optional_type = _handle_optional(type_)
if optional_type is None:
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_] attrs = {"__annotations__": {"__root__": type_}}
oas_operation.parameters[i].required = True if name in defaults:
else: attrs["__root__"] = defaults[name]
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type]
oas_operation.parameters[i].required = False 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") return_type = handler.__annotations__.get("return")
if return_type is not None: if return_type is not None:
_OASResponseBuilder(oas_operation, definitions).build(return_type) _OASResponseBuilder(oas, oas_operation).build(return_type)
def generate_oas(apps: List[Application]) -> dict: def generate_oas(apps: List[Application]) -> dict:
@@ -139,19 +135,20 @@ def generate_oas(apps: List[Application]) -> dict:
Generate and return Open Api Specification from PydanticView in application. Generate and return Open Api Specification from PydanticView in application.
""" """
oas = OpenApiSpec3() oas = OpenApiSpec3()
for app in apps: for app in apps:
for resources in app.router.resources(): for resources in app.router.resources():
for resource_route in resources: for resource_route in resources:
if is_pydantic_view(resource_route.handler): if not is_pydantic_view(resource_route.handler):
view: Type[PydanticView] = resource_route.handler continue
info = resource_route.get_info()
path = oas.paths[info.get("path", info.get("formatter"))] view: Type[PydanticView] = resource_route.handler
if resource_route.method == "*": info = resource_route.get_info()
for method_name in view.allowed_methods: path = oas.paths[info.get("path", info.get("formatter"))]
_add_http_method_to_oas(path, method_name, view, oas.definitions) if resource_route.method == "*":
else: for method_name in view.allowed_methods:
_add_http_method_to_oas(path, resource_route.method, view, oas.definitions) _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 return oas.spec
@@ -172,9 +169,6 @@ async def oas_ui(request):
static_url = request.app.router["static"].url_for(filename="") static_url = request.app.router["static"].url_for(filename="")
spec_url = request.app.router["spec"].url_for() spec_url = request.app.router["spec"].url_for()
if request.scheme != request.headers.get('x-forwarded-proto', request.scheme):
request = request.clone(scheme=request.headers['x-forwarded-proto'])
host = request.url.origin() host = request.url.origin()
return Response( return Response(

View File

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

View File

@@ -1,10 +1,17 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import List
class Friend(BaseModel):
name: str
age: str
class Pet(BaseModel): class Pet(BaseModel):
id: int id: int
name: str name: str
age: int age: int
friends: Friend
class Error(BaseModel): class Error(BaseModel):

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
from enum import Enum
from typing import List, Optional, Union from typing import List, Optional, Union
from uuid import UUID from uuid import UUID
@@ -9,9 +10,21 @@ from aiohttp_pydantic import PydanticView, oas
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 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): class Pet(BaseModel):
id: int id: int
name: str name: str
toys: List[Toy]
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
@@ -53,6 +66,26 @@ async def generated_oas(aiohttp_client, loop) -> web.Application:
return await response.json() 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): async def test_generated_oas_should_have_pets_paths(generated_oas):
assert "/pets" in generated_oas["paths"] assert "/pets" in generated_oas["paths"]
@@ -65,19 +98,19 @@ async def test_pets_route_should_have_get_method(generated_oas):
"in": "query", "in": "query",
"name": "format", "name": "format",
"required": True, "required": True,
"schema": {"type": "string"}, "schema": {"title": "format", "type": "string"},
}, },
{ {
"in": "query", "in": "query",
"name": "name", "name": "name",
"required": False, "required": False,
"schema": {"type": "string"}, "schema": {"title": "name", "type": "string"},
}, },
{ {
"in": "header", "in": "header",
"name": "promo", "name": "promo",
"required": False, "required": False,
"schema": {"format": "uuid", "type": "string"}, "schema": {"format": "uuid", "title": "promo", "type": "string"},
}, },
], ],
"responses": { "responses": {
@@ -86,11 +119,35 @@ async def test_pets_route_should_have_get_method(generated_oas):
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "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": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
}, },
@@ -110,13 +167,35 @@ async def test_pets_route_should_have_post_method(generated_oas):
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"title": "Pet", "definitions": {
"type": "object", "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": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "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": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"title": "Pet", "definitions": {
"type": "object", "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": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "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",
} }
} }
} }
@@ -152,7 +253,7 @@ async def test_pets_id_route_should_have_delete_method(generated_oas):
"required": True, "required": True,
"in": "path", "in": "path",
"name": "id", "name": "id",
"schema": {"type": "integer"}, "schema": {"title": "id", "type": "integer"},
} }
], ],
"responses": {"204": {"content": {}}}, "responses": {"204": {"content": {}}},
@@ -166,7 +267,7 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
"in": "path", "in": "path",
"name": "id", "name": "id",
"required": True, "required": True,
"schema": {"type": "integer"}, "schema": {"title": "id", "type": "integer"},
} }
], ],
"responses": { "responses": {
@@ -174,11 +275,33 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
} }
@@ -197,18 +320,40 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
"in": "path", "in": "path",
"name": "id", "name": "id",
"required": True, "required": True,
"schema": {"type": "integer"}, "schema": {"title": "id", "type": "integer"},
} }
], ],
"requestBody": { "requestBody": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
"toys": {
"items": {"$ref": "#/components/schemas/Toy"},
"title": "Toys",
"type": "array",
},
}, },
"required": ["id", "name"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "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): def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID):
pass pass
assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}) assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {})
assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}) assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {})
assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}) assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {})
assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}) 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_qs) == (
{"id": str},
{},
{"page": int},
{},
{},
)
assert _parse_func_signature(path_and_header) == ( assert _parse_func_signature(path_and_header) == (
{"id": str}, {"id": str},
{}, {},
{}, {},
{"auth": UUID}, {"auth": UUID},
{},
) )
assert _parse_func_signature(qs_and_header) == ( assert _parse_func_signature(qs_and_header) == (
{}, {},
{}, {},
{"page": int}, {"page": int},
{"auth": UUID}, {"auth": UUID},
{},
) )
assert _parse_func_signature(path_qs_and_header) == ( assert _parse_func_signature(path_qs_and_header) == (
{"id": str}, {"id": str},
{}, {},
{"page": int}, {"page": int},
{"auth": UUID}, {"auth": UUID},
{},
) )
assert _parse_func_signature(path_body_qs_and_header) == ( assert _parse_func_signature(path_body_qs_and_header) == (
{"id": str}, {"id": str},
{"user": User}, {"user": User},
{"page": int}, {"page": int},
{"auth": UUID}, {"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.status == 400
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "body",
"loc": ["nb_page"], "loc": ["nb_page"],
"msg": "value is not a valid integer", "msg": "value is not a valid integer",
"type": "type_error.integer", "type": "type_error.integer",

View File

@@ -1,5 +1,6 @@
import json import json
from datetime import datetime from datetime import datetime
from enum import Enum
from aiohttp import web 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( async def test_get_article_without_required_header_should_return_an_error_message(
aiohttp_client, loop 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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "headers",
"loc": ["signature_expired"], "loc": ["signature_expired"],
"msg": "field required", "msg": "field required",
"type": "value_error.missing", "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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "headers",
"loc": ["signature_expired"], "loc": ["signature_expired"],
"msg": "invalid datetime format", "msg": "invalid datetime format",
"type": "value_error.datetime", "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.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"signature": "2020-10-04T18:01:00"} 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]}) 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 aiohttp_client, loop
): ):
app = web.Application() 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.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"path": ["1234", "music", 1980]} 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): class ArticleView(PydanticView):
async def get(self, with_comments: bool, age: Optional[int] = None): async def get(
return web.json_response({"with_comments": with_comments, "age": age}) 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( 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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string",
"loc": ["with_comments"], "loc": ["with_comments"],
"msg": "field required", "msg": "field required",
"type": "value_error.missing", "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 resp.content_type == "application/json"
assert await resp.json() == [ assert await resp.json() == [
{ {
"in": "query string",
"loc": ["with_comments"], "loc": ["with_comments"],
"msg": "value could not be parsed to a boolean", "msg": "value could not be parsed to a boolean",
"type": "type_error.bool", "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}) resp = await client.get("/article", params={"with_comments": "yes", "age": 3})
assert resp.status == 200 assert resp.status == 200
assert resp.content_type == "application/json" 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 aiohttp_client, loop
): ):
app = web.Application() 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) client = await aiohttp_client(app)
resp = await client.get("/article", params={"with_comments": "yes"}) 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.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True, "age": None}