diff --git a/aiohttp_pydantic/__init__.py b/aiohttp_pydantic/__init__.py index e4c4db7..0524b62 100644 --- a/aiohttp_pydantic/__init__.py +++ b/aiohttp_pydantic/__init__.py @@ -1,5 +1,5 @@ from .view import PydanticView -__version__ = "1.7.2" +__version__ = "1.8.0" __all__ = ("PydanticView", "__version__") diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 75284ae..2c65b81 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -61,6 +61,7 @@ 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" async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): try: @@ -70,7 +71,16 @@ class BodyGetter(AbstractInjector): text='{"error": "Malformed JSON"}', content_type="application/json" ) from None - kwargs_view[self.arg_name] = self.model(**body) + # Pydantic tries to cast certain structures, such as a list of 2-tuples, + # 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 ' + 'valid dict", "type": "type_error.dict"}]', + content_type="application/json", + ) from None + + kwargs_view[self.arg_name] = self.model.parse_obj(body) class QueryGetter(AbstractInjector): diff --git a/tests/test_validation_body.py b/tests/test_validation_body.py index 424f136..6a7fdd7 100644 --- a/tests/test_validation_body.py +++ b/tests/test_validation_body.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Iterator, List, Optional from aiohttp import web from pydantic import BaseModel @@ -11,10 +11,20 @@ class ArticleModel(BaseModel): nb_page: Optional[int] +class ArticleModels(BaseModel): + __root__: List[ArticleModel] + + def __iter__(self) -> Iterator[ArticleModel]: + return iter(self.__root__) + + class ArticleView(PydanticView): async def post(self, article: ArticleModel): return web.json_response(article.dict()) + async def put(self, articles: ArticleModels): + return web.json_response([article.dict() for article in articles]) + async def test_post_an_article_without_required_field_should_return_an_error_message( aiohttp_client, loop @@ -56,6 +66,58 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess ] +async def test_post_an_array_json_is_supported(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + body = [{"name": "foo", "nb_page": 3}] * 2 + resp = await client.put("/article", json=body) + assert resp.status == 200 + assert resp.content_type == "application/json" + assert await resp.json() == body + + +async def test_post_an_array_json_to_an_object_model_should_return_an_error( + 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 == 400 + 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", + } + ] + + +async def test_post_an_object_json_to_a_list_model_should_return_an_error( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + 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", + } + ] + + async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop): app = web.Application() app.router.add_view("/article", ArticleView)