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