Compare commits

..

No commits in common. "v2.0.6" and "main" have entirely different histories.
v2.0.6 ... main

21 changed files with 321 additions and 483 deletions

View File

@ -1,26 +0,0 @@
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.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Set version
run: export UPLOAD_VERSION=${{ gitea.ref_name }}
- name: Update version
run: sed -i -e "s/1.12.1/${UPLOAD_VERSION: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

View File

@ -1,12 +0,0 @@
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

View File

@ -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).model_dump().values()) args_view.extend(self.model(**request.match_info).dict().values())
class BodyGetter(AbstractInjector): class BodyGetter(AbstractInjector):
@ -68,10 +68,7 @@ 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()))
schema = self.model.model_json_schema() self._expect_object = self.model.schema()["type"] == "object"
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:
@ -85,7 +82,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='[{"loc_in": "body", "loc": ["root"], "msg": "value is not a ' text='[{"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
@ -120,7 +117,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).model_dump() cleaned = self.model(**data).dict()
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:
@ -166,7 +163,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).model_dump() cleaned = self.model(**header).dict()
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:

View File

@ -15,12 +15,11 @@ 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"] = custom_template or jinja2.Template( oas_app["index template"] = 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

View File

@ -120,19 +120,6 @@ 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.
@ -140,7 +127,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*:.*|operation_?id\\s*:.*", line, re.IGNORECASE): if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*", line, re.IGNORECASE):
lines.rewind() lines.rewind()
for _ in _i_extract_block(lines): for _ in _i_extract_block(lines):
pass pass

View File

@ -1,27 +1,45 @@
{# This updated file is part of swagger_ui_bundle (https://github.com/bartsanchez/swagger_ui_bundle) #} {# This updated file is part of swagger_ui_bundle (https://github.com/dtkav/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-standalone-preset.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"> </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 }}",
@ -36,15 +54,16 @@
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>

View File

@ -207,17 +207,6 @@ 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):

View File

@ -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, RootModel from pydantic import BaseModel
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,10 +32,9 @@ 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("$defs", None): if def_sub_schemas := response_schema.pop("definitions", None):
self._oas.components.schemas.update(def_sub_schemas) self._oas.components.schemas.update(def_sub_schemas)
self._oas.components.schemas.update({response_schema['title']: response_schema}) return response_schema
return {'$ref': f'#/components/schemas/{response_schema["title"]}'}
return {} return {}
def _handle_list(self, obj): def _handle_list(self, obj):
@ -88,7 +87,6 @@ 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 = {}
@ -99,7 +97,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("$defs", None): if def_sub_schemas := body_schema.pop("definitions", 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 = {
@ -117,24 +115,17 @@ 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, (RootModel,), attrs).schema( oas_operation.parameters[i].schema = type(name, (BaseModel,), 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(
@ -194,8 +185,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

@ -10,8 +10,6 @@ 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,
@ -24,10 +22,6 @@ 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.
@ -97,7 +91,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.
@ -107,13 +101,14 @@ class PydanticView(AbstractView):
"headers", "path" or "query string" "headers", "path" or "query string"
""" """
errors = exception.errors() errors = exception.errors()
own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] for error in errors:
error["in"] = context
return json_response(data=own_errors, status=400) return json_response(data=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
@ -151,7 +146,6 @@ def is_pydantic_view(obj) -> bool:
__all__ = ( __all__ = (
"PydanticValidationError",
"AbstractInjector", "AbstractInjector",
"BodyGetter", "BodyGetter",
"HeadersGetter", "HeadersGetter",

View File

@ -4,6 +4,3 @@ requires = [
"wheel", "wheel",
] ]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@ -1,11 +1,42 @@
aiohttp==3.8.6 async-timeout==3.0.1
pydantic==2.5.1 attrs==21.2.0
jinja2==3.1.2 bleach==4.0.0
swagger-4-ui-bundle==0.0.4 certifi==2021.5.30
pytest==7.4.3 cffi==1.14.6
pytest-aiohttp==1.0.5 chardet==4.0.0
pytest-asyncio==0.21.1 charset-normalizer==2.0.4
pytest-cov==4.1.0 codecov==2.1.11
readme-renderer==42.0 colorama==0.4.4
codecov==2.1.13 coverage==5.5
twine==4.0.2 cryptography==3.4.7
docutils==0.17.1
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

View File

@ -1,9 +1,23 @@
aiohttp==3.8.6 async-timeout==3.0.1
pydantic==2.5.1 attrs==21.2.0
jinja2==3.1.2 bleach==4.0.0
swagger-4-ui-bundle==0.0.4 chardet==4.0.0
pytest==7.4.3 coverage==5.5
pytest-aiohttp==1.0.5 docutils==0.17.1
pytest-asyncio==0.21.1 idna==3.2
pytest-cov==4.1.0 iniconfig==1.1.1
readme-renderer==42.0 multidict==5.1.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

View File

@ -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.10 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.9
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.10 python_requires = >=3.8
install_requires = install_requires =
aiohttp aiohttp
pydantic>=2.5.0 pydantic>=1.7
swagger-4-ui-bundle swagger-ui-bundle
[options.extras_require] [options.extras_require]
test = test =
pytest==7.4.0 pytest==6.1.2
pytest-aiohttp==1.0.5 pytest-aiohttp==0.3.0
pytest-cov==4.1.0 pytest-cov==2.10.1
readme-renderer==42.0 readme-renderer==29.0
ci = ci =
%(test)s %(test)s
codecov==2.1.13 codecov==2.1.11
twine==4.0.2 twine==3.4.2
[options.packages.find] [options.packages.find]
exclude = exclude =

View File

@ -102,9 +102,8 @@ 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()
@ -137,7 +136,6 @@ 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}")
@ -158,7 +156,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, pypi_url=None): def upload(c, pypi_user=None, pypi_password=None):
""" """
Upload on pypi Upload on pypi
""" """
@ -167,9 +165,8 @@ def upload(c, pypi_user=None, pypi_password=None, pypi_url=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-url {pypi_url} --repository aiohttp-pydantic {dist}") c.run(f"dist_venv/bin/twine upload --repository aiohttp-pydantic {dist}")

View File

@ -4,10 +4,9 @@ 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, RootModel from pydantic import BaseModel
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
from aiohttp_pydantic.view import PydanticValidationError
class ArticleModel(BaseModel): class ArticleModel(BaseModel):
@ -15,11 +14,11 @@ class ArticleModel(BaseModel):
nb_page: Optional[int] nb_page: Optional[int]
class ArticleModels(RootModel): class ArticleModels(BaseModel):
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):
@ -31,29 +30,28 @@ 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()
own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] for error in errors:
return json_response(data=own_errors, status=400) error["in"] = context
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, event_loop aiohttp_client, 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() == [
{ {
'loc_in': 'body', "in": "body",
'input': 'foo', "loc": ["nb_page"],
'loc': ['nb_page'], "msg": "value is not a valid integer",
'msg': 'Input should be a valid integer, unable to parse string as an integer', "custom": "custom",
'type': 'int_parsing', "type": "type_error.integer",
'url': 'https://errors.pydantic.dev/2.5/v/int_parsing'
} }
] ]

View File

@ -1,34 +1,25 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import List, Optional, Union, Literal, Annotated from typing import List, Optional, Union, Literal
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, r400 from aiohttp_pydantic.oas.typing import r200, r201, r204, r404
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
@ -36,32 +27,13 @@ class Toy(BaseModel):
class Pet(BaseModel): class Pet(BaseModel):
id: int id: int
name: Optional[str] = Field(None) name: str
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, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None
) -> r200[List[Pet]]: ) -> r200[List[Pet]]:
""" """
Get a list of pets Get a list of pets
@ -69,7 +41,6 @@ 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()
@ -80,12 +51,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[Error], r400[Error]]: ) -> Union[r200[Pet], r404]:
return web.json_response() return web.json_response()
async def put(self, id: int, /, pet: Pet): async def put(self, id: int, /, pet: Pet):
@ -108,11 +79,6 @@ 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
@ -132,12 +98,11 @@ async def ensure_content_durability(client):
@pytest.fixture @pytest.fixture
async def generated_oas(aiohttp_client, event_loop) -> web.Application: async def generated_oas(aiohttp_client, 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))
@ -145,35 +110,12 @@ async def generated_oas(aiohttp_client, event_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": "Pet color", "description": "An enumeration.",
"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"},
@ -183,26 +125,6 @@ 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'
}
} }
@ -213,7 +135,6 @@ 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": [
{ {
@ -222,38 +143,17 @@ 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": { "schema": {"title": "name", "type": "string"},
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'name'
},
}, },
{ {
"in": "header", "in": "header",
"name": "promo", "name": "promo",
"required": False, "required": False,
"schema": { "schema": {"format": "uuid", "title": "promo", "type": "string"},
'anyOf': [
{'format': 'uuid', 'type': 'string'},
{'type': 'null'}
],
'default': None,
'title': 'promo'
},
}, },
], ],
"responses": { "responses": {
@ -262,7 +162,20 @@ async def test_pets_route_should_have_get_method(generated_oas):
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"items": {'$ref': '#/components/schemas/Pet'}, "items": {
"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",
} }
} }
@ -281,21 +194,14 @@ 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": { "name": {"title": "Name", "type": "string"},
'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", "toys"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
} }
@ -307,7 +213,20 @@ async def test_pets_route_should_have_post_method(generated_oas):
"description": "", "description": "",
"content": { "content": {
"application/json": { "application/json": {
"schema": {'$ref': '#/components/schemas/Pet'} "schema": {
"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",
}
} }
}, },
} }
@ -360,20 +279,36 @@ 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'}, {'const': 'now'}], "anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}],
'default': 'now', "default": "now",
'title': 'day' "title": "day",
}, },
}, },
], ],
'responses': { "responses": {
'200': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Pet'}}}, "200": {
'description': ''}, "description": "",
'400': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}}, "content": {
'description': ''}, "application/json": {
'404': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}}, "schema": {
'description': ''} "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",
}
}
},
},
"404": {"description": "", "content": {}},
},
} }
@ -393,20 +328,14 @@ 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": { "name": {"title": "Name", "type": "string"},
'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", "toys"], "required": ["id", "name", "toys"],
"title": "Pet", "title": "Pet",
"type": "object", "type": "object",
} }

View File

@ -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, RootModel from pydantic import BaseModel
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
class ArticleModel(BaseModel): class ArticleModel(BaseModel):
name: str name: str
nb_page: Optional[int] = None nb_page: Optional[int]
class ArticleModels(RootModel): class ArticleModels(BaseModel):
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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -38,21 +38,18 @@ 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() == [
{ {
'input': {}, "in": "body",
'loc': ['name'], "loc": ["name"],
'loc_in': 'body', "msg": "field required",
'msg': 'Field required', "type": "value_error.missing",
'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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -63,17 +60,15 @@ 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() == [
{ {
'input': 'foo', "in": "body",
'loc': ['nb_page'], "loc": ["nb_page"],
'loc_in': 'body', "msg": "value is not a valid integer",
'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'
} }
] ]
async def test_post_an_array_json_is_supported(aiohttp_client, event_loop): async def test_post_an_array_json_is_supported(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -86,7 +81,7 @@ async def test_post_an_array_json_is_supported(aiohttp_client, event_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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -97,16 +92,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() == [
{ {
'loc': ['root'], "in": "body",
'loc_in': 'body', "loc": ["__root__"],
'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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -115,20 +110,17 @@ 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() == [
{ {
'input': {'name': 'foo', 'nb_page': 3}, "in": "body",
'loc': [], "loc": ["__root__"],
'loc_in': 'body', "msg": "value is not a valid list",
'msg': 'Input should be a valid list', "type": "type_error.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, event_loop): async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)

View File

@ -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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -70,24 +70,18 @@ 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 == [
{ {
'type': 'missing', "in": "headers",
'loc': ['signature_expired'], "loc": ["signature_expired"],
'msg': 'Field required', "msg": "field required",
'url': 'https://errors.pydantic.dev/2.5/v/missing', "type": "value_error.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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -96,22 +90,18 @@ 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() == [
{ {
'type': 'datetime_parsing', "in": "headers",
'loc': ['signature_expired'], "loc": ["signature_expired"],
'msg': 'Input should be a valid datetime, input is too short', "msg": "invalid datetime format",
'input': 'foo', "type": "value_error.datetime",
'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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -126,7 +116,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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -140,32 +130,30 @@ 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, event_loop): async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, 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': {'expected': "'UMT' or 'MGRS'"}, "ctx": {"enum_values": ["UMT", "MGRS"]},
'input': 'WGS84', "in": "headers",
'loc': ['format'], "loc": ["format"],
'loc_in': 'headers', "msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'",
'msg': "Input should be 'UMT' or 'MGRS'", "type": "type_error.enum",
'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, event_loop): async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/coord", ViewWithEnumType) app.router.add_view("/coord", ViewWithEnumType)
@ -176,7 +164,7 @@ async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, eve
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
async def test_with_signature_group(aiohttp_client, event_loop): async def test_with_signature_group(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleViewWithSignatureGroup) app.router.add_view("/article", ArticleViewWithSignatureGroup)

View File

@ -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, event_loop aiohttp_client, 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, event_loop aiohttp_client, 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,14 +33,11 @@ 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() == [
{ {
'input': 'now', "in": "path",
'loc': ['date'], "loc": ["date"],
'loc_in': 'path', "msg": "value is not a valid integer",
'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'
} }
] ]

View File

@ -1,6 +1,5 @@
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
@ -11,11 +10,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(
{ {
@ -42,9 +41,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(
{ {
@ -55,22 +54,8 @@ 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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -79,21 +64,18 @@ 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() == [
{ {
'input': {}, "in": "query string",
'loc': ['with_comments'], "loc": ["with_comments"],
'loc_in': 'query string', "msg": "field required",
'msg': 'Field required', "type": "value_error.missing",
'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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -104,18 +86,16 @@ 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() == [
{ {
'input': 'foo', "in": "query string",
'loc': ['with_comments'], "loc": ["with_comments"],
'loc_in': 'query string', "msg": "value could not be parsed to a boolean",
'msg': 'Input should be a valid boolean, unable to interpret input', "type": "type_error.bool",
'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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -134,7 +114,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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -153,7 +133,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, event_loop aiohttp_client, loop
): ):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -163,19 +143,17 @@ 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() == [
{ {
'input': ['2', '3'], "in": "query string",
'loc': ['age'], "loc": ["age"],
'loc_in': 'query string', "msg": "value is not a valid integer",
'msg': 'Input should be a valid integer', "type": "type_error.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, event_loop): async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -194,7 +172,7 @@ async def test_get_article_with_multiple_value_of_tags(aiohttp_client, event_loo
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, event_loop): async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
@ -213,7 +191,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, event_loop): async def test_get_article_without_required_field_page(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup) app.router.add_view("/article", ArticleViewWithPaginationGroup)
@ -222,19 +200,17 @@ async def test_get_article_without_required_field_page(aiohttp_client, event_loo
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() == [
{ {
'input': {'with_comments': '1'}, "in": "query string",
'loc': ['page_num'], "loc": ["page_num"],
'loc_in': 'query string', "msg": "field required",
'msg': 'Field required', "type": "value_error.missing",
'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, event_loop): async def test_get_article_with_page(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup) app.router.add_view("/article", ArticleViewWithPaginationGroup)
@ -246,7 +222,7 @@ async def test_get_article_with_page(aiohttp_client, event_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, event_loop): async def test_get_article_with_page_and_page_size(aiohttp_client, loop):
app = web.Application() app = web.Application()
app.router.add_view("/article", ArticleViewWithPaginationGroup) app.router.add_view("/article", ArticleViewWithPaginationGroup)
@ -260,21 +236,7 @@ async def test_get_article_with_page_and_page_size(aiohttp_client, event_loop):
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
async def test_get_article_with_enum_in_query(aiohttp_client, event_loop): async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, 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)
@ -285,13 +247,10 @@ async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_l
) )
assert await resp.json() == [ assert await resp.json() == [
{ {
'input': 'large', "in": "query string",
'loc': ['page_size'], "loc": ["page_size"],
'loc_in': 'query string', "msg": "value is not a valid integer",
'msg': 'Input should be a valid integer, unable to parse string as an ' "type": "type_error.integer",
'integer',
'type': 'int_parsing',
'url': 'https://errors.pydantic.dev/2.5/v/int_parsing'
} }
] ]
assert resp.status == 400 assert resp.status == 400