Create aiohttp_pydantic
This commit is contained in:
parent
a397f3d8ec
commit
97ede771d2
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
87
aiohttp_pydantic/__init__.py
Normal file
87
aiohttp_pydantic/__init__.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
from aiohttp.abc import AbstractView
|
||||||
|
from aiohttp.hdrs import METH_ALL
|
||||||
|
from aiohttp.web_exceptions import HTTPMethodNotAllowed
|
||||||
|
from aiohttp.web_response import StreamResponse
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from typing import Generator, Any
|
||||||
|
from aiohttp.web import json_response
|
||||||
|
|
||||||
|
|
||||||
|
class PydanticView(AbstractView):
|
||||||
|
|
||||||
|
async def _iter(self) -> StreamResponse:
|
||||||
|
method = getattr(self, self.request.method.lower(), None)
|
||||||
|
resp = await method()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def __await__(self) -> Generator[Any, None, StreamResponse]:
|
||||||
|
return self._iter().__await__()
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
allowed_methods = {
|
||||||
|
meth_name for meth_name in METH_ALL
|
||||||
|
if hasattr(cls, meth_name.lower())}
|
||||||
|
|
||||||
|
async def raise_not_allowed(self):
|
||||||
|
raise HTTPMethodNotAllowed(self.request.method, allowed_methods)
|
||||||
|
|
||||||
|
if 'GET' in allowed_methods:
|
||||||
|
cls.get = inject_qs(cls.get)
|
||||||
|
if 'POST' in allowed_methods:
|
||||||
|
cls.post = inject_body(cls.post)
|
||||||
|
if 'PUT' in allowed_methods:
|
||||||
|
cls.put = inject_body(cls.put)
|
||||||
|
|
||||||
|
for meth_name in METH_ALL:
|
||||||
|
if meth_name not in allowed_methods:
|
||||||
|
setattr(cls, meth_name.lower(), raise_not_allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_qs(handler):
|
||||||
|
"""
|
||||||
|
Decorator to unpack the query string in the parameters of the web handler
|
||||||
|
regarding annotations.
|
||||||
|
"""
|
||||||
|
qs_model_class = type(
|
||||||
|
'QSModel', (BaseModel,),
|
||||||
|
{'__annotations__': handler.__annotations__})
|
||||||
|
|
||||||
|
async def wrapped_handler(self):
|
||||||
|
try:
|
||||||
|
qs = qs_model_class(**self.request.query)
|
||||||
|
except ValidationError as error:
|
||||||
|
return json_response(text=error.json(), status=400)
|
||||||
|
# raise HTTPBadRequest(
|
||||||
|
# reason='\n'.join(
|
||||||
|
# f'Error with query string parameter {", ".join(err["loc"])}:'
|
||||||
|
# f' {err["msg"]}' for err in error.errors()))
|
||||||
|
|
||||||
|
return await handler(self, **qs.dict())
|
||||||
|
|
||||||
|
return wrapped_handler
|
||||||
|
|
||||||
|
|
||||||
|
def inject_body(handler):
|
||||||
|
"""
|
||||||
|
Decorator to inject the request body as parameter of the web handler
|
||||||
|
regarding annotations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
arg_name, model_class = next(
|
||||||
|
((arg_name, arg_type)
|
||||||
|
for arg_name, arg_type in handler.__annotations__.items()
|
||||||
|
if issubclass(arg_type, BaseModel)), (None, None))
|
||||||
|
|
||||||
|
if arg_name is None:
|
||||||
|
return handler
|
||||||
|
|
||||||
|
async def wrapped_handler(self):
|
||||||
|
body = await self.request.json()
|
||||||
|
try:
|
||||||
|
model = model_class(**body)
|
||||||
|
except ValidationError as error:
|
||||||
|
return json_response(text=error.json(), status=400)
|
||||||
|
|
||||||
|
return await handler(self, **{arg_name: model})
|
||||||
|
|
||||||
|
return wrapped_handler
|
30
setup.py
Normal file
30
setup.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='aiohttp_pydantic',
|
||||||
|
version='0.0.1',
|
||||||
|
description='Aiohttp View using pydantic to validate request body and query sting regarding method annotation',
|
||||||
|
keywords='aiohttp pydantic annotation unpack inject validate',
|
||||||
|
author='Vincent Maillol',
|
||||||
|
author_email='vincent.maillol@gmail.com',
|
||||||
|
url='https://github.com/Maillol/aiohttp-pydantic',
|
||||||
|
license='MIT',
|
||||||
|
packages=['aiohttp_pydantic'],
|
||||||
|
classifiers=[
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'Intended Audience :: Information Technology',
|
||||||
|
'Programming Language :: Python',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Programming Language :: Python :: 3.9',
|
||||||
|
'Topic :: Software Development :: Libraries :: Application Frameworks',
|
||||||
|
'Framework :: AsyncIO',
|
||||||
|
'License :: OSI Approved :: MIT License'
|
||||||
|
],
|
||||||
|
python_requires='>=3.6',
|
||||||
|
install_requires=['aiohttp', 'pydantic']
|
||||||
|
)
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
52
tests/test_validation_body.py
Normal file
52
tests/test_validation_body.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp_pydantic import PydanticView
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleModel(BaseModel):
|
||||||
|
name: str
|
||||||
|
nb_page: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleView(PydanticView):
|
||||||
|
|
||||||
|
async def post(self, article: ArticleModel):
|
||||||
|
return web.Response(article.dict())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_an_article_without_required_field_should_return_an_error_message(aiohttp_client, loop):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view('/article', ArticleView)
|
||||||
|
|
||||||
|
client = await aiohttp_client(app)
|
||||||
|
resp = await client.post('/article', json={})
|
||||||
|
assert resp.status == 400
|
||||||
|
assert resp.content_type == 'application/json'
|
||||||
|
assert await resp.json() == [{'loc': ['name'],
|
||||||
|
'msg': 'field required',
|
||||||
|
'type': 'value_error.missing'}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(aiohttp_client, 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() == [{'loc': ['nb_page'],
|
||||||
|
'msg': 'value is not a valid integer',
|
||||||
|
'type': 'type_error.integer'}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, 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': 3})
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == 'application/json'
|
||||||
|
assert await resp.json() == {'name': 'foo', 'nb_page': 3}
|
45
tests/test_validation_query_string.py
Normal file
45
tests/test_validation_query_string.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
from aiohttp import web
|
||||||
|
from aiohttp_pydantic import PydanticView
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleView(PydanticView):
|
||||||
|
|
||||||
|
async def get(self, with_comments: bool):
|
||||||
|
return web.json_response({'with_comments': with_comments})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_article_without_required_qs_should_return_an_error_message(aiohttp_client, loop):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view('/article', ArticleView)
|
||||||
|
|
||||||
|
client = await aiohttp_client(app)
|
||||||
|
resp = await client.get('/article')
|
||||||
|
assert resp.status == 400
|
||||||
|
assert resp.content_type == 'application/json'
|
||||||
|
assert await resp.json() == [{'loc': ['with_comments'],
|
||||||
|
'msg': 'field required',
|
||||||
|
'type': 'value_error.missing'}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_article_with_wrong_qs_type_should_return_an_error_message(aiohttp_client, loop):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view('/article', ArticleView)
|
||||||
|
|
||||||
|
client = await aiohttp_client(app)
|
||||||
|
resp = await client.get('/article', params={'with_comments': 'foo'})
|
||||||
|
assert resp.status == 400
|
||||||
|
assert resp.content_type == 'application/json'
|
||||||
|
assert await resp.json() == [{'loc': ['with_comments'],
|
||||||
|
'msg': 'value could not be parsed to a boolean',
|
||||||
|
'type': 'type_error.bool'}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_article_with_valide_qs_should_return_the_parsed_type(aiohttp_client, loop):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_view('/article', ArticleView)
|
||||||
|
|
||||||
|
client = await aiohttp_client(app)
|
||||||
|
resp = await client.get('/article', params={'with_comments': 'yes'})
|
||||||
|
assert resp.status == 200
|
||||||
|
assert resp.content_type == 'application/json'
|
||||||
|
assert await resp.json() == {'with_comments': True}
|
Loading…
x
Reference in New Issue
Block a user