Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ad2c111fa1 | ||
|
5058b58991 | ||
|
23f08ad5a5 | ||
|
77cef551a1 | ||
|
5c5a701daa | ||
|
5ead48ec92 | ||
|
1f2ac30106 | ||
|
483b457b14 | ||
|
3126c2fd2e | ||
|
a45101637c | ||
|
937c09e2b7 | ||
|
82c638c1e0 | ||
|
7ce5e5d0d4 | ||
|
93e391b7b2 | ||
|
a94c9d4863 | ||
|
26fd6fa19f | ||
|
be944ac98e | ||
|
b896020a4f | ||
|
ba0530d6b1 | ||
|
83739c7c8e | ||
|
1dd98d2752 | ||
|
207204fe53 | ||
|
7618066b7f | ||
|
554e76ce51 | ||
|
2c51e9d929 | ||
|
ce341f8611 | ||
|
3529809970 | ||
|
1f320c1ad8 | ||
|
c4b5c20ff4 | ||
|
69141302cf | ||
|
df2ef1adc0 | ||
|
76dd0106be | ||
|
9d488db276 | ||
|
4d7e5b0384 | ||
|
6c154c76ff | ||
|
cd3a48c27a | ||
|
52bb0699e6 | ||
|
1181e2fc47 | ||
|
c32da605d0 | ||
|
40dfded213 | ||
|
0e991070a5 | ||
|
bf34914a8a | ||
|
4aee715e48 | ||
|
c649905e69 | ||
|
4015c60cfa | ||
|
a1dcc544cf | ||
|
f278629217 | ||
|
4e8fb95c52 | ||
|
27c0d76e16 | ||
|
16ba8caa5b | ||
|
53357214a8 | ||
|
cdcd526fb4 |
24
.gitea/workflows/release.yml
Normal file
24
.gitea/workflows/release.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
name: Release
|
||||||
|
run-name: ${{ gitea.actor }} is runs ci pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
packaging:
|
||||||
|
name: Distribution
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
- name: Update version
|
||||||
|
run: VSN=${{ gitea.ref_name }} && sed -i -e "s/1.12.1/${VSN:1}/g" aiohttp_pydantic/__init__.py
|
||||||
|
- name: Install invoke
|
||||||
|
run: python -m pip install setuptools wheel invoke
|
||||||
|
- name: Push to PyPi
|
||||||
|
run: invoke upload --pypi-user ${{ secrets.REPO_USER }} --pypi-password ${{ secrets.REPO_PASS }} --pypi-url https://git.ahax86.ru/api/packages/pub/pypi
|
12
.gitlab-ci.yml
Normal file
12
.gitlab-ci.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
stages:
|
||||||
|
- package
|
||||||
|
|
||||||
|
publish-pypi:
|
||||||
|
stage: package
|
||||||
|
image: python:3.11
|
||||||
|
script:
|
||||||
|
- sed -i -e "s/1.12.1/${CI_COMMIT_TAG:1}/g" aiohttp_pydantic/__init__.py
|
||||||
|
- pip install -U setuptools wheel pip; pip install invoke
|
||||||
|
- invoke upload --pypi-user ${PYPI_REPO_USER} --pypi-password ${PYPI_REPO_PASSWORD} --pypi-url ${PYPI_REPO_URL}
|
||||||
|
only:
|
||||||
|
- tags
|
@ -56,7 +56,7 @@ class MatchInfoGetter(AbstractInjector):
|
|||||||
self.model = type("PathModel", (BaseModel,), attrs)
|
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).model_dump().values())
|
||||||
|
|
||||||
|
|
||||||
class BodyGetter(AbstractInjector):
|
class BodyGetter(AbstractInjector):
|
||||||
@ -68,7 +68,10 @@ class BodyGetter(AbstractInjector):
|
|||||||
|
|
||||||
def __init__(self, args_spec: dict, default_values: dict):
|
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()))
|
||||||
self._expect_object = self.model.schema()["type"] == "object"
|
schema = self.model.model_json_schema()
|
||||||
|
if "type" not in schema:
|
||||||
|
schema["type"] = "object"
|
||||||
|
self._expect_object = schema["type"] == "object"
|
||||||
|
|
||||||
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||||
try:
|
try:
|
||||||
@ -82,7 +85,7 @@ class BodyGetter(AbstractInjector):
|
|||||||
# to a dict. Prevent this by requiring the body to be a dict for object models.
|
# to a dict. Prevent this by requiring the body to be a dict for object models.
|
||||||
if self._expect_object and not isinstance(body, dict):
|
if self._expect_object and not isinstance(body, dict):
|
||||||
raise HTTPBadRequest(
|
raise HTTPBadRequest(
|
||||||
text='[{"in": "body", "loc": ["__root__"], "msg": "value is not a '
|
text='[{"loc_in": "body", "loc": ["root"], "msg": "value is not a '
|
||||||
'valid dict", "type": "type_error.dict"}]',
|
'valid dict", "type": "type_error.dict"}]',
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
) from None
|
) from None
|
||||||
@ -117,7 +120,7 @@ class QueryGetter(AbstractInjector):
|
|||||||
|
|
||||||
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||||
data = self._query_to_dict(request.query)
|
data = self._query_to_dict(request.query)
|
||||||
cleaned = self.model(**data).dict()
|
cleaned = self.model(**data).model_dump()
|
||||||
for group_name, (group_cls, group_attrs) in self._groups.items():
|
for group_name, (group_cls, group_attrs) in self._groups.items():
|
||||||
group = group_cls()
|
group = group_cls()
|
||||||
for attr_name in group_attrs:
|
for attr_name in group_attrs:
|
||||||
@ -163,7 +166,7 @@ class HeadersGetter(AbstractInjector):
|
|||||||
|
|
||||||
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()}
|
||||||
cleaned = self.model(**header).dict()
|
cleaned = self.model(**header).model_dump()
|
||||||
for group_name, (group_cls, group_attrs) in self._groups.items():
|
for group_name, (group_cls, group_attrs) in self._groups.items():
|
||||||
group = group_cls()
|
group = group_cls()
|
||||||
for attr_name in group_attrs:
|
for attr_name in group_attrs:
|
||||||
|
@ -15,11 +15,12 @@ def setup(
|
|||||||
enable: bool = True,
|
enable: bool = True,
|
||||||
version_spec: Optional[str] = None,
|
version_spec: Optional[str] = None,
|
||||||
title_spec: Optional[str] = None,
|
title_spec: Optional[str] = None,
|
||||||
|
custom_template: Optional[jinja2.Template] = None
|
||||||
):
|
):
|
||||||
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,)
|
||||||
oas_app["index template"] = jinja2.Template(
|
oas_app["index template"] = custom_template or jinja2.Template(
|
||||||
resources.read_text("aiohttp_pydantic.oas", "index.j2")
|
resources.read_text("aiohttp_pydantic.oas", "index.j2")
|
||||||
)
|
)
|
||||||
oas_app["version_spec"] = version_spec
|
oas_app["version_spec"] = version_spec
|
||||||
|
@ -120,6 +120,19 @@ def tags(docstring: str) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def operation_id(docstring: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract the "OperationId:" block of the docstring.
|
||||||
|
"""
|
||||||
|
iterator = LinesIterator(docstring)
|
||||||
|
for line in iterator:
|
||||||
|
if re.fullmatch("operation_?id\\s*:.*", line, re.IGNORECASE):
|
||||||
|
iterator.rewind()
|
||||||
|
return line.split(":")[1].strip(' ')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def operation(docstring: str) -> str:
|
def operation(docstring: str) -> str:
|
||||||
"""
|
"""
|
||||||
Extract all docstring except the "Status Code:" block.
|
Extract all docstring except the "Status Code:" block.
|
||||||
@ -127,7 +140,7 @@ def operation(docstring: str) -> str:
|
|||||||
lines = LinesIterator(docstring)
|
lines = LinesIterator(docstring)
|
||||||
ret = []
|
ret = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE):
|
if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*|operation_?id\\s*:.*", line, re.IGNORECASE):
|
||||||
lines.rewind()
|
lines.rewind()
|
||||||
for _ in _i_extract_block(lines):
|
for _ in _i_extract_block(lines):
|
||||||
pass
|
pass
|
||||||
|
@ -1,45 +1,27 @@
|
|||||||
{# This updated file is part of swagger_ui_bundle (https://github.com/dtkav/swagger_ui_bundle) #}
|
{# This updated file is part of swagger_ui_bundle (https://github.com/bartsanchez/swagger_ui_bundle) #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{{ title | default('Swagger UI') }}</title>
|
<title>{{ title | default('Swagger UI') }}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" >
|
<link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/index.css" />
|
||||||
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" />
|
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" />
|
||||||
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" />
|
<link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" />
|
||||||
<style>
|
|
||||||
html
|
|
||||||
{
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: -moz-scrollbars-vertical;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*:before,
|
|
||||||
*:after
|
|
||||||
{
|
|
||||||
box-sizing: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
body
|
|
||||||
{
|
|
||||||
margin:0;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="swagger-ui"></div>
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||||
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js"> </script>
|
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||||
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js"> </script>
|
|
||||||
<script>
|
<script>
|
||||||
window.onload = function() {
|
window.onload = function() {
|
||||||
// Begin Swagger UI call region
|
// Begin Swagger UI call region
|
||||||
const ui = SwaggerUIBundle({
|
const ui = SwaggerUIBundle({
|
||||||
url: "{{ openapi_spec_url }}",
|
url: "{{ openapi_spec_url }}",
|
||||||
|
{% if urls is defined %}
|
||||||
|
urls: {{ urls|tojson|safe }},
|
||||||
|
{% endif %}
|
||||||
validatorUrl: {{ validatorUrl | default('null') }},
|
validatorUrl: {{ validatorUrl | default('null') }},
|
||||||
{% if configUrl is defined %}
|
{% if configUrl is defined %}
|
||||||
configUrl: "{{ configUrl }}",
|
configUrl: "{{ configUrl }}",
|
||||||
@ -54,16 +36,15 @@
|
|||||||
SwaggerUIBundle.plugins.DownloadUrl
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
],
|
],
|
||||||
layout: "StandaloneLayout"
|
layout: "StandaloneLayout"
|
||||||
})
|
});
|
||||||
{% if initOAuth is defined %}
|
{% if initOAuth is defined %}
|
||||||
ui.initOAuth(
|
ui.initOAuth(
|
||||||
{{ initOAuth|tojson|safe }}
|
{{ initOAuth|tojson|safe }}
|
||||||
)
|
)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
// End Swagger UI call region
|
// End Swagger UI call region
|
||||||
|
window.ui = ui;
|
||||||
window.ui = ui
|
};
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -207,6 +207,17 @@ class OperationObject:
|
|||||||
else:
|
else:
|
||||||
self._spec.pop("tags", None)
|
self._spec.pop("tags", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation_id(self) -> str | None:
|
||||||
|
return self._spec.get("operationId", None)
|
||||||
|
|
||||||
|
@operation_id.setter
|
||||||
|
def operation_id(self, operation_id: str | None) -> None:
|
||||||
|
if operation_id:
|
||||||
|
self._spec["operationId"] = operation_id
|
||||||
|
else:
|
||||||
|
self._spec.pop("operationId", None)
|
||||||
|
|
||||||
|
|
||||||
class PathItem:
|
class PathItem:
|
||||||
def __init__(self, spec: dict):
|
def __init__(self, spec: dict):
|
||||||
|
@ -5,7 +5,7 @@ from typing import List, Type, Optional, get_type_hints
|
|||||||
|
|
||||||
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 pydantic import BaseModel, RootModel
|
||||||
|
|
||||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
||||||
from . import docstring_parser
|
from . import docstring_parser
|
||||||
@ -32,9 +32,10 @@ class _OASResponseBuilder:
|
|||||||
response_schema = obj.schema(
|
response_schema = obj.schema(
|
||||||
ref_template="#/components/schemas/{model}"
|
ref_template="#/components/schemas/{model}"
|
||||||
).copy()
|
).copy()
|
||||||
if def_sub_schemas := response_schema.pop("definitions", None):
|
if def_sub_schemas := response_schema.pop("$defs", None):
|
||||||
self._oas.components.schemas.update(def_sub_schemas)
|
self._oas.components.schemas.update(def_sub_schemas)
|
||||||
return response_schema
|
self._oas.components.schemas.update({response_schema['title']: response_schema})
|
||||||
|
return {'$ref': f'#/components/schemas/{response_schema["title"]}'}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _handle_list(self, obj):
|
def _handle_list(self, obj):
|
||||||
@ -87,6 +88,7 @@ def _add_http_method_to_oas(
|
|||||||
if description:
|
if description:
|
||||||
oas_operation.description = docstring_parser.operation(description)
|
oas_operation.description = docstring_parser.operation(description)
|
||||||
oas_operation.tags = docstring_parser.tags(description)
|
oas_operation.tags = docstring_parser.tags(description)
|
||||||
|
oas_operation.operation_id = docstring_parser.operation_id(description)
|
||||||
status_code_descriptions = docstring_parser.status_code(description)
|
status_code_descriptions = docstring_parser.status_code(description)
|
||||||
else:
|
else:
|
||||||
status_code_descriptions = {}
|
status_code_descriptions = {}
|
||||||
@ -97,7 +99,7 @@ def _add_http_method_to_oas(
|
|||||||
.schema(ref_template="#/components/schemas/{model}")
|
.schema(ref_template="#/components/schemas/{model}")
|
||||||
.copy()
|
.copy()
|
||||||
)
|
)
|
||||||
if def_sub_schemas := body_schema.pop("definitions", None):
|
if def_sub_schemas := body_schema.pop("$defs", None):
|
||||||
oas.components.schemas.update(def_sub_schemas)
|
oas.components.schemas.update(def_sub_schemas)
|
||||||
|
|
||||||
oas_operation.request_body.content = {
|
oas_operation.request_body.content = {
|
||||||
@ -115,17 +117,24 @@ 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
|
||||||
|
|
||||||
attrs = {"__annotations__": {"__root__": type_}}
|
attrs = {"__annotations__": {"root": type_}}
|
||||||
if name in defaults:
|
if name in defaults:
|
||||||
attrs["__root__"] = defaults[name]
|
attrs["root"] = defaults[name]
|
||||||
oas_operation.parameters[i].required = False
|
oas_operation.parameters[i].required = False
|
||||||
else:
|
else:
|
||||||
oas_operation.parameters[i].required = True
|
oas_operation.parameters[i].required = True
|
||||||
|
|
||||||
oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema(
|
oas_operation.parameters[i].schema = type(name, (RootModel,), attrs).schema(
|
||||||
ref_template="#/components/schemas/{model}"
|
ref_template="#/components/schemas/{model}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if 'description' in oas_operation.parameters[i].schema:
|
||||||
|
oas_operation.parameters[i].description = oas_operation.parameters[i].schema['description']
|
||||||
|
|
||||||
|
# move definitions
|
||||||
|
if def_sub_schemas := oas_operation.parameters[i].schema.pop("$defs", None):
|
||||||
|
oas.components.schemas.update(def_sub_schemas)
|
||||||
|
|
||||||
return_type = get_type_hints(handler).get("return")
|
return_type = get_type_hints(handler).get("return")
|
||||||
if return_type is not None:
|
if return_type is not None:
|
||||||
_OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(
|
_OASResponseBuilder(oas, oas_operation, status_code_descriptions).build(
|
||||||
@ -185,6 +194,8 @@ 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(
|
||||||
|
@ -10,6 +10,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 pydantic_core import ErrorDetails
|
||||||
|
|
||||||
from .injectors import (
|
from .injectors import (
|
||||||
AbstractInjector,
|
AbstractInjector,
|
||||||
BodyGetter,
|
BodyGetter,
|
||||||
@ -22,6 +24,10 @@ from .injectors import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PydanticValidationError(ErrorDetails):
|
||||||
|
loc_in: CONTEXT
|
||||||
|
|
||||||
|
|
||||||
class PydanticView(AbstractView):
|
class PydanticView(AbstractView):
|
||||||
"""
|
"""
|
||||||
An AIOHTTP View that validate request using function annotations.
|
An AIOHTTP View that validate request using function annotations.
|
||||||
@ -91,7 +97,7 @@ class PydanticView(AbstractView):
|
|||||||
return injectors
|
return injectors
|
||||||
|
|
||||||
async def on_validation_error(
|
async def on_validation_error(
|
||||||
self, exception: ValidationError, context: CONTEXT
|
self, exception: ValidationError, context: CONTEXT
|
||||||
) -> StreamResponse:
|
) -> StreamResponse:
|
||||||
"""
|
"""
|
||||||
This method is a hook to intercept ValidationError.
|
This method is a hook to intercept ValidationError.
|
||||||
@ -101,14 +107,13 @@ class PydanticView(AbstractView):
|
|||||||
"headers", "path" or "query string"
|
"headers", "path" or "query string"
|
||||||
"""
|
"""
|
||||||
errors = exception.errors()
|
errors = exception.errors()
|
||||||
for error in errors:
|
own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors]
|
||||||
error["in"] = context
|
|
||||||
|
|
||||||
return json_response(data=errors, status=400)
|
return json_response(data=own_errors, status=400)
|
||||||
|
|
||||||
|
|
||||||
def inject_params(
|
def inject_params(
|
||||||
handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]]
|
handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]]
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Decorator to unpack the query string, route path, body and http header in
|
Decorator to unpack the query string, route path, body and http header in
|
||||||
@ -146,6 +151,7 @@ def is_pydantic_view(obj) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
"PydanticValidationError",
|
||||||
"AbstractInjector",
|
"AbstractInjector",
|
||||||
"BodyGetter",
|
"BodyGetter",
|
||||||
"HeadersGetter",
|
"HeadersGetter",
|
||||||
|
@ -4,3 +4,6 @@ requires = [
|
|||||||
"wheel",
|
"wheel",
|
||||||
]
|
]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
@ -1,42 +1,12 @@
|
|||||||
async-timeout==3.0.1
|
aiohttp==3.8.6
|
||||||
attrs==21.2.0
|
pydantic==2.5.1
|
||||||
bleach==4.0.0
|
jinja2==3.1.2
|
||||||
certifi==2021.5.30
|
swagger-4-ui-bundle==0.0.4
|
||||||
cffi==1.14.6
|
pytest==7.4.3
|
||||||
chardet==4.0.0
|
pytest-aiohttp==1.0.5
|
||||||
charset-normalizer==2.0.4
|
pytest-asyncio==0.21.1
|
||||||
codecov==2.1.11
|
pytest-cov==4.1.0
|
||||||
colorama==0.4.4
|
readme-renderer==42.0
|
||||||
coverage==5.5
|
codecov==2.1.13
|
||||||
cryptography==3.4.7
|
twine==4.0.2
|
||||||
docutils==0.17.1
|
importlib-metadata==7.1.0
|
||||||
idna==3.2
|
|
||||||
importlib-metadata==4.6.3
|
|
||||||
iniconfig==1.1.1
|
|
||||||
jeepney==0.7.1
|
|
||||||
keyring==23.0.1
|
|
||||||
multidict==5.1.0
|
|
||||||
packaging==21.0
|
|
||||||
pkginfo==1.7.1
|
|
||||||
pluggy==0.13.1
|
|
||||||
py==1.10.0
|
|
||||||
pycparser==2.20
|
|
||||||
Pygments==2.9.0
|
|
||||||
pyparsing==2.4.7
|
|
||||||
pytest==6.1.2
|
|
||||||
pytest-aiohttp==0.3.0
|
|
||||||
pytest-cov==2.10.1
|
|
||||||
readme-renderer==29.0
|
|
||||||
requests==2.26.0
|
|
||||||
requests-toolbelt==0.9.1
|
|
||||||
rfc3986==1.5.0
|
|
||||||
SecretStorage==3.3.1
|
|
||||||
six==1.16.0
|
|
||||||
toml==0.10.2
|
|
||||||
tqdm==4.62.0
|
|
||||||
twine==3.4.2
|
|
||||||
typing-extensions==3.10.0.0
|
|
||||||
urllib3==1.26.6
|
|
||||||
webencodings==0.5.1
|
|
||||||
yarl==1.6.3
|
|
||||||
zipp==3.5.0
|
|
@ -1,23 +1,9 @@
|
|||||||
async-timeout==3.0.1
|
aiohttp==3.8.6
|
||||||
attrs==21.2.0
|
pydantic==2.5.1
|
||||||
bleach==4.0.0
|
jinja2==3.1.2
|
||||||
chardet==4.0.0
|
swagger-4-ui-bundle==0.0.4
|
||||||
coverage==5.5
|
pytest==7.4.3
|
||||||
docutils==0.17.1
|
pytest-aiohttp==1.0.5
|
||||||
idna==3.2
|
pytest-asyncio==0.21.1
|
||||||
iniconfig==1.1.1
|
pytest-cov==4.1.0
|
||||||
multidict==5.1.0
|
readme-renderer==42.0
|
||||||
packaging==21.0
|
|
||||||
pluggy==0.13.1
|
|
||||||
py==1.10.0
|
|
||||||
Pygments==2.9.0
|
|
||||||
pyparsing==2.4.7
|
|
||||||
pytest==6.1.2
|
|
||||||
pytest-aiohttp==0.3.0
|
|
||||||
pytest-cov==2.10.1
|
|
||||||
readme-renderer==29.0
|
|
||||||
six==1.16.0
|
|
||||||
toml==0.10.2
|
|
||||||
typing-extensions==3.10.0.0
|
|
||||||
webencodings==0.5.1
|
|
||||||
yarl==1.6.3
|
|
22
setup.cfg
22
setup.cfg
@ -18,8 +18,8 @@ classifiers =
|
|||||||
Programming Language :: Python
|
Programming Language :: Python
|
||||||
Programming Language :: Python :: 3
|
Programming Language :: Python :: 3
|
||||||
Programming Language :: Python :: 3 :: Only
|
Programming Language :: Python :: 3 :: Only
|
||||||
Programming Language :: Python :: 3.8
|
Programming Language :: Python :: 3.10
|
||||||
Programming Language :: Python :: 3.9
|
Programming Language :: Python :: 3.11
|
||||||
Topic :: Software Development :: Libraries :: Application Frameworks
|
Topic :: Software Development :: Libraries :: Application Frameworks
|
||||||
Framework :: aiohttp
|
Framework :: aiohttp
|
||||||
License :: OSI Approved :: MIT License
|
License :: OSI Approved :: MIT License
|
||||||
@ -28,22 +28,22 @@ classifiers =
|
|||||||
zip_safe = False
|
zip_safe = False
|
||||||
include_package_data = True
|
include_package_data = True
|
||||||
packages = find:
|
packages = find:
|
||||||
python_requires = >=3.8
|
python_requires = >=3.10
|
||||||
install_requires =
|
install_requires =
|
||||||
aiohttp
|
aiohttp
|
||||||
pydantic>=1.7
|
pydantic>=2.5.0
|
||||||
swagger-ui-bundle
|
swagger-4-ui-bundle
|
||||||
|
|
||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
test =
|
test =
|
||||||
pytest==6.1.2
|
pytest==7.4.0
|
||||||
pytest-aiohttp==0.3.0
|
pytest-aiohttp==1.0.5
|
||||||
pytest-cov==2.10.1
|
pytest-cov==4.1.0
|
||||||
readme-renderer==29.0
|
readme-renderer==42.0
|
||||||
ci =
|
ci =
|
||||||
%(test)s
|
%(test)s
|
||||||
codecov==2.1.11
|
codecov==2.1.13
|
||||||
twine==3.4.2
|
twine==4.0.2
|
||||||
|
|
||||||
[options.packages.find]
|
[options.packages.find]
|
||||||
exclude =
|
exclude =
|
||||||
|
13
tasks.py
13
tasks.py
@ -102,8 +102,9 @@ def test(c, isolate=False):
|
|||||||
"""
|
"""
|
||||||
Launch tests
|
Launch tests
|
||||||
"""
|
"""
|
||||||
opt = "I" if isolate else ""
|
#opt = "I" if isolate else ""
|
||||||
c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/")
|
#c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/")
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@task()
|
@task()
|
||||||
@ -136,6 +137,7 @@ def prepare_ci_env(c):
|
|||||||
|
|
||||||
title("Installing wheel", "=")
|
title("Installing wheel", "=")
|
||||||
package_version = read_configuration("./setup.cfg")["metadata"]["version"]
|
package_version = read_configuration("./setup.cfg")["metadata"]["version"]
|
||||||
|
print([x for x in Path("dist").glob('*')])
|
||||||
dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl"))
|
dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl"))
|
||||||
c.run(f"dist_venv/bin/python -m pip install {dist}")
|
c.run(f"dist_venv/bin/python -m pip install {dist}")
|
||||||
|
|
||||||
@ -156,7 +158,7 @@ def prepare_upload(c):
|
|||||||
|
|
||||||
|
|
||||||
@task(tag_eq_version, prepare_upload)
|
@task(tag_eq_version, prepare_upload)
|
||||||
def upload(c, pypi_user=None, pypi_password=None):
|
def upload(c, pypi_user=None, pypi_password=None, pypi_url=None):
|
||||||
"""
|
"""
|
||||||
Upload on pypi
|
Upload on pypi
|
||||||
"""
|
"""
|
||||||
@ -165,8 +167,9 @@ def upload(c, pypi_user=None, pypi_password=None):
|
|||||||
if pypi_user is not None and pypi_password is not None:
|
if pypi_user is not None and pypi_password is not None:
|
||||||
c.run(
|
c.run(
|
||||||
f"dist_venv/bin/twine upload --non-interactive"
|
f"dist_venv/bin/twine upload --non-interactive"
|
||||||
f" -u {pypi_user} -p {pypi_password} {dist}",
|
f" -u {pypi_user} -p {pypi_password} {dist}"
|
||||||
|
f" --repository-url {pypi_url}",
|
||||||
hide=True,
|
hide=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
c.run(f"dist_venv/bin/twine upload --repository aiohttp-pydantic {dist}")
|
c.run(f"dist_venv/bin/twine upload --repository-url {pypi_url} --repository aiohttp-pydantic {dist}")
|
||||||
|
@ -4,9 +4,10 @@ from typing import Iterator, List, Optional
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web_response import json_response
|
from aiohttp.web_response import json_response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, RootModel
|
||||||
|
|
||||||
from aiohttp_pydantic import PydanticView
|
from aiohttp_pydantic import PydanticView
|
||||||
|
from aiohttp_pydantic.view import PydanticValidationError
|
||||||
|
|
||||||
|
|
||||||
class ArticleModel(BaseModel):
|
class ArticleModel(BaseModel):
|
||||||
@ -14,11 +15,11 @@ class ArticleModel(BaseModel):
|
|||||||
nb_page: Optional[int]
|
nb_page: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
class ArticleModels(BaseModel):
|
class ArticleModels(RootModel):
|
||||||
__root__: List[ArticleModel]
|
root: List[ArticleModel]
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[ArticleModel]:
|
def __iter__(self) -> Iterator[ArticleModel]:
|
||||||
return iter(self.__root__)
|
return iter(self.root)
|
||||||
|
|
||||||
|
|
||||||
class ArticleView(PydanticView):
|
class ArticleView(PydanticView):
|
||||||
@ -30,28 +31,29 @@ class ArticleView(PydanticView):
|
|||||||
|
|
||||||
async def on_validation_error(self, exception, context):
|
async def on_validation_error(self, exception, context):
|
||||||
errors = exception.errors()
|
errors = exception.errors()
|
||||||
for error in errors:
|
own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors]
|
||||||
error["in"] = context
|
return json_response(data=own_errors, status=400)
|
||||||
error["custom"] = "custom"
|
|
||||||
return json_response(data=errors, status=400)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(
|
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
client = await aiohttp_client(app)
|
client = await aiohttp_client(app)
|
||||||
resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"})
|
resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"})
|
||||||
|
|
||||||
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() == [
|
||||||
{
|
{
|
||||||
"in": "body",
|
'loc_in': 'body',
|
||||||
"loc": ["nb_page"],
|
'input': 'foo',
|
||||||
"msg": "value is not a valid integer",
|
'loc': ['nb_page'],
|
||||||
"custom": "custom",
|
'msg': 'Input should be a valid integer, unable to parse string as an integer',
|
||||||
"type": "type_error.integer",
|
'type': 'int_parsing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/int_parsing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,25 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional, Union, Literal
|
from typing import List, Optional, Union, Literal, Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from pydantic import Field, RootModel
|
||||||
from pydantic.main import BaseModel
|
from pydantic.main import BaseModel
|
||||||
|
|
||||||
from aiohttp_pydantic import PydanticView, oas
|
from aiohttp_pydantic import PydanticView, oas
|
||||||
from aiohttp_pydantic.injectors import Group
|
from aiohttp_pydantic.injectors import Group
|
||||||
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
|
from aiohttp_pydantic.oas.typing import r200, r201, r204, r404, r400
|
||||||
from aiohttp_pydantic.oas.view import generate_oas
|
from aiohttp_pydantic.oas.view import generate_oas
|
||||||
|
|
||||||
|
|
||||||
class Color(str, Enum):
|
class Color(str, Enum):
|
||||||
|
"""
|
||||||
|
Pet color
|
||||||
|
"""
|
||||||
RED = "red"
|
RED = "red"
|
||||||
GREEN = "green"
|
GREEN = "green"
|
||||||
PINK = "pink"
|
PINK = "pink"
|
||||||
|
|
||||||
|
|
||||||
|
class Lang(str, Enum):
|
||||||
|
EN = 'en'
|
||||||
|
FR = 'fr'
|
||||||
|
|
||||||
|
|
||||||
class Toy(BaseModel):
|
class Toy(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
color: Color
|
color: Color
|
||||||
@ -27,13 +36,32 @@ class Toy(BaseModel):
|
|||||||
|
|
||||||
class Pet(BaseModel):
|
class Pet(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: Optional[str] = Field(None)
|
||||||
toys: List[Toy]
|
toys: List[Toy]
|
||||||
|
|
||||||
|
|
||||||
|
class Error(BaseModel):
|
||||||
|
code: int
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class Cat(BaseModel):
|
||||||
|
pet_type: Literal['cat']
|
||||||
|
meows: int
|
||||||
|
|
||||||
|
|
||||||
|
class Dog(BaseModel):
|
||||||
|
pet_type: Literal['dog']
|
||||||
|
barks: float
|
||||||
|
|
||||||
|
|
||||||
|
class Animal(RootModel):
|
||||||
|
root: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]
|
||||||
|
|
||||||
|
|
||||||
class PetCollectionView(PydanticView):
|
class PetCollectionView(PydanticView):
|
||||||
async def get(
|
async def get(
|
||||||
self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None
|
self, format: str, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None
|
||||||
) -> r200[List[Pet]]:
|
) -> r200[List[Pet]]:
|
||||||
"""
|
"""
|
||||||
Get a list of pets
|
Get a list of pets
|
||||||
@ -41,6 +69,7 @@ class PetCollectionView(PydanticView):
|
|||||||
Tags: pet
|
Tags: pet
|
||||||
Status Codes:
|
Status Codes:
|
||||||
200: Successful operation
|
200: Successful operation
|
||||||
|
OperationId: createPet
|
||||||
"""
|
"""
|
||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
@ -51,12 +80,12 @@ class PetCollectionView(PydanticView):
|
|||||||
|
|
||||||
class PetItemView(PydanticView):
|
class PetItemView(PydanticView):
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
id: int,
|
id: int,
|
||||||
/,
|
/,
|
||||||
size: Union[int, Literal["x", "l", "s"]],
|
size: Union[int, Literal["x", "l", "s"]],
|
||||||
day: Union[int, Literal["now"]] = "now",
|
day: Union[int, Literal["now"]] = "now",
|
||||||
) -> Union[r200[Pet], r404]:
|
) -> Union[r200[Pet], r404[Error], r400[Error]]:
|
||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
async def put(self, id: int, /, pet: Pet):
|
async def put(self, id: int, /, pet: Pet):
|
||||||
@ -79,6 +108,11 @@ class ViewResponseReturnASimpleType(PydanticView):
|
|||||||
return web.json_response()
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
|
class DiscriminatedView(PydanticView):
|
||||||
|
async def post(self, /, request: Animal) -> r200[int]:
|
||||||
|
return web.json_response()
|
||||||
|
|
||||||
|
|
||||||
async def ensure_content_durability(client):
|
async def ensure_content_durability(client):
|
||||||
"""
|
"""
|
||||||
Reload the page 2 times to ensure that content is always the same
|
Reload the page 2 times to ensure that content is always the same
|
||||||
@ -98,11 +132,12 @@ async def ensure_content_durability(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def generated_oas(aiohttp_client, loop) -> web.Application:
|
async def generated_oas(aiohttp_client, event_loop) -> web.Application:
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/pets", PetCollectionView)
|
app.router.add_view("/pets", PetCollectionView)
|
||||||
app.router.add_view("/pets/{id}", PetItemView)
|
app.router.add_view("/pets/{id}", PetItemView)
|
||||||
app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
|
app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
|
||||||
|
app.router.add_view("/animals", DiscriminatedView)
|
||||||
oas.setup(app)
|
oas.setup(app)
|
||||||
|
|
||||||
return await ensure_content_durability(await aiohttp_client(app))
|
return await ensure_content_durability(await aiohttp_client(app))
|
||||||
@ -110,12 +145,35 @@ async def generated_oas(aiohttp_client, loop) -> web.Application:
|
|||||||
|
|
||||||
async def test_generated_oas_should_have_components_schemas(generated_oas):
|
async def test_generated_oas_should_have_components_schemas(generated_oas):
|
||||||
assert generated_oas["components"]["schemas"] == {
|
assert generated_oas["components"]["schemas"] == {
|
||||||
|
'Cat': {'properties': {'meows': {'title': 'Meows', 'type': 'integer'},
|
||||||
|
'pet_type': {'const': 'cat', 'title': 'Pet Type'}},
|
||||||
|
'required': ['pet_type', 'meows'],
|
||||||
|
'title': 'Cat',
|
||||||
|
'type': 'object'},
|
||||||
"Color": {
|
"Color": {
|
||||||
"description": "An enumeration.",
|
"description": "Pet color",
|
||||||
"enum": ["red", "green", "pink"],
|
"enum": ["red", "green", "pink"],
|
||||||
"title": "Color",
|
"title": "Color",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
|
'Dog': {'properties': {'barks': {'title': 'Barks', 'type': 'number'},
|
||||||
|
'pet_type': {'const': 'dog', 'title': 'Pet Type'}},
|
||||||
|
'required': ['pet_type', 'barks'],
|
||||||
|
'title': 'Dog',
|
||||||
|
'type': 'object'},
|
||||||
|
'Error': {
|
||||||
|
'properties': {
|
||||||
|
'code': {'title': 'Code', 'type': 'integer'},
|
||||||
|
'text': {'title': 'Text', 'type': 'string'}},
|
||||||
|
'required': ['code', 'text'],
|
||||||
|
'title': 'Error',
|
||||||
|
'type': 'object'
|
||||||
|
},
|
||||||
|
'Lang': {
|
||||||
|
'enum': ['en', 'fr'],
|
||||||
|
'title': 'Lang',
|
||||||
|
'type': 'string'
|
||||||
|
},
|
||||||
"Toy": {
|
"Toy": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"color": {"$ref": "#/components/schemas/Color"},
|
"color": {"$ref": "#/components/schemas/Color"},
|
||||||
@ -125,6 +183,26 @@ async def test_generated_oas_should_have_components_schemas(generated_oas):
|
|||||||
"title": "Toy",
|
"title": "Toy",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
'Pet': {
|
||||||
|
'properties': {
|
||||||
|
'id': {'title': 'Id', 'type': 'integer'},
|
||||||
|
'name': {
|
||||||
|
'anyOf': [
|
||||||
|
{'type': 'string'},
|
||||||
|
{'type': 'null'}
|
||||||
|
],
|
||||||
|
'default': None,
|
||||||
|
'title': 'Name'},
|
||||||
|
'toys': {
|
||||||
|
'items': {'$ref': '#/components/schemas/Toy'},
|
||||||
|
'title': 'Toys',
|
||||||
|
'type': 'array'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'required': ['id', 'toys'],
|
||||||
|
'title': 'Pet',
|
||||||
|
'type': 'object'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -135,6 +213,7 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
|
|||||||
async def test_pets_route_should_have_get_method(generated_oas):
|
async def test_pets_route_should_have_get_method(generated_oas):
|
||||||
assert generated_oas["paths"]["/pets"]["get"] == {
|
assert generated_oas["paths"]["/pets"]["get"] == {
|
||||||
"description": "Get a list of pets",
|
"description": "Get a list of pets",
|
||||||
|
"operationId": "createPet",
|
||||||
"tags": ["pet"],
|
"tags": ["pet"],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@ -143,17 +222,38 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
|||||||
"required": True,
|
"required": True,
|
||||||
"schema": {"title": "format", "type": "string"},
|
"schema": {"title": "format", "type": "string"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'in': 'query',
|
||||||
|
'name': 'lang',
|
||||||
|
'required': False,
|
||||||
|
'schema': {
|
||||||
|
'allOf': [{'$ref': '#/components/schemas/Lang'}],
|
||||||
|
'default': 'en',
|
||||||
|
'title': 'lang'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"required": False,
|
"required": False,
|
||||||
"schema": {"title": "name", "type": "string"},
|
"schema": {
|
||||||
|
'anyOf': [{'type': 'string'}, {'type': 'null'}],
|
||||||
|
'default': None,
|
||||||
|
'title': 'name'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"name": "promo",
|
"name": "promo",
|
||||||
"required": False,
|
"required": False,
|
||||||
"schema": {"format": "uuid", "title": "promo", "type": "string"},
|
"schema": {
|
||||||
|
'anyOf': [
|
||||||
|
{'format': 'uuid', 'type': 'string'},
|
||||||
|
{'type': 'null'}
|
||||||
|
],
|
||||||
|
'default': None,
|
||||||
|
'title': 'promo'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -162,20 +262,7 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"items": {
|
"items": {'$ref': '#/components/schemas/Pet'},
|
||||||
"properties": {
|
|
||||||
"id": {"title": "Id", "type": "integer"},
|
|
||||||
"name": {"title": "Name", "type": "string"},
|
|
||||||
"toys": {
|
|
||||||
"items": {"$ref": "#/components/schemas/Toy"},
|
|
||||||
"title": "Toys",
|
|
||||||
"type": "array",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["id", "name", "toys"],
|
|
||||||
"title": "Pet",
|
|
||||||
"type": "object",
|
|
||||||
},
|
|
||||||
"type": "array",
|
"type": "array",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,14 +281,21 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
|||||||
"schema": {
|
"schema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {
|
||||||
|
'anyOf': [
|
||||||
|
{'type': 'string'},
|
||||||
|
{'type': 'null'}
|
||||||
|
],
|
||||||
|
'default': None,
|
||||||
|
'title': 'Name'
|
||||||
|
},
|
||||||
"toys": {
|
"toys": {
|
||||||
"items": {"$ref": "#/components/schemas/Toy"},
|
"items": {"$ref": "#/components/schemas/Toy"},
|
||||||
"title": "Toys",
|
"title": "Toys",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["id", "name", "toys"],
|
"required": ["id", "toys"],
|
||||||
"title": "Pet",
|
"title": "Pet",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
}
|
}
|
||||||
@ -213,20 +307,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
|||||||
"description": "",
|
"description": "",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {'$ref': '#/components/schemas/Pet'}
|
||||||
"properties": {
|
|
||||||
"id": {"title": "Id", "type": "integer"},
|
|
||||||
"name": {"title": "Name", "type": "string"},
|
|
||||||
"toys": {
|
|
||||||
"items": {"$ref": "#/components/schemas/Toy"},
|
|
||||||
"title": "Toys",
|
|
||||||
"type": "array",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["id", "name", "toys"],
|
|
||||||
"title": "Pet",
|
|
||||||
"type": "object",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -279,36 +360,20 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
|
|||||||
"name": "day",
|
"name": "day",
|
||||||
"required": False,
|
"required": False,
|
||||||
"schema": {
|
"schema": {
|
||||||
"anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}],
|
'anyOf': [{'type': 'integer'}, {'const': 'now'}],
|
||||||
"default": "now",
|
'default': 'now',
|
||||||
"title": "day",
|
'title': 'day'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"responses": {
|
'responses': {
|
||||||
"200": {
|
'200': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Pet'}}},
|
||||||
"description": "",
|
'description': ''},
|
||||||
"content": {
|
'400': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}},
|
||||||
"application/json": {
|
'description': ''},
|
||||||
"schema": {
|
'404': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}},
|
||||||
"properties": {
|
'description': ''}
|
||||||
"id": {"title": "Id", "type": "integer"},
|
}
|
||||||
"name": {"title": "Name", "type": "string"},
|
|
||||||
"toys": {
|
|
||||||
"items": {"$ref": "#/components/schemas/Toy"},
|
|
||||||
"title": "Toys",
|
|
||||||
"type": "array",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["id", "name", "toys"],
|
|
||||||
"title": "Pet",
|
|
||||||
"type": "object",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"404": {"description": "", "content": {}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -328,14 +393,20 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
|
|||||||
"schema": {
|
"schema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {"title": "Id", "type": "integer"},
|
"id": {"title": "Id", "type": "integer"},
|
||||||
"name": {"title": "Name", "type": "string"},
|
"name": {
|
||||||
|
'anyOf': [
|
||||||
|
{'type': 'string'},
|
||||||
|
{'type': 'null'}
|
||||||
|
],
|
||||||
|
'default': None,
|
||||||
|
'title': 'Name'},
|
||||||
"toys": {
|
"toys": {
|
||||||
"items": {"$ref": "#/components/schemas/Toy"},
|
"items": {"$ref": "#/components/schemas/Toy"},
|
||||||
"title": "Toys",
|
"title": "Toys",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["id", "name", "toys"],
|
"required": ["id", "toys"],
|
||||||
"title": "Pet",
|
"title": "Pet",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
}
|
}
|
||||||
|
@ -3,21 +3,21 @@ from __future__ import annotations
|
|||||||
from typing import Iterator, List, Optional
|
from typing import Iterator, List, Optional
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, RootModel
|
||||||
|
|
||||||
from aiohttp_pydantic import PydanticView
|
from aiohttp_pydantic import PydanticView
|
||||||
|
|
||||||
|
|
||||||
class ArticleModel(BaseModel):
|
class ArticleModel(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
nb_page: Optional[int]
|
nb_page: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ArticleModels(BaseModel):
|
class ArticleModels(RootModel):
|
||||||
__root__: List[ArticleModel]
|
root: List[ArticleModel]
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[ArticleModel]:
|
def __iter__(self) -> Iterator[ArticleModel]:
|
||||||
return iter(self.__root__)
|
return iter(self.root)
|
||||||
|
|
||||||
|
|
||||||
class ArticleView(PydanticView):
|
class ArticleView(PydanticView):
|
||||||
@ -29,7 +29,7 @@ class ArticleView(PydanticView):
|
|||||||
|
|
||||||
|
|
||||||
async def test_post_an_article_without_required_field_should_return_an_error_message(
|
async def test_post_an_article_without_required_field_should_return_an_error_message(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -38,18 +38,21 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes
|
|||||||
resp = await client.post("/article", json={})
|
resp = await client.post("/article", json={})
|
||||||
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() == [
|
||||||
{
|
{
|
||||||
"in": "body",
|
'input': {},
|
||||||
"loc": ["name"],
|
'loc': ['name'],
|
||||||
"msg": "field required",
|
'loc_in': 'body',
|
||||||
"type": "value_error.missing",
|
'msg': 'Field required',
|
||||||
|
'type': 'missing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/missing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(
|
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -60,15 +63,17 @@ 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",
|
'input': 'foo',
|
||||||
"loc": ["nb_page"],
|
'loc': ['nb_page'],
|
||||||
"msg": "value is not a valid integer",
|
'loc_in': 'body',
|
||||||
"type": "type_error.integer",
|
'msg': 'Input should be a valid integer, unable to parse string as an integer',
|
||||||
|
'type': 'int_parsing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/int_parsing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_post_an_array_json_is_supported(aiohttp_client, loop):
|
async def test_post_an_array_json_is_supported(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
@ -81,7 +86,7 @@ async def test_post_an_array_json_is_supported(aiohttp_client, loop):
|
|||||||
|
|
||||||
|
|
||||||
async def test_post_an_array_json_to_an_object_model_should_return_an_error(
|
async def test_post_an_array_json_to_an_object_model_should_return_an_error(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -92,16 +97,16 @@ async def test_post_an_array_json_to_an_object_model_should_return_an_error(
|
|||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
assert await resp.json() == [
|
assert await resp.json() == [
|
||||||
{
|
{
|
||||||
"in": "body",
|
'loc': ['root'],
|
||||||
"loc": ["__root__"],
|
'loc_in': 'body',
|
||||||
"msg": "value is not a valid dict",
|
'msg': 'value is not a valid dict',
|
||||||
"type": "type_error.dict",
|
'type': 'type_error.dict'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_post_an_object_json_to_a_list_model_should_return_an_error(
|
async def test_post_an_object_json_to_a_list_model_should_return_an_error(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -110,17 +115,20 @@ async def test_post_an_object_json_to_a_list_model_should_return_an_error(
|
|||||||
resp = await client.put("/article", json={"name": "foo", "nb_page": 3})
|
resp = await client.put("/article", json={"name": "foo", "nb_page": 3})
|
||||||
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() == [
|
||||||
{
|
{
|
||||||
"in": "body",
|
'input': {'name': 'foo', 'nb_page': 3},
|
||||||
"loc": ["__root__"],
|
'loc': [],
|
||||||
"msg": "value is not a valid list",
|
'loc_in': 'body',
|
||||||
"type": "type_error.list",
|
'msg': 'Input should be a valid list',
|
||||||
|
'type': 'list_type',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/list_type'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop):
|
async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
|
@ -50,9 +50,9 @@ class Signature(Group):
|
|||||||
|
|
||||||
class ArticleViewWithSignatureGroup(PydanticView):
|
class ArticleViewWithSignatureGroup(PydanticView):
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
signature: Signature,
|
signature: Signature,
|
||||||
):
|
):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"expired": signature.expired, "scope": signature.scope},
|
{"expired": signature.expired, "scope": signature.scope},
|
||||||
@ -61,7 +61,7 @@ class ArticleViewWithSignatureGroup(PydanticView):
|
|||||||
|
|
||||||
|
|
||||||
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, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -70,18 +70,24 @@ async def test_get_article_without_required_header_should_return_an_error_messag
|
|||||||
resp = await client.get("/article", headers={})
|
resp = await client.get("/article", headers={})
|
||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
assert await resp.json() == [
|
|
||||||
|
result = await resp.json()
|
||||||
|
assert len(result) == 1
|
||||||
|
result[0].pop('input')
|
||||||
|
|
||||||
|
assert result == [
|
||||||
{
|
{
|
||||||
"in": "headers",
|
'type': 'missing',
|
||||||
"loc": ["signature_expired"],
|
'loc': ['signature_expired'],
|
||||||
"msg": "field required",
|
'msg': 'Field required',
|
||||||
"type": "value_error.missing",
|
'url': 'https://errors.pydantic.dev/2.5/v/missing',
|
||||||
|
'loc_in': 'headers'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_wrong_header_type_should_return_an_error_message(
|
async def test_get_article_with_wrong_header_type_should_return_an_error_message(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -90,18 +96,22 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message
|
|||||||
resp = await client.get("/article", headers={"signature_expired": "foo"})
|
resp = await client.get("/article", headers={"signature_expired": "foo"})
|
||||||
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() == [
|
||||||
{
|
{
|
||||||
"in": "headers",
|
'type': 'datetime_parsing',
|
||||||
"loc": ["signature_expired"],
|
'loc': ['signature_expired'],
|
||||||
"msg": "invalid datetime format",
|
'msg': 'Input should be a valid datetime, input is too short',
|
||||||
"type": "value_error.datetime",
|
'input': 'foo',
|
||||||
|
'ctx': {'error': 'input is too short'},
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/datetime_parsing',
|
||||||
|
'loc_in': 'headers'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_valid_header_should_return_the_parsed_type(
|
async def test_get_article_with_valid_header_should_return_the_parsed_type(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -116,7 +126,7 @@ async def test_get_article_with_valid_header_should_return_the_parsed_type(
|
|||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_valid_header_containing_hyphen_should_be_returned(
|
async def test_get_article_with_valid_header_containing_hyphen_should_be_returned(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -130,30 +140,32 @@ async def test_get_article_with_valid_header_containing_hyphen_should_be_returne
|
|||||||
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):
|
async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/coord", ViewWithEnumType)
|
app.router.add_view("/coord", ViewWithEnumType)
|
||||||
|
|
||||||
client = await aiohttp_client(app)
|
client = await aiohttp_client(app)
|
||||||
resp = await client.get("/coord", headers={"format": "WGS84"})
|
resp = await client.get("/coord", headers={"format": "WGS84"})
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
await resp.json()
|
await resp.json()
|
||||||
== [
|
== [
|
||||||
{
|
{
|
||||||
"ctx": {"enum_values": ["UMT", "MGRS"]},
|
'ctx': {'expected': "'UMT' or 'MGRS'"},
|
||||||
"in": "headers",
|
'input': 'WGS84',
|
||||||
"loc": ["format"],
|
'loc': ['format'],
|
||||||
"msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'",
|
'loc_in': 'headers',
|
||||||
"type": "type_error.enum",
|
'msg': "Input should be 'UMT' or 'MGRS'",
|
||||||
}
|
'type': 'enum'
|
||||||
]
|
}
|
||||||
!= {"signature": "2020-10-04T18:01:00"}
|
]
|
||||||
|
!= {"signature": "2020-10-04T18:01:00"}
|
||||||
)
|
)
|
||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loop):
|
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/coord", ViewWithEnumType)
|
app.router.add_view("/coord", ViewWithEnumType)
|
||||||
|
|
||||||
@ -164,7 +176,7 @@ async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loo
|
|||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_with_signature_group(aiohttp_client, loop):
|
async def test_with_signature_group(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleViewWithSignatureGroup)
|
app.router.add_view("/article", ArticleViewWithSignatureGroup)
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ class ArticleView(PydanticView):
|
|||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path(
|
async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
|
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
|
||||||
@ -24,7 +24,7 @@ async def test_get_article_with_correct_path_parameters_should_return_parameters
|
|||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_wrong_path_parameters_should_return_error(
|
async def test_get_article_with_wrong_path_parameters_should_return_error(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
|
app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView)
|
||||||
@ -33,11 +33,14 @@ async def test_get_article_with_wrong_path_parameters_should_return_error(
|
|||||||
resp = await client.get("/article/1234/tag/music/before/now")
|
resp = await client.get("/article/1234/tag/music/before/now")
|
||||||
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() == [
|
||||||
{
|
{
|
||||||
"in": "path",
|
'input': 'now',
|
||||||
"loc": ["date"],
|
'loc': ['date'],
|
||||||
"msg": "value is not a valid integer",
|
'loc_in': 'path',
|
||||||
"type": "type_error.integer",
|
'msg': 'Input should be a valid integer, unable to parse string as an integer',
|
||||||
|
'type': 'int_parsing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/int_parsing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@ -10,11 +11,11 @@ from aiohttp_pydantic.injectors import Group
|
|||||||
|
|
||||||
class ArticleView(PydanticView):
|
class ArticleView(PydanticView):
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
with_comments: bool,
|
with_comments: bool,
|
||||||
age: Optional[int] = None,
|
age: Optional[int] = None,
|
||||||
nb_items: int = 7,
|
nb_items: int = 7,
|
||||||
tags: List[str] = Field(default_factory=list),
|
tags: List[str] = Field(default_factory=list),
|
||||||
):
|
):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@ -41,9 +42,9 @@ class Pagination(Group):
|
|||||||
|
|
||||||
class ArticleViewWithPaginationGroup(PydanticView):
|
class ArticleViewWithPaginationGroup(PydanticView):
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
with_comments: bool,
|
with_comments: bool,
|
||||||
page: Pagination,
|
page: Pagination,
|
||||||
):
|
):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@ -54,8 +55,22 @@ class ArticleViewWithPaginationGroup(PydanticView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Lang(str, Enum):
|
||||||
|
EN = 'en'
|
||||||
|
FR = 'fr'
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleViewWithEnumInQuery(PydanticView):
|
||||||
|
async def get(self, lang: Lang):
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"lang": lang
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -64,18 +79,21 @@ async def test_get_article_without_required_qs_should_return_an_error_message(
|
|||||||
resp = await client.get("/article")
|
resp = await client.get("/article")
|
||||||
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() == [
|
||||||
{
|
{
|
||||||
"in": "query string",
|
'input': {},
|
||||||
"loc": ["with_comments"],
|
'loc': ['with_comments'],
|
||||||
"msg": "field required",
|
'loc_in': 'query string',
|
||||||
"type": "value_error.missing",
|
'msg': 'Field required',
|
||||||
|
'type': 'missing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/missing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_wrong_qs_type_should_return_an_error_message(
|
async def test_get_article_with_wrong_qs_type_should_return_an_error_message(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -86,16 +104,18 @@ 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",
|
'input': 'foo',
|
||||||
"loc": ["with_comments"],
|
'loc': ['with_comments'],
|
||||||
"msg": "value could not be parsed to a boolean",
|
'loc_in': 'query string',
|
||||||
"type": "type_error.bool",
|
'msg': 'Input should be a valid boolean, unable to interpret input',
|
||||||
|
'type': 'bool_parsing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/bool_parsing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_valid_qs_should_return_the_parsed_type(
|
async def test_get_article_with_valid_qs_should_return_the_parsed_type(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -114,7 +134,7 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
|
|||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value(
|
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -133,7 +153,7 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_defa
|
|||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_multiple_value_for_qs_age_must_failed(
|
async def test_get_article_with_multiple_value_for_qs_age_must_failed(
|
||||||
aiohttp_client, loop
|
aiohttp_client, event_loop
|
||||||
):
|
):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
@ -143,17 +163,19 @@ async def test_get_article_with_multiple_value_for_qs_age_must_failed(
|
|||||||
resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1})
|
resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1})
|
||||||
assert await resp.json() == [
|
assert await resp.json() == [
|
||||||
{
|
{
|
||||||
"in": "query string",
|
'input': ['2', '3'],
|
||||||
"loc": ["age"],
|
'loc': ['age'],
|
||||||
"msg": "value is not a valid integer",
|
'loc_in': 'query string',
|
||||||
"type": "type_error.integer",
|
'msg': 'Input should be a valid integer',
|
||||||
|
'type': 'int_type',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/int_type'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop):
|
async def test_get_article_with_multiple_value_of_tags(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
@ -172,7 +194,7 @@ async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop):
|
|||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, loop):
|
async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleView)
|
app.router.add_view("/article", ArticleView)
|
||||||
|
|
||||||
@ -191,7 +213,7 @@ async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client,
|
|||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_without_required_field_page(aiohttp_client, loop):
|
async def test_get_article_without_required_field_page(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||||
|
|
||||||
@ -200,17 +222,19 @@ async def test_get_article_without_required_field_page(aiohttp_client, loop):
|
|||||||
resp = await client.get("/article", params={"with_comments": 1})
|
resp = await client.get("/article", params={"with_comments": 1})
|
||||||
assert await resp.json() == [
|
assert await resp.json() == [
|
||||||
{
|
{
|
||||||
"in": "query string",
|
'input': {'with_comments': '1'},
|
||||||
"loc": ["page_num"],
|
'loc': ['page_num'],
|
||||||
"msg": "field required",
|
'loc_in': 'query string',
|
||||||
"type": "value_error.missing",
|
'msg': 'Field required',
|
||||||
|
'type': 'missing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/missing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_page(aiohttp_client, loop):
|
async def test_get_article_with_page(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||||
|
|
||||||
@ -222,7 +246,7 @@ async def test_get_article_with_page(aiohttp_client, loop):
|
|||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_page_and_page_size(aiohttp_client, loop):
|
async def test_get_article_with_page_and_page_size(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||||
|
|
||||||
@ -236,7 +260,21 @@ async def test_get_article_with_page_and_page_size(aiohttp_client, loop):
|
|||||||
assert resp.content_type == "application/json"
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, loop):
|
async def test_get_article_with_enum_in_query(aiohttp_client, event_loop):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view("/article", ArticleViewWithEnumInQuery)
|
||||||
|
|
||||||
|
client = await aiohttp_client(app)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/article", params={"lang": Lang.EN.value}
|
||||||
|
)
|
||||||
|
assert await resp.json() == {'lang': Lang.EN}
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == "application/json"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_loop):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
app.router.add_view("/article", ArticleViewWithPaginationGroup)
|
||||||
|
|
||||||
@ -247,10 +285,13 @@ async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, loop):
|
|||||||
)
|
)
|
||||||
assert await resp.json() == [
|
assert await resp.json() == [
|
||||||
{
|
{
|
||||||
"in": "query string",
|
'input': 'large',
|
||||||
"loc": ["page_size"],
|
'loc': ['page_size'],
|
||||||
"msg": "value is not a valid integer",
|
'loc_in': 'query string',
|
||||||
"type": "type_error.integer",
|
'msg': 'Input should be a valid integer, unable to parse string as an '
|
||||||
|
'integer',
|
||||||
|
'type': 'int_parsing',
|
||||||
|
'url': 'https://errors.pydantic.dev/2.5/v/int_parsing'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert resp.status == 400
|
assert resp.status == 400
|
||||||
|
Loading…
x
Reference in New Issue
Block a user