From 7ab2d84263835020ce1af2391d7395e71622e5a5 Mon Sep 17 00:00:00 2001 From: Vincent Maillol Date: Mon, 26 Jul 2021 08:12:38 +0200 Subject: [PATCH] Add a hook to intercept ValidationError --- README.rst | 20 ++++++++++ aiohttp_pydantic/injectors.py | 7 +++- aiohttp_pydantic/view.py | 25 +++++++++--- tests/test_hook_to_custom_response.py | 56 +++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/test_hook_to_custom_response.py diff --git a/README.rst b/README.rst index b30ab07..6c95003 100644 --- a/README.rst +++ b/README.rst @@ -310,6 +310,26 @@ Open Api Specification. self.request.app["model"].remove_pet(id) return web.Response(status=204) + +Custom Validation error +----------------------- + +You can redefine the on_validation_error hook in your PydanticView + +.. code-block:: python3 + + class PetView(PydanticView): + + async def on_validation_error(self, + exception: ValidationError, + context: str): + errors = exception.errors() + for error in errors: + error["in"] = context # context is "body", "headers", "path" or "query string" + error["custom"] = "your custom field ..." + return json_response(data=errors, status=400) + + Demo ---- diff --git a/aiohttp_pydantic/injectors.py b/aiohttp_pydantic/injectors.py index 1f5ae24..8147c4c 100644 --- a/aiohttp_pydantic/injectors.py +++ b/aiohttp_pydantic/injectors.py @@ -2,7 +2,7 @@ import abc import typing from inspect import signature from json.decoder import JSONDecodeError -from typing import Callable, Tuple +from typing import Callable, Tuple, Literal from aiohttp.web_exceptions import HTTPBadRequest from aiohttp.web_request import BaseRequest @@ -12,6 +12,9 @@ from pydantic import BaseModel from .utils import is_pydantic_base_model +CONTEXT = Literal["body", "headers", "path", "query string"] + + class AbstractInjector(metaclass=abc.ABCMeta): """ An injector parse HTTP request and inject params to the view. @@ -19,7 +22,7 @@ class AbstractInjector(metaclass=abc.ABCMeta): @property @abc.abstractmethod - def context(self) -> str: + def context(self) -> CONTEXT: """ The name of part of parsed request i.e "HTTP header", "URL path", ... diff --git a/aiohttp_pydantic/view.py b/aiohttp_pydantic/view.py index 6196ddd..355def5 100644 --- a/aiohttp_pydantic/view.py +++ b/aiohttp_pydantic/view.py @@ -1,6 +1,6 @@ from functools import update_wrapper from inspect import iscoroutinefunction -from typing import Any, Callable, Generator, Iterable, Set, ClassVar +from typing import Any, Callable, Generator, Iterable, Set, ClassVar, Literal import warnings from aiohttp.abc import AbstractView @@ -17,6 +17,7 @@ from .injectors import ( MatchInfoGetter, QueryGetter, _parse_func_signature, + CONTEXT, ) @@ -88,6 +89,22 @@ class PydanticView(AbstractView): injectors.append(HeadersGetter(header_args, default_value(header_args))) return injectors + async def on_validation_error( + self, exception: ValidationError, context: CONTEXT + ) -> StreamResponse: + """ + This method is a hook to intercept ValidationError. + + This hook can be redefined to return a custom HTTP response error. + The exception is a pydantic.ValidationError and the context is "body", + "headers", "path" or "query string" + """ + errors = exception.errors() + for error in errors: + error["in"] = context + + return json_response(data=errors, status=400) + def inject_params( handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] @@ -109,11 +126,7 @@ def inject_params( else: injector.inject(self.request, args, kwargs) except ValidationError as error: - errors = error.errors() - for error in errors: - error["in"] = injector.context - - return json_response(data=errors, status=400) + return await self.on_validation_error(error, injector.context) return await handler(self, *args, **kwargs) diff --git a/tests/test_hook_to_custom_response.py b/tests/test_hook_to_custom_response.py new file mode 100644 index 0000000..069005b --- /dev/null +++ b/tests/test_hook_to_custom_response.py @@ -0,0 +1,56 @@ +from typing import Iterator, List, Optional + +from aiohttp import web +from aiohttp.web_response import json_response +from pydantic import BaseModel + +from aiohttp_pydantic import PydanticView + + +class ArticleModel(BaseModel): + name: str + 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 on_validation_error(self, exception, context): + errors = exception.errors() + for error in errors: + error["in"] = context + error["custom"] = "custom" + return json_response(data=errors, status=400) + + +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() == [ + { + "in": "body", + "loc": ["nb_page"], + "msg": "value is not a valid integer", + "custom": "custom", + "type": "type_error.integer", + } + ]