Compare commits

4 Commits

Author SHA1 Message Date
Vincent Maillol
03854cf939 Update version 1.4.1 2020-11-03 13:14:16 +01:00
Vincent Maillol
2db23d3328 PydanticView returns 400 if the request payload is not JSON 2020-11-03 13:10:44 +01:00
Vincent Maillol
d866ce5358 fix bug we cannot use optional params 2020-11-03 12:54:28 +01:00
Vincent Maillol
13c19105d8 Update README.rst 2020-11-02 23:27:45 +01:00
9 changed files with 67 additions and 29 deletions

View File

@@ -1,6 +1,18 @@
Aiohttp pydantic - Aiohttp View to validate and parse request Aiohttp pydantic - Aiohttp View to validate and parse request
============================================================= =============================================================
Aiohttp pydantic is an `aiohttp view`_ to easily parse and validate request.
You define using the function annotations what your methods for handling HTTP verbs expects and Aiohttp pydantic parses the HTTP request
for you, validates the data, and injects that you want as parameters.
Features:
- Query string, request body, URL path and HTTP headers validation.
- Open API Specification generation.
How to install How to install
-------------- --------------
@@ -254,3 +266,4 @@ You can generate the OAS in a json file using the command:
.. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo
.. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views

View File

@@ -1,5 +1,5 @@
from .view import PydanticView from .view import PydanticView
__version__ = "1.4.0" __version__ = "1.4.1"
__all__ = ("PydanticView", "__version__") __all__ = ("PydanticView", "__version__")

View File

@@ -1,10 +1,14 @@
import abc import abc
from inspect import signature from inspect import signature
from json.decoder import JSONDecodeError
from typing import Callable, Tuple from typing import Callable, Tuple
from aiohttp.web_exceptions import HTTPBadRequest
from aiohttp.web_request import BaseRequest from aiohttp.web_request import BaseRequest
from pydantic import BaseModel from pydantic import BaseModel
from .utils import is_pydantic_base_model
class AbstractInjector(metaclass=abc.ABCMeta): class AbstractInjector(metaclass=abc.ABCMeta):
""" """
@@ -45,7 +49,13 @@ class BodyGetter(AbstractInjector):
self.arg_name, self.model = next(iter(args_spec.items())) self.arg_name, self.model = next(iter(args_spec.items()))
async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict):
body = await request.json() try:
body = await request.json()
except JSONDecodeError:
raise HTTPBadRequest(
text='{"error": "Malformed JSON"}', content_type="application/json"
)
kwargs_view[self.arg_name] = self.model(**body) kwargs_view[self.arg_name] = self.model(**body)
@@ -98,7 +108,7 @@ def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]:
if param_spec.kind is param_spec.POSITIONAL_ONLY: if param_spec.kind is param_spec.POSITIONAL_ONLY:
path_args[param_name] = param_spec.annotation path_args[param_name] = param_spec.annotation
elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD:
if issubclass(param_spec.annotation, BaseModel): if is_pydantic_base_model(param_spec.annotation):
body_args[param_name] = param_spec.annotation body_args[param_name] = param_spec.annotation
else: else:
qs_args[param_name] = param_spec.annotation qs_args[param_name] = param_spec.annotation

View File

@@ -3,27 +3,17 @@ from typing import List, Type
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from aiohttp.web_app import Application from aiohttp.web_app import Application
from pydantic import BaseModel
from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem
from ..injectors import _parse_func_signature from ..injectors import _parse_func_signature
from ..utils import is_pydantic_base_model
from ..view import PydanticView, is_pydantic_view from ..view import PydanticView, is_pydantic_view
from .typing import is_status_code_type from .typing import is_status_code_type
JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"} JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"}
def _is_pydantic_base_model(obj):
"""
Return true is obj is a pydantic.BaseModel subclass.
"""
try:
return issubclass(obj, BaseModel)
except TypeError:
return False
class _OASResponseBuilder: class _OASResponseBuilder:
""" """
Parse the type annotated as returned by a function and Parse the type annotated as returned by a function and
@@ -35,7 +25,7 @@ class _OASResponseBuilder:
@staticmethod @staticmethod
def _handle_pydantic_base_model(obj): def _handle_pydantic_base_model(obj):
if _is_pydantic_base_model(obj): if is_pydantic_base_model(obj):
return obj.schema() return obj.schema()
return {} return {}

11
aiohttp_pydantic/utils.py Normal file
View File

@@ -0,0 +1,11 @@
from pydantic import BaseModel
def is_pydantic_base_model(obj):
"""
Return true is obj is a pydantic.BaseModel subclass.
"""
try:
return issubclass(obj, BaseModel)
except TypeError:
return False

View File

@@ -9,14 +9,8 @@ from aiohttp.web_exceptions import HTTPMethodNotAllowed
from aiohttp.web_response import StreamResponse from aiohttp.web_response import StreamResponse
from pydantic import ValidationError from pydantic import ValidationError
from .injectors import ( from .injectors import (AbstractInjector, BodyGetter, HeadersGetter,
AbstractInjector, MatchInfoGetter, QueryGetter, _parse_func_signature)
BodyGetter,
HeadersGetter,
MatchInfoGetter,
QueryGetter,
_parse_func_signature,
)
class PydanticView(AbstractView): class PydanticView(AbstractView):

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel
class Pet(BaseModel): class Pet(BaseModel):
id: int id: int
name: str name: str
age: int
class Error(BaseModel): class Error(BaseModel):

View File

@@ -1,4 +1,4 @@
from typing import List, Union from typing import List, Optional, Union
from aiohttp import web from aiohttp import web
@@ -9,9 +9,11 @@ from .model import Error, Pet
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get(self) -> r200[List[Pet]]: async def get(self, age: Optional[int] = None) -> r200[List[Pet]]:
pets = self.request.app["model"].list_pets() pets = self.request.app["model"].list_pets()
return web.json_response([pet.dict() for pet in pets]) return web.json_response(
[pet.dict() for pet in pets if age is None or age == pet.age]
)
async def post(self, pet: Pet) -> r201[Pet]: async def post(self, pet: Pet) -> r201[Pet]:
self.request.app["model"].add_pet(pet) self.request.app["model"].add_pet(pet)

View File

@@ -1,11 +1,13 @@
from typing import Optional
from aiohttp import web from aiohttp import web
from aiohttp_pydantic import PydanticView from aiohttp_pydantic import PydanticView
class ArticleView(PydanticView): class ArticleView(PydanticView):
async def get(self, with_comments: bool): async def get(self, with_comments: bool, age: Optional[int] = None):
return web.json_response({"with_comments": with_comments}) return web.json_response({"with_comments": with_comments, "age": age})
async def test_get_article_without_required_qs_should_return_an_error_message( async def test_get_article_without_required_qs_should_return_an_error_message(
@@ -53,7 +55,22 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type(
app.router.add_view("/article", ArticleView) app.router.add_view("/article", ArticleView)
client = await aiohttp_client(app) client = await aiohttp_client(app)
resp = await client.get("/article", params={"with_comments": "yes", "age": 3})
assert resp.status == 200
assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True, "age": 3}
async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none(
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"}) resp = await client.get("/article", params={"with_comments": "yes"})
assert resp.status == 200 assert resp.status == 200
assert resp.content_type == "application/json" assert resp.content_type == "application/json"
assert await resp.json() == {"with_comments": True} assert await resp.json() == {"with_comments": True, "age": None}