Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 44 additions & 18 deletions usaspending_api/common/helpers/pydantic_error_formatter.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,52 @@
from typing import Any

from pydantic import ValidationError

# Pydantic V2 error types mapped to the type names our API has always used
API_TYPE_BY_ERROR_TYPE = {
"bool_parsing": "boolean",
"bool_type": "boolean",
"date_from_datetime_parsing": "date",
"date_parsing": "date",
"dict_type": "object",
"float_parsing": "float",
"float_type": "float",
"int_parsing": "integer",
"int_type": "integer",
"list_type": "array",
"model_attributes_type": "object",
"model_type": "object",
"string_type": "text",
}


def _key_name(loc: tuple[str | int, ...]) -> str:
"""Convert a Pydantic error location into the pipe delimited key used in our API messages.

List indexes and union member tags are dropped, so ("filters", "recipient_locations", 0, "state") becomes
"filters|recipient_locations|state" and ("filters", "naics_codes", "list[str]") becomes "filters|"naics_codes".
"""

field_names = [part for part in loc if isinstance(part, str) and "[" not in part and part.islower()]
return "|".join(field_names)


def pydantic_error_formatter(error: ValidationError) -> str:
errors: list[dict] = error.errors()
def pydantic_error_formatter(error: ValidationError) -> str: # noqa: PLR0911
errors: list[dict[str, Any]] = error.errors()
key_name = _key_name(errors[0]["loc"])

for error in errors:
# Missing required fields
if error.get('msg') == 'Field required':
key_name = error['loc'][0]
key_errors = [err for err in errors if _key_name(err["loc"]) == key_name]
rule_error = next((err for err in key_errors if err["type"] not in API_TYPE_BY_ERROR_TYPE), None)

return f"Missing value: '{key_name}' is a required field"
if rule_error is None:
expected_types = ", ".join(dict.fromkeys(API_TYPE_BY_ERROR_TYPE[err["type"]] for err in key_errors))
return f"Invalid value in '{key_name}'. '{errors[0]['input']}' is not a valid type ({expected_types})."

# Incorrect type for a filter
else:
key_name = error["loc"][0] + "|" + error["loc"][1] if len(error['loc']) > 1 else error['loc'][0]
bad_value = error['input']
if rule_error["type"] == "missing":
return f"Missing value: '{key_name}' is a required field"

if error['type'].endswith('_type'):
expected_type = error['type'].strip("_type")
elif error.get('ctx'):
expected_type = error['ctx']['expected_type']
else:
expected_type = "dictionary"
if rule_error["type"] == "literal_error":
return f"Field '{key_name}' is outside valid values {rule_error['ctx']['expected']}"

return f"Invalid value in '{key_name}'. '{bad_value}' is not a valid type ({expected_type})."
message: str = rule_error["msg"].removeprefix("Value error, ")
return f"Invalid value in '{key_name}': {message}"
9 changes: 3 additions & 6 deletions usaspending_api/common/pydantic_base_models/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ def check_at_least_one_key(self) -> Self:
if self.require is None and self.exclude is None:
raise PydanticCustomError(
"missing_required_field",
'At least one of "require" or "exclude" must be provided',
{"expected_type": "array, object"},
'At least one of "require" or "exclude" must be provided'
)
return self

Expand All @@ -27,8 +26,7 @@ def check_at_least_one_key(self) -> Self:
if self.require is None and self.exclude is None:
raise PydanticCustomError(
"missing_required_field",
'At least one of "require" or "exclude" must be provided',
{"expected_type": "array, object"},
'At least one of "require" or "exclude" must be provided'
)
return self

Expand All @@ -42,8 +40,7 @@ def check_at_least_one_key(self) -> Self:
if self.require is None and self.exclude is None:
raise PydanticCustomError(
"missing_required_field",
'At least one of "require" or "exclude" must be provided',
{"expected_type": "array, object"}
'At least one of "require" or "exclude" must be provided'
)
return self

Expand Down
Loading