diff --git a/README.rst b/README.rst index dd8cd69..7ad0c03 100644 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ Example: return web.json_response({'name': article.name, 'number_of_page': article.nb_page}) - async def get(self, with_comments: Optional[bool]): + async def get(self, with_comments: bool=False): return web.json_response({'with_comments': with_comments}) @@ -101,7 +101,7 @@ API: Inject Path Parameters ~~~~~~~~~~~~~~~~~~~~~~ -To declare a path parameters, you must declare your argument as a `positional-only parameters`_: +To declare a path parameter, you must declare your argument as a `positional-only parameters`_: Example: @@ -118,18 +118,33 @@ Example: Inject Query String Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To declare a query parameters, you must declare your argument as a simple argument: +To declare a query parameter, you must declare your argument as a simple argument: .. code-block:: python3 class AccountView(PydanticView): - async def get(self, customer_id: str): + async def get(self, customer_id: Optional[str] = None): ... app = web.Application() app.router.add_get('/customers', AccountView) + +A query string parameter is generally optional and we do not want to force the user to set it in the URL. +It's recommended to define a default value. It's possible to get a multiple value for the same parameter using +the List type + +.. code-block:: python3 + + class AccountView(PydanticView): + async def get(self, tags: List[str] = []): + ... + + app = web.Application() + app.router.add_get('/customers', AccountView) + + Inject Request Body ~~~~~~~~~~~~~~~~~~~ @@ -152,7 +167,7 @@ To declare a body parameter, you must declare your argument as a simple argument Inject HTTP headers ~~~~~~~~~~~~~~~~~~~ -To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_. +To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_. .. code-block:: python3 diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 2c65b81..1f5ae24 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -1,10 +1,12 @@ import abc +import typing from inspect import signature from json.decoder import JSONDecodeError from typing import Callable, Tuple from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest +from multidict import MultiDict from pydantic import BaseModel from .utils import is_pydantic_base_model @@ -94,9 +96,27 @@ class QueryGetter(AbstractInjector): attrs = {"__annotations__": args_spec} attrs.update(default_values) self.model = type("QueryModel", (BaseModel,), attrs) + self.args_spec = args_spec + self._is_multiple = frozenset( + name for name, spec in args_spec.items() if typing.get_origin(spec) is list + ) def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): - kwargs_view.update(self.model(**request.query).dict()) + kwargs_view.update(self.model(**self._query_to_dict(request.query)).dict()) + + def _query_to_dict(self, query: MultiDict): + """ + Return a dict with list as value from the MultiDict. + + The value will be wrapped in a list if the args spec is define as a list or if + the multiple values are sent (i.e ?foo=1&foo=2) + """ + return { + key: values + if len(values := query.getall(key)) > 1 or key in self._is_multiple + else value + for key, value in query.items() + } class HeadersGetter(AbstractInjector): diff --git a/tests/test_oas/test_view.py b/tests/test_oas/test_view.py index acdbe83..adf653f 100644 --- a/tests/test_oas/test_view.py +++ b/tests/test_oas/test_view.py @@ -59,7 +59,7 @@ class PetItemView(PydanticView): return web.json_response() -class TestResponseReturnASimpleType(PydanticView): +class ViewResponseReturnASimpleType(PydanticView): async def get(self) -> r200[int]: """ Status Codes: @@ -73,7 +73,7 @@ async def generated_oas(aiohttp_client, loop) -> web.Application: app = web.Application() app.router.add_view("/pets", PetCollectionView) app.router.add_view("/pets/{id}", PetItemView) - app.router.add_view("/simple-type", TestResponseReturnASimpleType) + app.router.add_view("/simple-type", ViewResponseReturnASimpleType) oas.setup(app) client = await aiohttp_client(app) diff --git a/tests/test_validation_query_string.py b/tests/test_validation_query_string.py index 363c461..b25ff43 100644 --- a/tests/test_validation_query_string.py +++ b/tests/test_validation_query_string.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from aiohttp import web @@ -7,10 +7,19 @@ from aiohttp_pydantic import PydanticView class ArticleView(PydanticView): async def get( - self, with_comments: bool, age: Optional[int] = None, nb_items: int = 7 + self, + with_comments: bool, + age: Optional[int] = None, + nb_items: int = 7, + tags: List[str] = [], ): return web.json_response( - {"with_comments": with_comments, "age": age, "nb_items": nb_items} + { + "with_comments": with_comments, + "age": age, + "nb_items": nb_items, + "tags": tags, + } ) @@ -65,7 +74,12 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type( 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, "nb_items": 7} + assert await resp.json() == { + "with_comments": True, + "age": 3, + "nb_items": 7, + "tags": [], + } async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( @@ -77,6 +91,70 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_defa client = await aiohttp_client(app) resp = await client.get("/article", params={"with_comments": "yes"}) - assert await resp.json() == {"with_comments": True, "age": None, "nb_items": 7} + assert await resp.json() == { + "with_comments": True, + "age": None, + "nb_items": 7, + "tags": [], + } + assert resp.status == 200 + assert resp.content_type == "application/json" + + +async def test_get_article_with_multiple_value_for_qs_age_must_failed( + aiohttp_client, loop +): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + + resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1}) + assert await resp.json() == [ + { + "in": "query string", + "loc": ["age"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + assert resp.status == 400 + assert resp.content_type == "application/json" + + +async def test_get_article_with_multiple_value_of_tags(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + + resp = await client.get( + "/article", params={"age": 2, "with_comments": 1, "tags": ["aa", "bb"]} + ) + assert await resp.json() == { + "age": 2, + "nb_items": 7, + "tags": ["aa", "bb"], + "with_comments": True, + } + assert resp.status == 200 + assert resp.content_type == "application/json" + + +async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, loop): + app = web.Application() + app.router.add_view("/article", ArticleView) + + client = await aiohttp_client(app) + + resp = await client.get( + "/article", params={"age": 2, "with_comments": 1, "tags": ["aa"]} + ) + assert await resp.json() == { + "age": 2, + "nb_items": 7, + "tags": ["aa"], + "with_comments": True, + } assert resp.status == 200 assert resp.content_type == "application/json"