diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 124aac8..f4cfd7d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,18 +12,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest, macOS-latest] - # Python 3.7 is not supported on Apple ARM64, - # or the latest Ubuntu 2404 - exclude: - - python-version: "3.7" - os: macos-latest - - python-version: "3.7" - os: ubuntu-latest - include: # Python 3.7 is tested with a x86 macOS version - - python-version: "3.7" - os: macos-13 steps: diff --git a/flask_pydantic/core.py b/flask_pydantic/core.py index 882bb7d..aea4383 100644 --- a/flask_pydantic/core.py +++ b/flask_pydantic/core.py @@ -6,6 +6,7 @@ from pydantic.v1 import BaseModel as V1BaseModel from pydantic.v1.error_wrappers import ValidationError as V1ValidationError from pydantic.v1.tools import parse_obj_as +from pydantic_core import ErrorDetails from .converters import convert_query_params from .exceptions import ( @@ -64,7 +65,11 @@ def is_iterable_of_models(content: Any) -> bool: def validate_many_models( - model: Type[V1OrV2BaseModel], content: Any + model: Type[V1OrV2BaseModel], + content: Any, + include_error_url: bool = True, + include_error_context: bool = True, + include_error_input: bool = True, ) -> List[V1OrV2BaseModel]: try: return [model(**fields) for fields in content] @@ -77,13 +82,26 @@ def validate_many_models( "type": "type_error.array", } ] - raise ManyModelValidationError(err) from te - except (ValidationError, V1ValidationError) as ve: + except ValidationError as ve: + raise ManyModelValidationError( + ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + ) from ve + except V1ValidationError as ve: raise ManyModelValidationError(ve.errors()) from ve -def validate_path_params(func: Callable, kwargs: dict) -> Tuple[dict, list]: +def validate_path_params( + func: Callable, + kwargs: dict, + include_error_url: bool = True, + include_error_context: bool = True, + include_error_input: bool = True, +) -> Tuple[dict, List[ErrorDetails]]: errors = [] validated = {} for name, type_ in func.__annotations__.items(): @@ -96,7 +114,15 @@ def validate_path_params(func: Callable, kwargs: dict) -> Tuple[dict, list]: else: value = parse_obj_as(type_, kwargs.get(name)) validated[name] = value - except (ValidationError, V1ValidationError) as e: + except ValidationError as e: + err = e.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + )[0] + err["loc"] = [name] + errors.append(err) + except V1ValidationError as e: err = e.errors()[0] err["loc"] = [name] errors.append(err) @@ -121,6 +147,9 @@ def validate( response_by_alias: bool = False, get_json_params: Optional[dict] = None, form: Optional[Type[V1OrV2BaseModel]] = None, + include_error_url: bool = True, + include_error_context: bool = True, + include_error_input: bool = True, ): """ Decorator for route methods which will validate query, body and form parameters @@ -142,6 +171,9 @@ def validate( (request.body_params then contains list of models i. e. List[BaseModel]) `response_by_alias` whether Pydantic's alias is used `get_json_params` - parameters to be passed to Request.get_json() function + `include_error_url` whether to include a URL to documentation on the error each error + `include_error_context` whether to include the context of each error + `include_error_input` whether to include the input value of each error example:: @@ -186,7 +218,13 @@ def decorate(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): q, b, f, err = None, None, None, {} - kwargs, path_err = validate_path_params(func, kwargs) + kwargs, path_err = validate_path_params( + func, + kwargs, + include_error_url=include_error_url, + include_error_context=include_error_context, + include_error_input=include_error_input, + ) if path_err: err["path_params"] = path_err query_in_kwargs = func.__annotations__.get("query") @@ -195,7 +233,13 @@ def wrapper(*args, **kwargs): query_params = convert_query_params(request.args, query_model) try: q = query_model(**query_params) - except (ValidationError, V1ValidationError) as ve: + except ValidationError as ve: + err["query_params"] = ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + except V1ValidationError as ve: err["query_params"] = ve.errors() body_in_kwargs = func.__annotations__.get("body") body_model = body_in_kwargs or body @@ -212,11 +256,23 @@ def wrapper(*args, **kwargs): elif issubclass(body_model, RootModel): try: b = body_model(body_params) - except (ValidationError, V1ValidationError) as ve: + except ValidationError as ve: + err["body_params"] = ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + except V1ValidationError as ve: err["body_params"] = ve.errors() elif request_body_many: try: - b = validate_many_models(body_model, body_params) + b = validate_many_models( + body_model, + body_params, + include_error_url, + include_error_context, + include_error_input, + ) except ManyModelValidationError as e: err["body_params"] = e.errors() else: @@ -229,7 +285,13 @@ def wrapper(*args, **kwargs): return unsupported_media_type_response(content_type) else: raise JsonBodyParsingError() from te - except (ValidationError, V1ValidationError) as ve: + except ValidationError as ve: + err["body_params"] = ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + except V1ValidationError as ve: err["body_params"] = ve.errors() form_in_kwargs = func.__annotations__.get("form") form_model = form_in_kwargs or form @@ -241,12 +303,24 @@ def wrapper(*args, **kwargs): ): try: f = form_model(form_params) - except (ValidationError, V1ValidationError) as ve: + except ValidationError as ve: + err["form_params"] = ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + except V1ValidationError as ve: err["form_params"] = ve.errors() elif issubclass(form_model, RootModel): try: f = form_model(form_params) - except (ValidationError, V1ValidationError) as ve: + except ValidationError as ve: + err["form_params"] = ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + except V1ValidationError as ve: err["form_params"] = ve.errors() else: try: @@ -257,8 +331,14 @@ def wrapper(*args, **kwargs): if media_type != "multipart/form-data": return unsupported_media_type_response(content_type) else: - raise JsonBodyParsingError from te - except (ValidationError, V1ValidationError) as ve: + raise JsonBodyParsingError() from te + except ValidationError as ve: + err["form_params"] = ve.errors( + include_url=include_error_url, + include_context=include_error_context, + include_input=include_error_input, + ) + except V1ValidationError as ve: err["form_params"] = ve.errors() request.query_params = q request.body_params = b diff --git a/flask_pydantic/exceptions.py b/flask_pydantic/exceptions.py index e214cc1..87146e7 100644 --- a/flask_pydantic/exceptions.py +++ b/flask_pydantic/exceptions.py @@ -1,5 +1,7 @@ from typing import List, Optional +from pydantic_core import ErrorDetails + class BaseFlaskPydanticException(Exception): """Base exc class for all exception from this library""" @@ -24,7 +26,7 @@ class ManyModelValidationError(BaseFlaskPydanticException): """This exception is raised if there is a failure during validation of many models in an iterable""" - def __init__(self, errors: List[dict], *args): + def __init__(self, errors: List[ErrorDetails], *args): self._errors = errors super().__init__(*args) @@ -38,10 +40,10 @@ class ValidationError(BaseFlaskPydanticException): def __init__( self, - body_params: Optional[List[dict]] = None, - form_params: Optional[List[dict]] = None, - path_params: Optional[List[dict]] = None, - query_params: Optional[List[dict]] = None, + body_params: Optional[List[ErrorDetails]] = None, + form_params: Optional[List[ErrorDetails]] = None, + path_params: Optional[List[ErrorDetails]] = None, + query_params: Optional[List[ErrorDetails]] = None, ): super().__init__() self.body_params = body_params diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 9c2f1b0..1f9359d 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -35,6 +35,9 @@ class ValidateParams(NamedTuple): exclude_none: bool = False response_many: bool = False request_body_many: bool = False + include_error_url: bool = True + include_error_context: bool = True + include_error_input: bool = True class ResponseModel(BaseModel): @@ -131,6 +134,27 @@ class RequestBodyModelRoot(RootModel): ), id="invalid query param", ), + pytest.param( + ValidateParams( + query_model=QueryModel, + include_error_url=False, + include_error_context=False, + include_error_input=False, + expected_response_body={ + "validation_error": { + "query_params": [ + { + "loc": ["q1"], + "msg": "Field required", + "type": "missing", + } + ] + } + }, + expected_status_code=400, + ), + id="invalid query param", + ), pytest.param( ValidateParams( body_model=RequestBodyModel, @@ -151,6 +175,27 @@ class RequestBodyModelRoot(RootModel): ), id="`request_body_many=True` but in request body is a single object", ), + pytest.param( + ValidateParams( + include_error_url=False, + include_error_context=False, + include_error_input=False, + expected_response_body={ + "validation_error": { + "body_params": [ + { + "loc": ["b1"], + "msg": "Field required", + "type": "missing", + } + ] + } + }, + body_model=RequestBodyModel, + expected_status_code=400, + ), + id="invalid body param", + ), pytest.param( ValidateParams( expected_response_body={ @@ -219,6 +264,27 @@ class RequestBodyModelRoot(RootModel): ), id="invalid form param", ), + pytest.param( + ValidateParams( + form_model=FormModel, + include_error_url=False, + include_error_context=False, + include_error_input=False, + expected_response_body={ + "validation_error": { + "form_params": [ + { + "loc": ["f1"], + "msg": "Field required", + "type": "missing", + } + ] + } + }, + expected_status_code=400, + ), + id="invalid form param without error details", + ), pytest.param( ValidateParams( request_query=ImmutableMultiDict( @@ -317,6 +383,9 @@ def f(): response_many=parameters.response_many, request_body_many=parameters.request_body_many, form=parameters.form_model, + include_error_url=parameters.include_error_url, + include_error_context=parameters.include_error_context, + include_error_input=parameters.include_error_input, )(f)() assert response.status_code == parameters.expected_status_code @@ -356,6 +425,9 @@ def f( exclude_none=parameters.exclude_none, response_many=parameters.response_many, request_body_many=parameters.request_body_many, + include_error_url=parameters.include_error_url, + include_error_context=parameters.include_error_context, + include_error_input=parameters.include_error_input, )(f)() assert_matches(parameters.expected_response_body, response.json) @@ -555,6 +627,9 @@ def f() -> Any: exclude_none=parameters.exclude_none, response_many=parameters.response_many, request_body_many=parameters.request_body_many, + include_error_url=parameters.include_error_url, + include_error_context=parameters.include_error_context, + include_error_input=parameters.include_error_input, )(f)() assert response.status_code == parameters.expected_status_code @@ -644,6 +719,39 @@ def test_body_fail_validation_raise_exception(self, app, request_ctx, mocker): excinfo.value.body_params, ) + def test_body_fail_validation_raise_exception_excluding_error_context( + self, app, request_ctx, mocker + ): + app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True + mock_request = mocker.patch.object(request_ctx, "request") + content_type = "application/json" + mock_request.headers = {"Content-Type": content_type} + mock_request.get_json = lambda: None + body_model = RequestBodyModelRoot + with pytest.raises(ValidationError) as excinfo: + validate(body_model, include_error_context=False)(lambda x: x)() + assert_matches( + [ + { + "input": None, + "loc": ("str",), + "msg": "Input should be a valid string", + "type": "string_type", + "url": re.compile( + r"https://errors\.pydantic\.dev/.*/v/string_type" + ), + }, + { + "input": None, + "loc": ("RequestBodyModel",), + "msg": "Input should be a valid dictionary or instance of RequestBodyModel", + "type": "model_type", + "url": re.compile(r"https://errors\.pydantic\.dev/.*/v/model_type"), + }, + ], + excinfo.value.body_params, + ) + def test_query_fail_validation_raise_exception(self, app, request_ctx, mocker): app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True mock_request = mocker.patch.object(request_ctx, "request")