diff --git a/usaspending_api/common/helpers/pydantic_error_formatter.py b/usaspending_api/common/helpers/pydantic_error_formatter.py index cdf5b4ec9c..e303ee6e02 100644 --- a/usaspending_api/common/helpers/pydantic_error_formatter.py +++ b/usaspending_api/common/helpers/pydantic_error_formatter.py @@ -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}" diff --git a/usaspending_api/common/pydantic_base_models/codes.py b/usaspending_api/common/pydantic_base_models/codes.py index 1ae02729da..65bc93b937 100644 --- a/usaspending_api/common/pydantic_base_models/codes.py +++ b/usaspending_api/common/pydantic_base_models/codes.py @@ -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 @@ -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 @@ -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