From 4ee39e7b8e687ebb95f175424fdfa32340c2c522 Mon Sep 17 00:00:00 2001 From: Kinuax Date: Tue, 29 Apr 2025 00:23:28 +0200 Subject: [PATCH 1/8] Add support for enabling/disabling input and url fields in validation errors --- fastapi/_compat.py | 33 ++++++++++++++---- fastapi/applications.py | 26 +++++++++++++++ fastapi/dependencies/utils.py | 20 +++++++++-- fastapi/routing.py | 63 +++++++++++++++++++++++++++++++++-- fastapi/utils.py | 11 +++++- 5 files changed, 141 insertions(+), 12 deletions(-) diff --git a/fastapi/_compat.py b/fastapi/_compat.py index c07e4a3b0..dbc5251e5 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -89,6 +89,8 @@ if PYDANTIC_V2: field_info: FieldInfo name: str mode: Literal["validation", "serialization"] = "validation" + include_error_input: bool = True + include_error_url: bool = False @property def alias(self) -> str: @@ -131,7 +133,11 @@ if PYDANTIC_V2: ) except ValidationError as exc: return None, _regenerate_error_with_loc( - errors=exc.errors(include_url=False), loc_prefix=loc + errors=exc.errors( + include_input=self.include_error_input, + include_url=self.include_error_url, + ), + loc_prefix=loc, ) def serialize( @@ -267,11 +273,16 @@ if PYDANTIC_V2: assert issubclass(origin_type, sequence_types) # type: ignore[arg-type] return sequence_annotation_to_type[origin_type](value) # type: ignore[no-any-return] - def get_missing_field_error(loc: Tuple[str, ...]) -> Dict[str, Any]: + def get_missing_field_error( + loc: Tuple[str, ...], + include_error_input: bool = True, + include_error_url: bool = False, + ) -> Dict[str, Any]: error = ValidationError.from_exception_data( "Field required", [{"type": "missing", "loc": loc, "input": {}}] - ).errors(include_url=False)[0] - error["input"] = None + ).errors(include_url=include_error_url)[0] + if include_error_input: + error["input"] = None return error # type: ignore[return-value] def create_body_model( @@ -509,10 +520,18 @@ else: def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: return sequence_shape_to_type[field.shape](value) # type: ignore[no-any-return,attr-defined] - def get_missing_field_error(loc: Tuple[str, ...]) -> Dict[str, Any]: + def get_missing_field_error( + loc: Tuple[str, ...], + include_error_input: bool = True, + include_error_url: bool = False, + ) -> Dict[str, Any]: missing_field_error = ErrorWrapper(MissingError(), loc=loc) # type: ignore[call-arg] - new_error = ValidationError([missing_field_error], RequestErrorModel) - return new_error.errors()[0] # type: ignore[return-value] + new_error = ValidationError([missing_field_error], RequestErrorModel).errors( + include_url=include_error_url + )[0] + if include_error_input: + new_error["input"] = None + return new_error # type: ignore[return-value] def create_body_model( *, fields: Sequence[ModelField], model_name: str diff --git a/fastapi/applications.py b/fastapi/applications.py index 6d427cdc2..ecd53746a 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -752,6 +752,22 @@ class FastAPI(Starlette): """ ), ] = True, + include_error_input: Annotated[ + bool, + Doc( + """ + TODO + """ + ), + ] = True, + include_error_url: Annotated[ + bool, + Doc( + """ + TODO + """ + ), + ] = False, swagger_ui_parameters: Annotated[ Optional[Dict[str, Any]], Doc( @@ -941,6 +957,8 @@ class FastAPI(Starlette): callbacks=callbacks, deprecated=deprecated, include_in_schema=include_in_schema, + include_error_input=include_error_input, + include_error_url=include_error_url, responses=responses, generate_unique_id_function=generate_unique_id_function, ) @@ -1076,6 +1094,8 @@ class FastAPI(Starlette): response_model_exclude_defaults: bool = False, response_model_exclude_none: bool = False, include_in_schema: bool = True, + include_error_input: bool = True, + include_error_url: bool = False, response_class: Union[Type[Response], DefaultPlaceholder] = Default( JSONResponse ), @@ -1106,6 +1126,8 @@ class FastAPI(Starlette): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=include_error_input, + include_error_url=include_error_url, response_class=response_class, name=name, openapi_extra=openapi_extra, @@ -1134,6 +1156,8 @@ class FastAPI(Starlette): response_model_exclude_defaults: bool = False, response_model_exclude_none: bool = False, include_in_schema: bool = True, + include_error_input: bool = True, + include_error_url: bool = False, response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, openapi_extra: Optional[Dict[str, Any]] = None, @@ -1163,6 +1187,8 @@ class FastAPI(Starlette): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=include_error_input, + include_error_url=include_error_url, response_class=response_class, name=name, openapi_extra=openapi_extra, diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 84dfa4d03..eb39f3e61 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -269,6 +269,8 @@ def get_dependant( name: Optional[str] = None, security_scopes: Optional[List[str]] = None, use_cache: bool = True, + include_error_input: bool = True, + include_error_url: bool = False, ) -> Dependant: path_param_names = get_path_param_names(path) endpoint_signature = get_typed_signature(call) @@ -287,6 +289,8 @@ def get_dependant( annotation=param.annotation, value=param.default, is_path_param=is_path_param, + include_error_input=include_error_input, + include_error_url=include_error_url, ) if param_details.depends is not None: sub_dependant = get_param_sub_dependant( @@ -351,6 +355,8 @@ def analyze_param( annotation: Any, value: Any, is_path_param: bool, + include_error_input: bool, + include_error_url: bool, ) -> ParamDetails: field_info = None depends = None @@ -492,6 +498,8 @@ def analyze_param( alias=alias, required=field_info.default in (RequiredParam, Undefined), field_info=field_info, + include_error_input=include_error_input, + include_error_url=include_error_url, ) if is_path_param: assert is_scalar_field(field=field), ( @@ -700,7 +708,11 @@ def _validate_value_with_model_field( ) -> Tuple[Any, List[Any]]: if value is None: if field.required: - return None, [get_missing_field_error(loc=loc)] + return None, [ + get_missing_field_error( + loc, field.include_error_input, field.include_error_url + ) + ] else: return deepcopy(field.default), [] v_, errors_ = field.validate(value, values, loc=loc) @@ -915,7 +927,11 @@ async def request_body_to_args( value = body_to_process.get(field.alias) # If the received body is a list, not a dict except AttributeError: - errors.append(get_missing_field_error(loc)) + errors.append( + get_missing_field_error( + loc, field.include_error_input, field.include_error_url + ) + ) continue v_, errors_ = _validate_value_with_model_field( field=field, value=value, values=values, loc=loc diff --git a/fastapi/routing.py b/fastapi/routing.py index 457481e32..0bb443941 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -450,6 +450,8 @@ class APIRoute(routing.Route): response_model_exclude_defaults: bool = False, response_model_exclude_none: bool = False, include_in_schema: bool = True, + include_error_input: bool = True, + include_error_url: bool = False, response_class: Union[Type[Response], DefaultPlaceholder] = Default( JSONResponse ), @@ -480,6 +482,8 @@ class APIRoute(routing.Route): self.response_model_exclude_defaults = response_model_exclude_defaults self.response_model_exclude_none = response_model_exclude_none self.include_in_schema = include_in_schema + self.include_error_input = include_error_input + self.include_error_url = include_error_url self.response_class = response_class self.dependency_overrides_provider = dependency_overrides_provider self.callbacks = callbacks @@ -512,6 +516,8 @@ class APIRoute(routing.Route): name=response_name, type_=self.response_model, mode="serialization", + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, ) # Create a clone of the field, so that a Pydantic submodel is not returned # as is just because it's an instance of a subclass of a more limited class @@ -542,7 +548,11 @@ class APIRoute(routing.Route): ) response_name = f"Response_{additional_status_code}_{self.unique_id}" response_field = create_model_field( - name=response_name, type_=model, mode="serialization" + name=response_name, + type_=model, + mode="serialization", + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, ) response_fields[additional_status_code] = response_field if response_fields: @@ -551,7 +561,12 @@ class APIRoute(routing.Route): self.response_fields = {} assert callable(endpoint), "An endpoint must be a callable" - self.dependant = get_dependant(path=self.path_format, call=self.endpoint) + self.dependant = get_dependant( + path=self.path_format, + call=self.endpoint, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, + ) for depends in self.dependencies[::-1]: self.dependant.dependencies.insert( 0, @@ -818,6 +833,22 @@ class APIRouter(routing.Router): """ ), ] = True, + include_error_input: Annotated[ + bool, + Doc( + """ + TODO + """ + ), + ] = True, + include_error_url: Annotated[ + bool, + Doc( + """ + TODO + """ + ), + ] = False, generate_unique_id_function: Annotated[ Callable[[APIRoute], str], Doc( @@ -852,6 +883,8 @@ class APIRouter(routing.Router): self.dependencies = list(dependencies or []) self.deprecated = deprecated self.include_in_schema = include_in_schema + self.include_error_input = include_error_input + self.include_error_url = include_error_url self.responses = responses or {} self.callbacks = callbacks or [] self.dependency_overrides_provider = dependency_overrides_provider @@ -901,6 +934,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults: bool = False, response_model_exclude_none: bool = False, include_in_schema: bool = True, + include_error_input: bool = True, + include_error_url: bool = False, response_class: Union[Type[Response], DefaultPlaceholder] = Default( JSONResponse ), @@ -951,6 +986,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema and self.include_in_schema, + include_error_input=include_error_input, + include_error_url=include_error_url, response_class=current_response_class, name=name, dependency_overrides_provider=self.dependency_overrides_provider, @@ -982,6 +1019,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults: bool = False, response_model_exclude_none: bool = False, include_in_schema: bool = True, + include_error_input: bool = True, + include_error_url: bool = False, response_class: Type[Response] = Default(JSONResponse), name: Optional[str] = None, callbacks: Optional[List[BaseRoute]] = None, @@ -1012,6 +1051,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=include_error_input, + include_error_url=include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -1322,6 +1363,8 @@ class APIRouter(routing.Router): include_in_schema=route.include_in_schema and self.include_in_schema and include_in_schema, + include_error_input=route.include_error_input, + include_error_url=route.include_error_url, response_class=use_response_class, name=route.name, route_class_override=type(route), @@ -1733,6 +1776,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -2115,6 +2160,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -2497,6 +2544,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -2874,6 +2923,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -3251,6 +3302,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -3633,6 +3686,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -4015,6 +4070,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, @@ -4397,6 +4454,8 @@ class APIRouter(routing.Router): response_model_exclude_defaults=response_model_exclude_defaults, response_model_exclude_none=response_model_exclude_none, include_in_schema=include_in_schema, + include_error_input=self.include_error_input, + include_error_url=self.include_error_url, response_class=response_class, name=name, callbacks=callbacks, diff --git a/fastapi/utils.py b/fastapi/utils.py index 4c7350fea..84b83be3a 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -70,6 +70,8 @@ def create_model_field( field_info: Optional[FieldInfo] = None, alias: Optional[str] = None, mode: Literal["validation", "serialization"] = "validation", + include_error_input: bool = True, + include_error_url: bool = False, ) -> ModelField: class_validators = class_validators or {} if PYDANTIC_V2: @@ -78,7 +80,12 @@ def create_model_field( ) else: field_info = field_info or FieldInfo() - kwargs = {"name": name, "field_info": field_info} + kwargs = { + "name": name, + "field_info": field_info, + "include_error_input": include_error_input, + "include_error_url": include_error_url, + } if PYDANTIC_V2: kwargs.update({"mode": mode}) else: @@ -140,6 +147,8 @@ def create_cloned_field( new_field.required = field.required # type: ignore[misc] new_field.model_config = field.model_config # type: ignore[attr-defined] new_field.field_info = field.field_info + new_field.include_error_input = field.include_error_input + new_field.include_error_url = field.include_error_url new_field.allow_none = field.allow_none # type: ignore[attr-defined] new_field.validate_always = field.validate_always # type: ignore[attr-defined] if field.sub_fields: # type: ignore[attr-defined] From bfe00c742093e25c726431047df792ea4e952fb1 Mon Sep 17 00:00:00 2001 From: Kinuax Date: Thu, 1 May 2025 13:53:24 +0200 Subject: [PATCH 2/8] Fix tests --- fastapi/_compat.py | 16 ++++------------ fastapi/dependencies/utils.py | 24 +++++++++++++++--------- fastapi/utils.py | 12 +++++++----- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/fastapi/_compat.py b/fastapi/_compat.py index dbc5251e5..78a6872c6 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -280,7 +280,7 @@ if PYDANTIC_V2: ) -> Dict[str, Any]: error = ValidationError.from_exception_data( "Field required", [{"type": "missing", "loc": loc, "input": {}}] - ).errors(include_url=include_error_url)[0] + ).errors(include_input=include_error_input, include_url=include_error_url)[0] if include_error_input: error["input"] = None return error # type: ignore[return-value] @@ -520,18 +520,10 @@ else: def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: return sequence_shape_to_type[field.shape](value) # type: ignore[no-any-return,attr-defined] - def get_missing_field_error( - loc: Tuple[str, ...], - include_error_input: bool = True, - include_error_url: bool = False, - ) -> Dict[str, Any]: + def get_missing_field_error(loc: Tuple[str, ...]) -> Dict[str, Any]: missing_field_error = ErrorWrapper(MissingError(), loc=loc) # type: ignore[call-arg] - new_error = ValidationError([missing_field_error], RequestErrorModel).errors( - include_url=include_error_url - )[0] - if include_error_input: - new_error["input"] = None - return new_error # type: ignore[return-value] + new_error = ValidationError([missing_field_error], RequestErrorModel) + return new_error.errors()[0] # type: ignore[return-value] def create_body_model( *, fields: Sequence[ModelField], model_name: str diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index eb39f3e61..a53e65c16 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -708,11 +708,14 @@ def _validate_value_with_model_field( ) -> Tuple[Any, List[Any]]: if value is None: if field.required: - return None, [ - get_missing_field_error( - loc, field.include_error_input, field.include_error_url - ) - ] + if PYDANTIC_V2: + return None, [ + get_missing_field_error( + loc, field.include_error_input, field.include_error_url + ) + ] + else: + return None, [get_missing_field_error(loc=loc)] else: return deepcopy(field.default), [] v_, errors_ = field.validate(value, values, loc=loc) @@ -927,11 +930,14 @@ async def request_body_to_args( value = body_to_process.get(field.alias) # If the received body is a list, not a dict except AttributeError: - errors.append( - get_missing_field_error( - loc, field.include_error_input, field.include_error_url + if PYDANTIC_V2: + errors.append( + get_missing_field_error( + loc, field.include_error_input, field.include_error_url + ) ) - ) + else: + errors.append(get_missing_field_error(loc)) continue v_, errors_ = _validate_value_with_model_field( field=field, value=value, values=values, loc=loc diff --git a/fastapi/utils.py b/fastapi/utils.py index 84b83be3a..110e17e1f 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -83,11 +83,15 @@ def create_model_field( kwargs = { "name": name, "field_info": field_info, - "include_error_input": include_error_input, - "include_error_url": include_error_url, } if PYDANTIC_V2: - kwargs.update({"mode": mode}) + kwargs.update( + { + "mode": mode, + "include_error_input": include_error_input, + "include_error_url": include_error_url, + } + ) else: kwargs.update( { @@ -147,8 +151,6 @@ def create_cloned_field( new_field.required = field.required # type: ignore[misc] new_field.model_config = field.model_config # type: ignore[attr-defined] new_field.field_info = field.field_info - new_field.include_error_input = field.include_error_input - new_field.include_error_url = field.include_error_url new_field.allow_none = field.allow_none # type: ignore[attr-defined] new_field.validate_always = field.validate_always # type: ignore[attr-defined] if field.sub_fields: # type: ignore[attr-defined] From c24441928596b21b922d683fa4458b0f5d9991b7 Mon Sep 17 00:00:00 2001 From: Kinuax Date: Thu, 1 May 2025 14:57:32 +0200 Subject: [PATCH 3/8] Fix linting --- fastapi/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/_compat.py b/fastapi/_compat.py index 78a6872c6..31bcecfab 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -520,7 +520,7 @@ else: def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: return sequence_shape_to_type[field.shape](value) # type: ignore[no-any-return,attr-defined] - def get_missing_field_error(loc: Tuple[str, ...]) -> Dict[str, Any]: + def get_missing_field_error(loc: Tuple[str, ...]) -> Dict[str, Any]: # type: ignore[misc] missing_field_error = ErrorWrapper(MissingError(), loc=loc) # type: ignore[call-arg] new_error = ValidationError([missing_field_error], RequestErrorModel) return new_error.errors()[0] # type: ignore[return-value] From 1d46c97829f741922a160e77d4d6f8af7ef9ccef Mon Sep 17 00:00:00 2001 From: Kinuax Date: Thu, 1 May 2025 17:04:42 +0200 Subject: [PATCH 4/8] Add tests --- tests/test_validation_error_fields.py | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_validation_error_fields.py diff --git a/tests/test_validation_error_fields.py b/tests/test_validation_error_fields.py new file mode 100644 index 000000000..72e65f733 --- /dev/null +++ b/tests/test_validation_error_fields.py @@ -0,0 +1,79 @@ +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +from .utils import needs_pydanticv2 + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "include_error_input,include_error_url", + [(False, False), (False, True), (True, False), (True, True)], +) +def test_input_and_url_fields(include_error_input, include_error_url): + app = FastAPI( + include_error_input=include_error_input, include_error_url=include_error_url + ) + + @app.get("/path1/{path_param}") + def path1(path_param: int): + return {"path_param": path_param} + + @app.get("/path2/") + def path2(query_param: int): + return query_param + + router = APIRouter() + + @app.get("/path3/{path_param}") + def path3(path_param: int): + return {"path_param": path_param} + + @app.get("/path4/") + def path4(query_param: int): + return query_param + + app.include_router(router, prefix="/prefix") + client = TestClient(app) + + with client: + invalid = "not-an-integer" + + for path in ["path1", "path3"]: + response = client.get(f"/{path}/{invalid}") + assert response.status_code == 422, response.text + error = response.json()["detail"][0] + if include_error_input: + assert error["input"] == invalid + else: + assert "input" not in error + if include_error_url: + assert "url" in error + else: + assert "url" not in error + + for path in ["path2", "path4"]: + response = client.get(f"/{path}/") + assert response.status_code == 422, response.text + error = response.json()["detail"][0] + if include_error_input: + assert error["type"] == "missing" + assert error["input"] is None + else: + assert "input" not in error + if include_error_url: + assert "url" in error + else: + assert "url" not in error + + response = client.get(f"/{path}/?query_param={invalid}") + assert response.status_code == 422, response.text + error = response.json()["detail"][0] + if include_error_input: + assert error["input"] == invalid + else: + assert "input" not in error + if include_error_url: + assert "url" in error + else: + assert "url" not in error From c4ae1e3d3b106ee5f9e2ece037bd113cd6f15e5a Mon Sep 17 00:00:00 2001 From: Kinuax Date: Fri, 2 May 2025 11:06:45 +0200 Subject: [PATCH 5/8] Add docs --- fastapi/applications.py | 8 ++++++-- fastapi/routing.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index ecd53746a..69caceea8 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -756,7 +756,9 @@ class FastAPI(Starlette): bool, Doc( """ - TODO + To include (or not) the field `input` in the validation error of all *path operations*. + + This does not affect the generated OpenAPI (e.g. visible at `/docs`). """ ), ] = True, @@ -764,7 +766,9 @@ class FastAPI(Starlette): bool, Doc( """ - TODO + To include (or not) the field `url` in the validation error of all *path operations*. + + This does not affect the generated OpenAPI (e.g. visible at `/docs`). """ ), ] = False, diff --git a/fastapi/routing.py b/fastapi/routing.py index 0bb443941..a2fae393a 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -837,7 +837,9 @@ class APIRouter(routing.Router): bool, Doc( """ - TODO + To include (or not) the field `input` in the validation error of all *path operations*. + + This does not affect the generated OpenAPI (e.g. visible at `/docs`). """ ), ] = True, @@ -845,7 +847,9 @@ class APIRouter(routing.Router): bool, Doc( """ - TODO + To include (or not) the field `url` in the validation error of all *path operations*. + + This does not affect the generated OpenAPI (e.g. visible at `/docs`). """ ), ] = False, From 9b5b74bc45a4e26997d1b4f3dbe37446be695962 Mon Sep 17 00:00:00 2001 From: Kinuax Date: Fri, 2 May 2025 19:25:33 +0200 Subject: [PATCH 6/8] Tweak utils and tests --- fastapi/dependencies/utils.py | 20 +++++++++----------- fastapi/utils.py | 5 +---- tests/test_validation_error_fields.py | 10 ++++++---- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index a53e65c16..52d1a48a4 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -709,13 +709,12 @@ def _validate_value_with_model_field( if value is None: if field.required: if PYDANTIC_V2: - return None, [ - get_missing_field_error( - loc, field.include_error_input, field.include_error_url - ) - ] + error = get_missing_field_error( + loc, field.include_error_input, field.include_error_url + ) else: - return None, [get_missing_field_error(loc=loc)] + error = get_missing_field_error(loc) + return None, [error] else: return deepcopy(field.default), [] v_, errors_ = field.validate(value, values, loc=loc) @@ -931,13 +930,12 @@ async def request_body_to_args( # If the received body is a list, not a dict except AttributeError: if PYDANTIC_V2: - errors.append( - get_missing_field_error( - loc, field.include_error_input, field.include_error_url - ) + error = get_missing_field_error( + loc, field.include_error_input, field.include_error_url ) else: - errors.append(get_missing_field_error(loc)) + error = get_missing_field_error(loc) + errors.append(error) continue v_, errors_ = _validate_value_with_model_field( field=field, value=value, values=values, loc=loc diff --git a/fastapi/utils.py b/fastapi/utils.py index 110e17e1f..b4cd55369 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -80,10 +80,7 @@ def create_model_field( ) else: field_info = field_info or FieldInfo() - kwargs = { - "name": name, - "field_info": field_info, - } + kwargs = {"name": name, "field_info": field_info} if PYDANTIC_V2: kwargs.update( { diff --git a/tests/test_validation_error_fields.py b/tests/test_validation_error_fields.py index 72e65f733..efe117ebb 100644 --- a/tests/test_validation_error_fields.py +++ b/tests/test_validation_error_fields.py @@ -23,17 +23,19 @@ def test_input_and_url_fields(include_error_input, include_error_url): def path2(query_param: int): return query_param - router = APIRouter() + router = APIRouter( + include_error_input=include_error_input, include_error_url=include_error_url + ) - @app.get("/path3/{path_param}") + @router.get("/path3/{path_param}") def path3(path_param: int): return {"path_param": path_param} - @app.get("/path4/") + @router.get("/path4/") def path4(query_param: int): return query_param - app.include_router(router, prefix="/prefix") + app.include_router(router) client = TestClient(app) with client: From 3a6a0a6a2dbedcbe79a20c4072cb5968bc91122d Mon Sep 17 00:00:00 2001 From: Kinuax Date: Sun, 27 Jul 2025 11:57:08 +0200 Subject: [PATCH 7/8] Simplify and extend tests --- tests/test_validation_error_fields.py | 140 ++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/tests/test_validation_error_fields.py b/tests/test_validation_error_fields.py index efe117ebb..d5a41dff4 100644 --- a/tests/test_validation_error_fields.py +++ b/tests/test_validation_error_fields.py @@ -1,8 +1,9 @@ import pytest from fastapi import APIRouter, FastAPI from fastapi.testclient import TestClient +from pydantic import BaseModel -from .utils import needs_pydanticv2 +from .utils import needs_pydanticv1, needs_pydanticv2 @needs_pydanticv2 @@ -10,38 +11,51 @@ from .utils import needs_pydanticv2 "include_error_input,include_error_url", [(False, False), (False, True), (True, False), (True, True)], ) -def test_input_and_url_fields(include_error_input, include_error_url): +def test_input_and_url_fields_with_pydanticv2(include_error_input, include_error_url): app = FastAPI( include_error_input=include_error_input, include_error_url=include_error_url ) - @app.get("/path1/{path_param}") - def path1(path_param: int): - return {"path_param": path_param} + @app.get("/get1/{path_param}") + def get1(path_param: int): + ... - @app.get("/path2/") - def path2(query_param: int): - return query_param + @app.get("/get2/") + def get2(query_param: int): + ... + + class Body1(BaseModel): + ... + + class Body2(BaseModel): + ... + + @app.post("/post1/") + def post1(body1: Body1, body2: Body2): + ... router = APIRouter( include_error_input=include_error_input, include_error_url=include_error_url ) - @router.get("/path3/{path_param}") - def path3(path_param: int): - return {"path_param": path_param} + @router.get("/get3/{path_param}") + def get3(path_param: int): + ... + + @router.get("/get4/") + def get4(query_param: int): + ... - @router.get("/path4/") - def path4(query_param: int): - return query_param + @router.post("/post2/") + def post2(body1: Body1, body2: Body2): + ... app.include_router(router) client = TestClient(app) - with client: invalid = "not-an-integer" - for path in ["path1", "path3"]: + for path in ["get1", "get3"]: response = client.get(f"/{path}/{invalid}") assert response.status_code == 422, response.text error = response.json()["detail"][0] @@ -54,7 +68,7 @@ def test_input_and_url_fields(include_error_input, include_error_url): else: assert "url" not in error - for path in ["path2", "path4"]: + for path in ["get2", "get4"]: response = client.get(f"/{path}/") assert response.status_code == 422, response.text error = response.json()["detail"][0] @@ -79,3 +93,95 @@ def test_input_and_url_fields(include_error_input, include_error_url): assert "url" in error else: assert "url" not in error + + for path in ["post1", "post2"]: + response = client.post(f"/{path}/", json=["not-a-dict"]) + assert response.status_code == 422 + error = response.json()["detail"][0] + if include_error_input: + assert error["type"] == "missing" + assert error["input"] is None + else: + assert "input" not in error + if include_error_url: + assert "url" in error + else: + assert "url" not in error + + +# TODO: remove when deprecating Pydantic v1 +@needs_pydanticv1 +@pytest.mark.parametrize( + "include_error_input,include_error_url", + [(False, False), (False, True), (True, False), (True, True)], +) +def test_input_and_url_fields_with_pydanticv1(include_error_input, include_error_url): + app = FastAPI( + include_error_input=include_error_input, include_error_url=include_error_url + ) + + @app.get("/get1/{path_param}") + def get1(path_param: int): + ... + + @app.get("/get2/") + def get2(query_param: int): + ... + + class Body1(BaseModel): + ... + + class Body2(BaseModel): + ... + + @app.post("/post1/") + def post1(body1: Body1, body2: Body2): + ... + + router = APIRouter( + include_error_input=include_error_input, include_error_url=include_error_url + ) + + @router.get("/get3/{path_param}") + def get3(path_param: int): + ... + + @router.get("/get4/") + def get4(query_param: int): + ... + + @router.post("/post2/") + def post2(body1: Body1, body2: Body2): + ... + + app.include_router(router) + client = TestClient(app) + with client: + invalid = "not-an-integer" + + for path in ["get1", "get3"]: + response = client.get(f"/{path}/{invalid}") + assert response.status_code == 422, response.text + error = response.json()["detail"][0] + assert "input" not in error + assert "url" not in error + + for path in ["get2", "get4"]: + response = client.get(f"/{path}/") + assert response.status_code == 422, response.text + error = response.json()["detail"][0] + assert "input" not in error + assert "url" not in error + + response = client.get(f"/{path}/?query_param={invalid}") + assert response.status_code == 422, response.text + error = response.json()["detail"][0] + assert "input" not in error + assert "url" not in error + + for path in ["post1", "post2"]: + response = client.post(f"/{path}/", json=["not-a-dict"]) + assert response.status_code == 422 + error = response.json()["detail"][0] + assert "input" not in error + assert "url" not in error From ef4c2c8d275d906fb7e6262de0ca21c60a1f29ed Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 27 Jul 2025 10:00:32 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_validation_error_fields.py | 48 +++++++++------------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/tests/test_validation_error_fields.py b/tests/test_validation_error_fields.py index d5a41dff4..da1fc3d10 100644 --- a/tests/test_validation_error_fields.py +++ b/tests/test_validation_error_fields.py @@ -17,38 +17,30 @@ def test_input_and_url_fields_with_pydanticv2(include_error_input, include_error ) @app.get("/get1/{path_param}") - def get1(path_param: int): - ... + def get1(path_param: int): ... @app.get("/get2/") - def get2(query_param: int): - ... + def get2(query_param: int): ... - class Body1(BaseModel): - ... + class Body1(BaseModel): ... - class Body2(BaseModel): - ... + class Body2(BaseModel): ... @app.post("/post1/") - def post1(body1: Body1, body2: Body2): - ... + def post1(body1: Body1, body2: Body2): ... router = APIRouter( include_error_input=include_error_input, include_error_url=include_error_url ) @router.get("/get3/{path_param}") - def get3(path_param: int): - ... + def get3(path_param: int): ... @router.get("/get4/") - def get4(query_param: int): - ... + def get4(query_param: int): ... @router.post("/post2/") - def post2(body1: Body1, body2: Body2): - ... + def post2(body1: Body1, body2: Body2): ... app.include_router(router) client = TestClient(app) @@ -121,38 +113,30 @@ def test_input_and_url_fields_with_pydanticv1(include_error_input, include_error ) @app.get("/get1/{path_param}") - def get1(path_param: int): - ... + def get1(path_param: int): ... @app.get("/get2/") - def get2(query_param: int): - ... + def get2(query_param: int): ... - class Body1(BaseModel): - ... + class Body1(BaseModel): ... - class Body2(BaseModel): - ... + class Body2(BaseModel): ... @app.post("/post1/") - def post1(body1: Body1, body2: Body2): - ... + def post1(body1: Body1, body2: Body2): ... router = APIRouter( include_error_input=include_error_input, include_error_url=include_error_url ) @router.get("/get3/{path_param}") - def get3(path_param: int): - ... + def get3(path_param: int): ... @router.get("/get4/") - def get4(query_param: int): - ... + def get4(query_param: int): ... @router.post("/post2/") - def post2(body1: Body1, body2: Body2): - ... + def post2(body1: Body1, body2: Body2): ... app.include_router(router) client = TestClient(app)