Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ValidationException.errors() are ErrorDetails #11542

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/en/docs/tutorial/handling-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ path -> item_id
!!! warning
These are technical details that you might skip if it's not important for you now.

`RequestValidationError` is a sub-class of Pydantic's <a href="https://docs.pydantic.dev/latest/concepts/models/#error-handling" class="external-link" target="_blank">`ValidationError`</a>.
`RequestValidationError` is morally a sub-class of Pydantic's <a href="https://docs.pydantic.dev/latest/concepts/models/#error-handling" class="external-link" target="_blank">`ValidationError`</a>.

**FastAPI** uses it so that, if you use a Pydantic model in `response_model`, and your data has an error, you will see the error in your log.

Expand Down
106 changes: 62 additions & 44 deletions fastapi/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,19 @@
Union,
)

from fastapi.exceptions import RequestErrorModel
from fastapi.exceptions import ErrorDetails, RequestErrorModel
from fastapi.types import IncEx, ModelNameMap, UnionType
from pydantic import BaseModel, create_model
from pydantic.version import VERSION as P_VERSION
from starlette.datastructures import UploadFile
from typing_extensions import Annotated, Literal, get_args, get_origin
from typing_extensions import (
Annotated,
Literal,
TypeAlias,
assert_never,
get_args,
get_origin,
)

# Reassign variable to make it reexported for mypy
PYDANTIC_VERSION = P_VERSION
Expand Down Expand Up @@ -67,7 +74,7 @@
)
except ImportError: # pragma: no cover
from pydantic_core.core_schema import (
general_plain_validator_function as with_info_plain_validator_function, # noqa: F401
general_plain_validator_function as with_info_plain_validator_function,
)

Required = PydanticUndefined
Expand All @@ -82,6 +89,9 @@ class BaseConfig:
class ErrorWrapper(Exception):
pass

# See https://github.com/pydantic/pydantic/blob/07b6473/pydantic/v1/error_wrappers.py#L45-L47
ErrorList: TypeAlias = Union[Sequence["ErrorList"], ErrorWrapper]

@dataclass
class ModelField:
field_info: FieldInfo
Expand Down Expand Up @@ -115,22 +125,25 @@ def get_default(self) -> Any:
return Undefined
return self.field_info.get_default(call_default_factory=True)

# See https://github.com/pydantic/pydantic/blob/07b6473/pydantic/v1/fields.py#L850-L852 for the signature.
def validate(
self,
value: Any,
values: Dict[str, Any] = {}, # noqa: B006
*,
loc: Tuple[Union[int, str], ...] = (),
) -> Tuple[Any, Union[List[Dict[str, Any]], None]]:
) -> Tuple[Any, Union[ErrorList, Sequence[ErrorDetails], None]]:
try:
return (
self._type_adapter.validate_python(value, from_attributes=True),
None,
)
except ValidationError as exc:
return None, _regenerate_error_with_loc(
errors=exc.errors(include_url=False), loc_prefix=loc
)
errors: List[ErrorDetails] = [
{**err, "loc": loc + err.get("loc", ())} # type: ignore [typeddict-unknown-key]
for err in exc.errors(include_url=False)
]
return None, errors

def serialize(
self,
Expand Down Expand Up @@ -167,8 +180,16 @@ def get_annotation_from_field_info(
) -> Any:
return annotation

def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]:
return errors # type: ignore[return-value]
def _normalize_errors(
errors: Union[ErrorList, Sequence[ErrorDetails]],
) -> Sequence[ErrorDetails]:
assert isinstance(errors, Sequence), type(errors)
use_errors: List[ErrorDetails] = []
for error in errors:
assert not isinstance(error, ErrorWrapper)
assert not isinstance(error, Sequence)
use_errors.append(error)
return use_errors

def _model_rebuild(model: Type[BaseModel]) -> None:
model.model_rebuild()
Expand Down Expand Up @@ -265,12 +286,12 @@ def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]:
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, ...]) -> ErrorDetails:
error = ValidationError.from_exception_data(
"Field required", [{"type": "missing", "loc": loc, "input": {}}]
).errors(include_url=False)[0]
error["input"] = None
return error # type: ignore[return-value]
return error

def create_body_model(
*, fields: Sequence[ModelField], model_name: str
Expand All @@ -283,14 +304,17 @@ def create_body_model(
from fastapi.openapi.constants import REF_PREFIX as REF_PREFIX
from pydantic import AnyUrl as Url # noqa: F401
from pydantic import ( # type: ignore[assignment]
BaseConfig as BaseConfig, # noqa: F401
BaseConfig as BaseConfig,
)
from pydantic import ValidationError as ValidationError # noqa: F401
from pydantic import ValidationError as ValidationError
from pydantic.class_validators import ( # type: ignore[no-redef]
Validator as Validator, # noqa: F401
Validator as Validator,
)
from pydantic.error_wrappers import ( # type: ignore[no-redef]
ErrorWrapper as ErrorWrapper, # noqa: F401
ErrorList as ErrorList,
)
from pydantic.error_wrappers import ( # type: ignore[no-redef]
ErrorWrapper as ErrorWrapper,
)
from pydantic.errors import MissingError
from pydantic.fields import ( # type: ignore[attr-defined]
Expand All @@ -304,31 +328,31 @@ def create_body_model(
)
from pydantic.fields import FieldInfo as FieldInfo
from pydantic.fields import ( # type: ignore[no-redef,attr-defined]
ModelField as ModelField, # noqa: F401
ModelField as ModelField,
)
from pydantic.fields import ( # type: ignore[no-redef,attr-defined]
Required as Required, # noqa: F401
Required as Required,
)
from pydantic.fields import ( # type: ignore[no-redef,attr-defined]
Undefined as Undefined,
)
from pydantic.fields import ( # type: ignore[no-redef, attr-defined]
UndefinedType as UndefinedType, # noqa: F401
UndefinedType as UndefinedType,
)
from pydantic.schema import (
field_schema,
get_flat_models_from_fields,
get_model_name_map,
model_process_schema,
)
from pydantic.schema import ( # type: ignore[no-redef] # noqa: F401
from pydantic.schema import ( # type: ignore[no-redef]
get_annotation_from_field_info as get_annotation_from_field_info,
)
from pydantic.typing import ( # type: ignore[no-redef]
evaluate_forwardref as evaluate_forwardref, # noqa: F401
evaluate_forwardref as evaluate_forwardref,
)
from pydantic.utils import ( # type: ignore[no-redef]
lenient_issubclass as lenient_issubclass, # noqa: F401
lenient_issubclass as lenient_issubclass,
)

GetJsonSchemaHandler = Any # type: ignore[assignment,misc]
Expand Down Expand Up @@ -418,18 +442,23 @@ def is_pv1_scalar_sequence_field(field: ModelField) -> bool:
return True
return False

def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]:
use_errors: List[Any] = []
for error in errors:
if isinstance(error, ErrorWrapper):
new_errors = ValidationError( # type: ignore[call-arg]
errors=[error], model=RequestErrorModel
def _normalize_errors(
errors: Union[ErrorList, Sequence[ErrorDetails]],
) -> Sequence[ErrorDetails]:
use_errors: List[ErrorDetails] = []
if isinstance(errors, ErrorWrapper):
use_errors.extend(
ValidationError( # type: ignore[call-arg]
errors=[errors], model=RequestErrorModel
).errors()
use_errors.extend(new_errors)
elif isinstance(error, list):
)
elif isinstance(errors, Sequence):
for error in errors:
assert not isinstance(error, dict)
use_errors.extend(_normalize_errors(error))
else:
use_errors.append(error)
return use_errors
else:
assert_never(errors) # pragma: no cover
return use_errors

def _model_rebuild(model: Type[BaseModel]) -> None:
Expand Down Expand Up @@ -500,10 +529,10 @@ def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo:
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, ...]) -> ErrorDetails:
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]
return new_error.errors()[0]

def create_body_model(
*, fields: Sequence[ModelField], model_name: str
Expand All @@ -514,17 +543,6 @@ def create_body_model(
return BodyModel


def _regenerate_error_with_loc(
*, errors: Sequence[Any], loc_prefix: Tuple[Union[str, int], ...]
) -> List[Dict[str, Any]]:
updated_loc_errors: List[Any] = [
{**err, "loc": loc_prefix + err.get("loc", ())}
for err in _normalize_errors(errors)
]

return updated_loc_errors


def _annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool:
if lenient_issubclass(annotation, (str, bytes)):
return False
Expand Down
33 changes: 14 additions & 19 deletions fastapi/dependencies/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@
from fastapi import params
from fastapi._compat import (
PYDANTIC_V2,
ErrorWrapper,
ModelField,
Required,
Undefined,
_regenerate_error_with_loc,
_normalize_errors,
copy_field_info,
create_body_model,
evaluate_forwardref,
Expand All @@ -50,6 +49,7 @@
contextmanager_in_threadpool,
)
from fastapi.dependencies.models import Dependant, SecurityRequirement
from fastapi.exceptions import ErrorDetails
from fastapi.logger import logger
from fastapi.security.base import SecurityBase
from fastapi.security.oauth2 import OAuth2, SecurityScopes
Expand Down Expand Up @@ -533,13 +533,13 @@ async def solve_dependencies(
async_exit_stack: AsyncExitStack,
) -> Tuple[
Dict[str, Any],
List[Any],
Sequence[ErrorDetails],
Optional[StarletteBackgroundTasks],
Response,
Dict[Tuple[Callable[..., Any], Tuple[str]], Any],
]:
values: Dict[str, Any] = {}
errors: List[Any] = []
errors: List[ErrorDetails] = []
if response is None:
response = Response()
del response.headers["content-length"]
Expand Down Expand Up @@ -620,7 +620,8 @@ async def solve_dependencies(
values.update(query_values)
values.update(header_values)
values.update(cookie_values)
errors += path_errors + query_errors + header_errors + cookie_errors
for errors_ in (path_errors, query_errors, header_errors, cookie_errors):
errors.extend(errors_)
if dependant.body_params:
(
body_values,
Expand Down Expand Up @@ -652,9 +653,9 @@ async def solve_dependencies(
def request_params_to_args(
required_params: Sequence[ModelField],
received_params: Union[Mapping[str, Any], QueryParams, Headers],
) -> Tuple[Dict[str, Any], List[Any]]:
) -> Tuple[Dict[str, Any], Sequence[ErrorDetails]]:
values = {}
errors = []
errors: List[ErrorDetails] = []
for field in required_params:
if is_scalar_sequence_field(field) and isinstance(
received_params, (QueryParams, Headers)
Expand All @@ -674,11 +675,8 @@ def request_params_to_args(
values[field.name] = deepcopy(field.default)
continue
v_, errors_ = field.validate(value, values, loc=loc)
if isinstance(errors_, ErrorWrapper):
errors.append(errors_)
elif isinstance(errors_, list):
new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
errors.extend(new_errors)
if errors_ is not None:
errors.extend(_normalize_errors(errors_))
else:
values[field.name] = v_
return values, errors
Expand All @@ -687,9 +685,9 @@ def request_params_to_args(
async def request_body_to_args(
required_params: List[ModelField],
received_body: Optional[Union[Dict[str, Any], FormData]],
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
) -> Tuple[Dict[str, Any], Sequence[ErrorDetails]]:
values = {}
errors: List[Dict[str, Any]] = []
errors: List[ErrorDetails] = []
if required_params:
field = required_params[0]
field_info = field.field_info
Expand Down Expand Up @@ -756,11 +754,8 @@ async def process_fn(
value = serialize_sequence_value(field=field, value=results)

v_, errors_ = field.validate(value, values, loc=loc)

if isinstance(errors_, list):
errors.extend(errors_)
elif errors_:
errors.append(errors_)
if errors_ is not None:
errors.extend(_normalize_errors(errors_))
else:
values[field.name] = v_
return values, errors
Expand Down
25 changes: 19 additions & 6 deletions fastapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
from typing import Any, Dict, Optional, Sequence, Type, Union
from typing import Any, Dict, Optional, Sequence, Tuple, Type, Union

from pydantic import BaseModel, create_model
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.exceptions import WebSocketException as StarletteWebSocketException
from typing_extensions import Annotated, Doc
from typing_extensions import Annotated, Doc, TypedDict


class ErrorDetails(TypedDict):
"""
The common subset shared by `ErrorDict` in Pydantic V1[0] and `ErrorDetails` in Pydantic V2[1].

[0] https://github.com/pydantic/pydantic/blob/4d7bef62aeff10985fe67d13477fe666b13ba070/pydantic/v1/error_wrappers.py#L21-L22
[1] https://github.com/pydantic/pydantic-core/blob/e1fc99dd3207157aad77defc20ab6873fd268b5b/python/pydantic_core/__init__.py#L73-L91
"""

loc: Tuple[Union[int, str], ...]
msg: str
type: str


class HTTPException(StarletteHTTPException):
Expand Down Expand Up @@ -147,15 +160,15 @@ class FastAPIError(RuntimeError):


class ValidationException(Exception):
def __init__(self, errors: Sequence[Any]) -> None:
def __init__(self, errors: Sequence[ErrorDetails]) -> None:
self._errors = errors

def errors(self) -> Sequence[Any]:
def errors(self) -> Sequence[ErrorDetails]:
return self._errors


class RequestValidationError(ValidationException):
def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None:
def __init__(self, errors: Sequence[ErrorDetails], *, body: Any = None) -> None:
super().__init__(errors)
self.body = body

Expand All @@ -165,7 +178,7 @@ class WebSocketRequestValidationError(ValidationException):


class ResponseValidationError(ValidationException):
def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None:
def __init__(self, errors: Sequence[ErrorDetails], *, body: Any = None) -> None:
super().__init__(errors)
self.body = body

Expand Down