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)
|
||||
|
||||
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):
|
||||
@ -68,7 +68,10 @@ class BodyGetter(AbstractInjector):
|
||||
|
||||
def __init__(self, args_spec: dict, default_values: dict):
|
||||
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):
|
||||
try:
|
||||
@ -82,7 +85,7 @@ class BodyGetter(AbstractInjector):
|
||||
# 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):
|
||||
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"}]',
|
||||
content_type="application/json",
|
||||
) from None
|
||||
@ -117,7 +120,7 @@ class QueryGetter(AbstractInjector):
|
||||
|
||||
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||
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():
|
||||
group = group_cls()
|
||||
for attr_name in group_attrs:
|
||||
@ -163,7 +166,7 @@ class HeadersGetter(AbstractInjector):
|
||||
|
||||
def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
|
||||
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():
|
||||
group = group_cls()
|
||||
for attr_name in group_attrs:
|
||||
|
@ -15,11 +15,12 @@ def setup(
|
||||
enable: bool = True,
|
||||
version_spec: Optional[str] = None,
|
||||
title_spec: Optional[str] = None,
|
||||
custom_template: Optional[jinja2.Template] = None
|
||||
):
|
||||
if enable:
|
||||
oas_app = web.Application()
|
||||
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")
|
||||
)
|
||||
oas_app["version_spec"] = version_spec
|
||||
|
@ -120,6 +120,19 @@ def tags(docstring: str) -> List[str]:
|
||||
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:
|
||||
"""
|
||||
Extract all docstring except the "Status Code:" block.
|
||||
@ -127,7 +140,7 @@ def operation(docstring: str) -> str:
|
||||
lines = LinesIterator(docstring)
|
||||
ret = []
|
||||
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()
|
||||
for _ in _i_extract_block(lines):
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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-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>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js"> </script>
|
||||
<script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js"> </script>
|
||||
<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>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "{{ openapi_spec_url }}",
|
||||
{% if urls is defined %}
|
||||
urls: {{ urls|tojson|safe }},
|
||||
{% endif %}
|
||||
validatorUrl: {{ validatorUrl | default('null') }},
|
||||
{% if configUrl is defined %}
|
||||
configUrl: "{{ configUrl }}",
|
||||
@ -54,16 +36,15 @@
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
})
|
||||
});
|
||||
{% if initOAuth is defined %}
|
||||
ui.initOAuth(
|
||||
{{ initOAuth|tojson|safe }}
|
||||
)
|
||||
{% endif %}
|
||||
// End Swagger UI call region
|
||||
|
||||
window.ui = ui
|
||||
}
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -207,6 +207,17 @@ class OperationObject:
|
||||
else:
|
||||
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:
|
||||
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_app import Application
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
|
||||
from . import docstring_parser
|
||||
@ -32,9 +32,10 @@ class _OASResponseBuilder:
|
||||
response_schema = obj.schema(
|
||||
ref_template="#/components/schemas/{model}"
|
||||
).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)
|
||||
return response_schema
|
||||
self._oas.components.schemas.update({response_schema['title']: response_schema})
|
||||
return {'$ref': f'#/components/schemas/{response_schema["title"]}'}
|
||||
return {}
|
||||
|
||||
def _handle_list(self, obj):
|
||||
@ -87,6 +88,7 @@ def _add_http_method_to_oas(
|
||||
if description:
|
||||
oas_operation.description = docstring_parser.operation(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)
|
||||
else:
|
||||
status_code_descriptions = {}
|
||||
@ -97,7 +99,7 @@ def _add_http_method_to_oas(
|
||||
.schema(ref_template="#/components/schemas/{model}")
|
||||
.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_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].name = name
|
||||
|
||||
attrs = {"__annotations__": {"__root__": type_}}
|
||||
attrs = {"__annotations__": {"root": type_}}
|
||||
if name in defaults:
|
||||
attrs["__root__"] = defaults[name]
|
||||
attrs["root"] = defaults[name]
|
||||
oas_operation.parameters[i].required = False
|
||||
else:
|
||||
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}"
|
||||
)
|
||||
|
||||
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")
|
||||
if return_type is not None:
|
||||
_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="")
|
||||
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()
|
||||
|
||||
return Response(
|
||||
|
@ -10,6 +10,8 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
|
||||
from aiohttp.web_response import StreamResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from pydantic_core import ErrorDetails
|
||||
|
||||
from .injectors import (
|
||||
AbstractInjector,
|
||||
BodyGetter,
|
||||
@ -22,6 +24,10 @@ from .injectors import (
|
||||
)
|
||||
|
||||
|
||||
class PydanticValidationError(ErrorDetails):
|
||||
loc_in: CONTEXT
|
||||
|
||||
|
||||
class PydanticView(AbstractView):
|
||||
"""
|
||||
An AIOHTTP View that validate request using function annotations.
|
||||
@ -91,7 +97,7 @@ class PydanticView(AbstractView):
|
||||
return injectors
|
||||
|
||||
async def on_validation_error(
|
||||
self, exception: ValidationError, context: CONTEXT
|
||||
self, exception: ValidationError, context: CONTEXT
|
||||
) -> StreamResponse:
|
||||
"""
|
||||
This method is a hook to intercept ValidationError.
|
||||
@ -101,14 +107,13 @@ class PydanticView(AbstractView):
|
||||
"headers", "path" or "query string"
|
||||
"""
|
||||
errors = exception.errors()
|
||||
for error in errors:
|
||||
error["in"] = context
|
||||
own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors]
|
||||
|
||||
return json_response(data=errors, status=400)
|
||||
return json_response(data=own_errors, status=400)
|
||||
|
||||
|
||||
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
|
||||
@ -146,6 +151,7 @@ def is_pydantic_view(obj) -> bool:
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PydanticValidationError",
|
||||
"AbstractInjector",
|
||||
"BodyGetter",
|
||||
"HeadersGetter",
|
||||
|
@ -4,3 +4,6 @@ requires = [
|
||||
"wheel",
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
@ -1,42 +1,12 @@
|
||||
async-timeout==3.0.1
|
||||
attrs==21.2.0
|
||||
bleach==4.0.0
|
||||
certifi==2021.5.30
|
||||
cffi==1.14.6
|
||||
chardet==4.0.0
|
||||
charset-normalizer==2.0.4
|
||||
codecov==2.1.11
|
||||
colorama==0.4.4
|
||||
coverage==5.5
|
||||
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
|
||||
aiohttp==3.8.6
|
||||
pydantic==2.5.1
|
||||
jinja2==3.1.2
|
||||
swagger-4-ui-bundle==0.0.4
|
||||
pytest==7.4.3
|
||||
pytest-aiohttp==1.0.5
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
readme-renderer==42.0
|
||||
codecov==2.1.13
|
||||
twine==4.0.2
|
||||
importlib-metadata==7.1.0
|
@ -1,23 +1,9 @@
|
||||
async-timeout==3.0.1
|
||||
attrs==21.2.0
|
||||
bleach==4.0.0
|
||||
chardet==4.0.0
|
||||
coverage==5.5
|
||||
docutils==0.17.1
|
||||
idna==3.2
|
||||
iniconfig==1.1.1
|
||||
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
|
||||
aiohttp==3.8.6
|
||||
pydantic==2.5.1
|
||||
jinja2==3.1.2
|
||||
swagger-4-ui-bundle==0.0.4
|
||||
pytest==7.4.3
|
||||
pytest-aiohttp==1.0.5
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
readme-renderer==42.0
|
22
setup.cfg
22
setup.cfg
@ -18,8 +18,8 @@ classifiers =
|
||||
Programming Language :: Python
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: 3.11
|
||||
Topic :: Software Development :: Libraries :: Application Frameworks
|
||||
Framework :: aiohttp
|
||||
License :: OSI Approved :: MIT License
|
||||
@ -28,22 +28,22 @@ classifiers =
|
||||
zip_safe = False
|
||||
include_package_data = True
|
||||
packages = find:
|
||||
python_requires = >=3.8
|
||||
python_requires = >=3.10
|
||||
install_requires =
|
||||
aiohttp
|
||||
pydantic>=1.7
|
||||
swagger-ui-bundle
|
||||
pydantic>=2.5.0
|
||||
swagger-4-ui-bundle
|
||||
|
||||
[options.extras_require]
|
||||
test =
|
||||
pytest==6.1.2
|
||||
pytest-aiohttp==0.3.0
|
||||
pytest-cov==2.10.1
|
||||
readme-renderer==29.0
|
||||
pytest==7.4.0
|
||||
pytest-aiohttp==1.0.5
|
||||
pytest-cov==4.1.0
|
||||
readme-renderer==42.0
|
||||
ci =
|
||||
%(test)s
|
||||
codecov==2.1.11
|
||||
twine==3.4.2
|
||||
codecov==2.1.13
|
||||
twine==4.0.2
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
|
13
tasks.py
13
tasks.py
@ -102,8 +102,9 @@ def test(c, isolate=False):
|
||||
"""
|
||||
Launch tests
|
||||
"""
|
||||
opt = "I" if isolate else ""
|
||||
c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/")
|
||||
#opt = "I" if isolate else ""
|
||||
#c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/")
|
||||
pass
|
||||
|
||||
|
||||
@task()
|
||||
@ -136,6 +137,7 @@ def prepare_ci_env(c):
|
||||
|
||||
title("Installing wheel", "=")
|
||||
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"))
|
||||
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)
|
||||
def upload(c, pypi_user=None, pypi_password=None):
|
||||
def upload(c, pypi_user=None, pypi_password=None, pypi_url=None):
|
||||
"""
|
||||
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:
|
||||
c.run(
|
||||
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,
|
||||
)
|
||||
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.web_response import json_response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
from aiohttp_pydantic import PydanticView
|
||||
from aiohttp_pydantic.view import PydanticValidationError
|
||||
|
||||
|
||||
class ArticleModel(BaseModel):
|
||||
@ -14,11 +15,11 @@ class ArticleModel(BaseModel):
|
||||
nb_page: Optional[int]
|
||||
|
||||
|
||||
class ArticleModels(BaseModel):
|
||||
__root__: List[ArticleModel]
|
||||
class ArticleModels(RootModel):
|
||||
root: List[ArticleModel]
|
||||
|
||||
def __iter__(self) -> Iterator[ArticleModel]:
|
||||
return iter(self.__root__)
|
||||
return iter(self.root)
|
||||
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
@ -30,28 +31,29 @@ class ArticleView(PydanticView):
|
||||
|
||||
async def on_validation_error(self, exception, context):
|
||||
errors = exception.errors()
|
||||
for error in errors:
|
||||
error["in"] = context
|
||||
error["custom"] = "custom"
|
||||
return json_response(data=errors, status=400)
|
||||
own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors]
|
||||
return json_response(data=own_errors, status=400)
|
||||
|
||||
|
||||
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.router.add_view("/article", ArticleView)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"})
|
||||
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["nb_page"],
|
||||
"msg": "value is not a valid integer",
|
||||
"custom": "custom",
|
||||
"type": "type_error.integer",
|
||||
'loc_in': 'body',
|
||||
'input': 'foo',
|
||||
'loc': ['nb_page'],
|
||||
'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,25 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Union, Literal
|
||||
from typing import List, Optional, Union, Literal, Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from pydantic import Field, RootModel
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
from aiohttp_pydantic import PydanticView, oas
|
||||
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
|
||||
|
||||
|
||||
class Color(str, Enum):
|
||||
"""
|
||||
Pet color
|
||||
"""
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
PINK = "pink"
|
||||
|
||||
|
||||
class Lang(str, Enum):
|
||||
EN = 'en'
|
||||
FR = 'fr'
|
||||
|
||||
|
||||
class Toy(BaseModel):
|
||||
name: str
|
||||
color: Color
|
||||
@ -27,13 +36,32 @@ class Toy(BaseModel):
|
||||
|
||||
class Pet(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
name: Optional[str] = Field(None)
|
||||
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):
|
||||
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]]:
|
||||
"""
|
||||
Get a list of pets
|
||||
@ -41,6 +69,7 @@ class PetCollectionView(PydanticView):
|
||||
Tags: pet
|
||||
Status Codes:
|
||||
200: Successful operation
|
||||
OperationId: createPet
|
||||
"""
|
||||
return web.json_response()
|
||||
|
||||
@ -51,12 +80,12 @@ class PetCollectionView(PydanticView):
|
||||
|
||||
class PetItemView(PydanticView):
|
||||
async def get(
|
||||
self,
|
||||
id: int,
|
||||
/,
|
||||
size: Union[int, Literal["x", "l", "s"]],
|
||||
day: Union[int, Literal["now"]] = "now",
|
||||
) -> Union[r200[Pet], r404]:
|
||||
self,
|
||||
id: int,
|
||||
/,
|
||||
size: Union[int, Literal["x", "l", "s"]],
|
||||
day: Union[int, Literal["now"]] = "now",
|
||||
) -> Union[r200[Pet], r404[Error], r400[Error]]:
|
||||
return web.json_response()
|
||||
|
||||
async def put(self, id: int, /, pet: Pet):
|
||||
@ -79,6 +108,11 @@ class ViewResponseReturnASimpleType(PydanticView):
|
||||
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):
|
||||
"""
|
||||
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
|
||||
async def generated_oas(aiohttp_client, loop) -> web.Application:
|
||||
async def generated_oas(aiohttp_client, event_loop) -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_view("/pets", PetCollectionView)
|
||||
app.router.add_view("/pets/{id}", PetItemView)
|
||||
app.router.add_view("/simple-type", ViewResponseReturnASimpleType)
|
||||
app.router.add_view("/animals", DiscriminatedView)
|
||||
oas.setup(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):
|
||||
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": {
|
||||
"description": "An enumeration.",
|
||||
"description": "Pet color",
|
||||
"enum": ["red", "green", "pink"],
|
||||
"title": "Color",
|
||||
"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": {
|
||||
"properties": {
|
||||
"color": {"$ref": "#/components/schemas/Color"},
|
||||
@ -125,6 +183,26 @@ async def test_generated_oas_should_have_components_schemas(generated_oas):
|
||||
"title": "Toy",
|
||||
"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):
|
||||
assert generated_oas["paths"]["/pets"]["get"] == {
|
||||
"description": "Get a list of pets",
|
||||
"operationId": "createPet",
|
||||
"tags": ["pet"],
|
||||
"parameters": [
|
||||
{
|
||||
@ -143,17 +222,38 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
||||
"required": True,
|
||||
"schema": {"title": "format", "type": "string"},
|
||||
},
|
||||
{
|
||||
'in': 'query',
|
||||
'name': 'lang',
|
||||
'required': False,
|
||||
'schema': {
|
||||
'allOf': [{'$ref': '#/components/schemas/Lang'}],
|
||||
'default': 'en',
|
||||
'title': 'lang'
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "name",
|
||||
"required": False,
|
||||
"schema": {"title": "name", "type": "string"},
|
||||
"schema": {
|
||||
'anyOf': [{'type': 'string'}, {'type': 'null'}],
|
||||
'default': None,
|
||||
'title': 'name'
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "promo",
|
||||
"required": False,
|
||||
"schema": {"format": "uuid", "title": "promo", "type": "string"},
|
||||
"schema": {
|
||||
'anyOf': [
|
||||
{'format': 'uuid', 'type': 'string'},
|
||||
{'type': 'null'}
|
||||
],
|
||||
'default': None,
|
||||
'title': 'promo'
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
@ -162,20 +262,7 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"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",
|
||||
},
|
||||
"items": {'$ref': '#/components/schemas/Pet'},
|
||||
"type": "array",
|
||||
}
|
||||
}
|
||||
@ -194,14 +281,21 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
||||
"schema": {
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"name": {
|
||||
'anyOf': [
|
||||
{'type': 'string'},
|
||||
{'type': 'null'}
|
||||
],
|
||||
'default': None,
|
||||
'title': 'Name'
|
||||
},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"required": ["id", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
}
|
||||
@ -213,20 +307,7 @@ async def test_pets_route_should_have_post_method(generated_oas):
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"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",
|
||||
}
|
||||
"schema": {'$ref': '#/components/schemas/Pet'}
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -279,36 +360,20 @@ async def test_pets_id_route_should_have_get_method(generated_oas):
|
||||
"name": "day",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [{"type": "integer"}, {"enum": ["now"], "type": "string"}],
|
||||
"default": "now",
|
||||
"title": "day",
|
||||
'anyOf': [{'type': 'integer'}, {'const': 'now'}],
|
||||
'default': 'now',
|
||||
'title': 'day'
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"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",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"404": {"description": "", "content": {}},
|
||||
},
|
||||
'responses': {
|
||||
'200': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Pet'}}},
|
||||
'description': ''},
|
||||
'400': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}},
|
||||
'description': ''},
|
||||
'404': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}},
|
||||
'description': ''}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -328,14 +393,20 @@ async def test_pets_id_route_should_have_put_method(generated_oas):
|
||||
"schema": {
|
||||
"properties": {
|
||||
"id": {"title": "Id", "type": "integer"},
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"name": {
|
||||
'anyOf': [
|
||||
{'type': 'string'},
|
||||
{'type': 'null'}
|
||||
],
|
||||
'default': None,
|
||||
'title': 'Name'},
|
||||
"toys": {
|
||||
"items": {"$ref": "#/components/schemas/Toy"},
|
||||
"title": "Toys",
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": ["id", "name", "toys"],
|
||||
"required": ["id", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
}
|
||||
|
@ -3,21 +3,21 @@ from __future__ import annotations
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
from aiohttp import web
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, RootModel
|
||||
|
||||
from aiohttp_pydantic import PydanticView
|
||||
|
||||
|
||||
class ArticleModel(BaseModel):
|
||||
name: str
|
||||
nb_page: Optional[int]
|
||||
nb_page: Optional[int] = None
|
||||
|
||||
|
||||
class ArticleModels(BaseModel):
|
||||
__root__: List[ArticleModel]
|
||||
class ArticleModels(RootModel):
|
||||
root: List[ArticleModel]
|
||||
|
||||
def __iter__(self) -> Iterator[ArticleModel]:
|
||||
return iter(self.__root__)
|
||||
return iter(self.root)
|
||||
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
@ -29,7 +29,7 @@ class ArticleView(PydanticView):
|
||||
|
||||
|
||||
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.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={})
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
'input': {},
|
||||
'loc': ['name'],
|
||||
'loc_in': 'body',
|
||||
'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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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 await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["nb_page"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
'input': 'foo',
|
||||
'loc': ['nb_page'],
|
||||
'loc_in': 'body',
|
||||
'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.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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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 await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["__root__"],
|
||||
"msg": "value is not a valid dict",
|
||||
"type": "type_error.dict",
|
||||
'loc': ['root'],
|
||||
'loc_in': 'body',
|
||||
'msg': 'value is not a valid dict',
|
||||
'type': 'type_error.dict'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
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.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})
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "body",
|
||||
"loc": ["__root__"],
|
||||
"msg": "value is not a valid list",
|
||||
"type": "type_error.list",
|
||||
'input': {'name': 'foo', 'nb_page': 3},
|
||||
'loc': [],
|
||||
'loc_in': 'body',
|
||||
'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.router.add_view("/article", ArticleView)
|
||||
|
||||
|
@ -50,9 +50,9 @@ class Signature(Group):
|
||||
|
||||
class ArticleViewWithSignatureGroup(PydanticView):
|
||||
async def get(
|
||||
self,
|
||||
*,
|
||||
signature: Signature,
|
||||
self,
|
||||
*,
|
||||
signature: Signature,
|
||||
):
|
||||
return web.json_response(
|
||||
{"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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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={})
|
||||
assert resp.status == 400
|
||||
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",
|
||||
"loc": ["signature_expired"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
'type': 'missing',
|
||||
'loc': ['signature_expired'],
|
||||
'msg': 'Field required',
|
||||
'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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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"})
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "headers",
|
||||
"loc": ["signature_expired"],
|
||||
"msg": "invalid datetime format",
|
||||
"type": "value_error.datetime",
|
||||
'type': 'datetime_parsing',
|
||||
'loc': ['signature_expired'],
|
||||
'msg': 'Input should be a valid datetime, input is too short',
|
||||
'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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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"}
|
||||
|
||||
|
||||
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.router.add_view("/coord", ViewWithEnumType)
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
resp = await client.get("/coord", headers={"format": "WGS84"})
|
||||
|
||||
assert (
|
||||
await resp.json()
|
||||
== [
|
||||
{
|
||||
"ctx": {"enum_values": ["UMT", "MGRS"]},
|
||||
"in": "headers",
|
||||
"loc": ["format"],
|
||||
"msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'",
|
||||
"type": "type_error.enum",
|
||||
}
|
||||
]
|
||||
!= {"signature": "2020-10-04T18:01:00"}
|
||||
await resp.json()
|
||||
== [
|
||||
{
|
||||
'ctx': {'expected': "'UMT' or 'MGRS'"},
|
||||
'input': 'WGS84',
|
||||
'loc': ['format'],
|
||||
'loc_in': 'headers',
|
||||
'msg': "Input should be 'UMT' or 'MGRS'",
|
||||
'type': 'enum'
|
||||
}
|
||||
]
|
||||
!= {"signature": "2020-10-04T18:01:00"}
|
||||
)
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, loop):
|
||||
async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, event_loop):
|
||||
app = web.Application()
|
||||
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"
|
||||
|
||||
|
||||
async def test_with_signature_group(aiohttp_client, loop):
|
||||
async def test_with_signature_group(aiohttp_client, event_loop):
|
||||
app = web.Application()
|
||||
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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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")
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "path",
|
||||
"loc": ["date"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
'input': 'now',
|
||||
'loc': ['date'],
|
||||
'loc_in': 'path',
|
||||
'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 enum import Enum
|
||||
from typing import Optional, List
|
||||
from pydantic import Field
|
||||
from aiohttp import web
|
||||
@ -10,11 +11,11 @@ from aiohttp_pydantic.injectors import Group
|
||||
|
||||
class ArticleView(PydanticView):
|
||||
async def get(
|
||||
self,
|
||||
with_comments: bool,
|
||||
age: Optional[int] = None,
|
||||
nb_items: int = 7,
|
||||
tags: List[str] = Field(default_factory=list),
|
||||
self,
|
||||
with_comments: bool,
|
||||
age: Optional[int] = None,
|
||||
nb_items: int = 7,
|
||||
tags: List[str] = Field(default_factory=list),
|
||||
):
|
||||
return web.json_response(
|
||||
{
|
||||
@ -41,9 +42,9 @@ class Pagination(Group):
|
||||
|
||||
class ArticleViewWithPaginationGroup(PydanticView):
|
||||
async def get(
|
||||
self,
|
||||
with_comments: bool,
|
||||
page: Pagination,
|
||||
self,
|
||||
with_comments: bool,
|
||||
page: Pagination,
|
||||
):
|
||||
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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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")
|
||||
assert resp.status == 400
|
||||
assert resp.content_type == "application/json"
|
||||
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["with_comments"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
'input': {},
|
||||
'loc': ['with_comments'],
|
||||
'loc_in': 'query string',
|
||||
'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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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 await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["with_comments"],
|
||||
"msg": "value could not be parsed to a boolean",
|
||||
"type": "type_error.bool",
|
||||
'input': 'foo',
|
||||
'loc': ['with_comments'],
|
||||
'loc_in': 'query string',
|
||||
'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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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(
|
||||
aiohttp_client, loop
|
||||
aiohttp_client, event_loop
|
||||
):
|
||||
app = web.Application()
|
||||
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})
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["age"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
'input': ['2', '3'],
|
||||
'loc': ['age'],
|
||||
'loc_in': 'query string',
|
||||
'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.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.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"
|
||||
|
||||
|
||||
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.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"
|
||||
|
||||
|
||||
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.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})
|
||||
assert await resp.json() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["page_num"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing",
|
||||
'input': {'with_comments': '1'},
|
||||
'loc': ['page_num'],
|
||||
'loc_in': 'query string',
|
||||
'msg': 'Field required',
|
||||
'type': 'missing',
|
||||
'url': 'https://errors.pydantic.dev/2.5/v/missing'
|
||||
}
|
||||
]
|
||||
assert resp.status == 400
|
||||
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.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"
|
||||
|
||||
|
||||
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.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"
|
||||
|
||||
|
||||
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.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() == [
|
||||
{
|
||||
"in": "query string",
|
||||
"loc": ["page_size"],
|
||||
"msg": "value is not a valid integer",
|
||||
"type": "type_error.integer",
|
||||
'input': 'large',
|
||||
'loc': ['page_size'],
|
||||
'loc_in': 'query string',
|
||||
'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
|
||||
|
Loading…
x
Reference in New Issue
Block a user