Create aiohttp_pydantic
This commit is contained in:
		
							
								
								
									
										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} | ||||
		Reference in New Issue
	
	Block a user