Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ad2c111fa1 | ||
|
5058b58991 | ||
|
23f08ad5a5 | ||
|
77cef551a1 | ||
|
5c5a701daa | ||
|
5ead48ec92 | ||
|
1f2ac30106 | ||
|
483b457b14 | ||
|
3126c2fd2e | ||
|
a45101637c | ||
|
937c09e2b7 | ||
|
82c638c1e0 | ||
|
7ce5e5d0d4 | ||
|
93e391b7b2 | ||
|
a94c9d4863 | ||
|
26fd6fa19f | ||
|
be944ac98e |
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
|
@@ -3,7 +3,7 @@ stages:
|
||||
|
||||
publish-pypi:
|
||||
stage: package
|
||||
image: python:3.10
|
||||
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
|
||||
|
@@ -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:
|
||||
|
@@ -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,7 +32,7 @@ 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)
|
||||
self._oas.components.schemas.update({response_schema['title']: response_schema})
|
||||
return {'$ref': f'#/components/schemas/{response_schema["title"]}'}
|
||||
@@ -99,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 = {
|
||||
@@ -117,19 +117,22 @@ 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("definitions", None):
|
||||
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")
|
||||
|
@@ -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.
|
||||
@@ -101,10 +107,9 @@ 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(
|
||||
@@ -146,6 +151,7 @@ def is_pydantic_view(obj) -> bool:
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PydanticValidationError",
|
||||
"AbstractInjector",
|
||||
"BodyGetter",
|
||||
"HeadersGetter",
|
||||
|
@@ -1,42 +1,12 @@
|
||||
aiohttp==3.8.1
|
||||
aiosignal==1.2.0
|
||||
async-timeout==4.0.2
|
||||
atomicwrites==1.4.1
|
||||
attrs==21.4.0
|
||||
bleach==5.0.1
|
||||
certifi==2022.6.15
|
||||
charset-normalizer==2.1.0
|
||||
codecov==2.1.12
|
||||
colorama==0.4.5
|
||||
commonmark==0.9.1
|
||||
coverage==6.4.2
|
||||
docutils==0.19
|
||||
frozenlist==1.3.0
|
||||
idna==3.3
|
||||
importlib-metadata==4.12.0
|
||||
iniconfig==1.1.1
|
||||
keyring==23.7.0
|
||||
multidict==6.0.2
|
||||
packaging==21.3
|
||||
pkginfo==1.8.3
|
||||
pluggy==1.0.0
|
||||
py==1.11.0
|
||||
Pygments==2.12.0
|
||||
pyparsing==3.0.9
|
||||
pytest==7.1.2
|
||||
pytest-aiohttp==1.0.4
|
||||
pytest-asyncio==0.19.0
|
||||
pytest-cov==3.0.0
|
||||
pywin32-ctypes==0.2.0
|
||||
readme-renderer==35.0
|
||||
requests==2.28.1
|
||||
requests-toolbelt==0.9.1
|
||||
rfc3986==2.0.0
|
||||
rich==12.5.1
|
||||
six==1.16.0
|
||||
tomli==2.0.1
|
||||
twine==4.0.1
|
||||
urllib3==1.26.11
|
||||
webencodings==0.5.1
|
||||
yarl==1.7.2
|
||||
zipp==3.8.1
|
||||
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,28 +1,9 @@
|
||||
aiohttp==3.8.1
|
||||
aiosignal==1.2.0
|
||||
async-timeout==4.0.2
|
||||
atomicwrites==1.4.1
|
||||
attrs==21.4.0
|
||||
bleach==5.0.1
|
||||
charset-normalizer==2.1.0
|
||||
colorama==0.4.5
|
||||
coverage==6.4.2
|
||||
docutils==0.19
|
||||
frozenlist==1.3.0
|
||||
idna==3.3
|
||||
iniconfig==1.1.1
|
||||
multidict==6.0.2
|
||||
packaging==21.3
|
||||
pluggy==1.0.0
|
||||
py==1.11.0
|
||||
Pygments==2.12.0
|
||||
pyparsing==3.0.9
|
||||
pytest==7.1.2
|
||||
pytest-aiohttp==1.0.4
|
||||
pytest-asyncio==0.19.0
|
||||
pytest-cov==3.0.0
|
||||
readme-renderer==35.0
|
||||
six==1.16.0
|
||||
tomli==2.0.1
|
||||
webencodings==0.5.1
|
||||
yarl==1.7.2
|
||||
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
|
20
setup.cfg
20
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
|
||||
pydantic>=2.5.0
|
||||
swagger-4-ui-bundle
|
||||
|
||||
[options.extras_require]
|
||||
test =
|
||||
pytest==7.1.2
|
||||
pytest-aiohttp==1.0.4
|
||||
pytest-cov==3.0.0
|
||||
readme-renderer==35.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.12
|
||||
twine==4.0.1
|
||||
codecov==2.1.13
|
||||
twine==4.0.2
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
|
@@ -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,10 +31,8 @@ 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(
|
||||
@@ -47,12 +46,14 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess
|
||||
|
||||
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,12 +1,12 @@
|
||||
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
|
||||
from pydantic import Field, RootModel
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
from aiohttp_pydantic import PydanticView, oas
|
||||
@@ -16,6 +16,9 @@ from aiohttp_pydantic.oas.view import generate_oas
|
||||
|
||||
|
||||
class Color(str, Enum):
|
||||
"""
|
||||
Pet color
|
||||
"""
|
||||
RED = "red"
|
||||
GREEN = "green"
|
||||
PINK = "pink"
|
||||
@@ -42,6 +45,20 @@ class Error(BaseModel):
|
||||
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, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None
|
||||
@@ -91,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
|
||||
@@ -115,6 +137,7 @@ async def generated_oas(aiohttp_client, event_loop) -> 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))
|
||||
@@ -122,14 +145,31 @@ async def generated_oas(aiohttp_client, event_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': {
|
||||
'description': 'An enumeration.',
|
||||
'enum': ['en', 'fr'],
|
||||
'title': 'Lang',
|
||||
'type': 'string'
|
||||
@@ -143,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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,13 +236,24 @@ async def test_pets_route_should_have_get_method(generated_oas):
|
||||
"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": {
|
||||
@@ -191,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", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
},
|
||||
"items": {'$ref': '#/components/schemas/Pet'},
|
||||
"type": "array",
|
||||
}
|
||||
}
|
||||
@@ -223,7 +281,14 @@ 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",
|
||||
@@ -242,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", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
}
|
||||
"schema": {'$ref': '#/components/schemas/Pet'}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -308,37 +360,21 @@ 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", "toys"],
|
||||
"title": "Pet",
|
||||
"type": "object",
|
||||
'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': ''}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"404": {"description": "", "content": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_pets_id_route_should_have_put_method(generated_oas):
|
||||
@@ -357,7 +393,13 @@ 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",
|
||||
|
@@ -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):
|
||||
@@ -38,12 +38,15 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -60,10 +63,12 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -92,10 +97,10 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -110,12 +115,15 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
|
@@ -70,12 +70,18 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -90,12 +96,16 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -136,15 +146,17 @@ async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, event
|
||||
|
||||
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",
|
||||
'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"}
|
||||
|
@@ -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'
|
||||
}
|
||||
]
|
||||
|
@@ -79,12 +79,15 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -101,10 +104,12 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -158,10 +163,12 @@ 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
|
||||
@@ -215,10 +222,12 @@ async def test_get_article_without_required_field_page(aiohttp_client, event_loo
|
||||
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
|
||||
@@ -276,10 +285,13 @@ async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_l
|
||||
)
|
||||
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
|
||||
|
Reference in New Issue
Block a user