Skip to content
Open
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
119 changes: 101 additions & 18 deletions python/turboapi/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,81 @@ def _returns_model(handler) -> bool | None:
return None

class RequestParsingError(ValueError):
"""Raised when request data cannot be parsed into handler parameters."""
"""Raised when request data cannot be parsed into handler parameters.

``errors`` optionally carries FastAPI-style structured validation details
(``[{"loc": [...], "msg": ..., "type": ...}, ...]``). It is only populated
on the error path, so successful requests pay zero extra cost.
"""

def __init__(self, message: str, errors: list | None = None):
super().__init__(message)
self.errors = errors


_JSON_SAFE = (str, int, float, bool, type(None), list, dict)


def _validation_error_details(exc, param_name: str, embed: bool = False) -> list | None:
"""Extract FastAPI-style error details from a validation exception.

Supports both Pydantic (``exc.errors()`` returning dicts) and dhi
(``exc.errors`` list of objects with ``field``/``message``). Returns None
for anything else so callers fall back to the legacy string detail.
Only ever invoked after validation has already failed (error path).

``embed`` mirrors FastAPI's loc convention: a single body model maps to
["body", <field>], while embedded/multi-param bodies include the
parameter name: ["body", <param>, <field>].
"""
prefix = ["body", param_name] if embed else ["body"]
try:
errors_attr = getattr(exc, "errors", None)
if callable(errors_attr): # pydantic.ValidationError
details = []
for err in errors_attr():
detail = {
"type": err.get("type", "value_error"),
"loc": [*prefix, *(str(p) for p in err.get("loc", ()))],
"msg": err.get("msg", "Invalid value"),
}
# Match FastAPI's payload where safely JSON-serializable
if isinstance(err.get("input"), _JSON_SAFE):
detail["input"] = err["input"]
Comment on lines +83 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard optional validation input before indexing

When a Pydantic-style errors() result omits the optional input key (for example v1-shaped error dicts or compatible validators), err.get("input") returns None, which passes _JSON_SAFE; the subsequent err["input"] then raises KeyError. Because the helper catches that and returns None, these model validation failures fall back to the legacy 400 response instead of the new structured 422 detail, so check that the key exists before copying it.

Useful? React with 👍 / 👎.

if isinstance(err.get("ctx"), dict) and all(
isinstance(v, _JSON_SAFE) for v in err["ctx"].values()
):
detail["ctx"] = err["ctx"]
details.append(detail)
return details or None
if isinstance(errors_attr, (list, tuple)): # dhi.ValidationErrors
details = []
for err in errors_attr:
field = getattr(err, "field", None)
msg = getattr(err, "message", None)
if field is None and msg is None:
return None
details.append({
"type": "value_error",
"loc": prefix + ([str(field)] if field else []),
"msg": str(msg) if msg is not None else "Invalid value",
})
return details or None
except Exception:
return None
return None


def _parsing_error_response(e: RequestParsingError) -> tuple[int, dict]:
"""(status_code, payload) for a RequestParsingError.

Structured validation failures use FastAPI-compatible 422 responses
(matching the Zig-native dhi validator); everything else keeps the
legacy 400 shape.
"""
if getattr(e, "errors", None):
return 422, {"detail": e.errors}
return 400, {"error": "Bad Request", "detail": str(e)}


class DependencyResolver:
Expand Down Expand Up @@ -427,7 +501,10 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
parsed_params[param_name] = validated_model
return parsed_params
except Exception as e:
raise RequestParsingError(f"Validation error for {param_name}: {e}")
raise RequestParsingError(
f"Validation error for {param_name}: {e}",
errors=_validation_error_details(e, param_name),
)

# If annotated as dict or list, pass entire body
elif param.annotation in (dict, list) or param.annotation == inspect.Parameter.empty:
Expand All @@ -448,7 +525,10 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
parsed_params[param_name] = validated_model
return parsed_params
except Exception as e:
raise RequestParsingError(f"Validation error for {param_name}: {e}")
raise RequestParsingError(
f"Validation error for {param_name}: {e}",
errors=_validation_error_details(e, param_name),
)

# Unknown class annotation with single param — try direct construction
if inspect.isclass(param.annotation):
Expand Down Expand Up @@ -481,14 +561,20 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
validated_model = param.annotation.model_validate(json_data)
parsed_params[param_name] = validated_model
except Exception as e:
raise RequestParsingError(f"Validation error for {param_name}: {e}")
raise RequestParsingError(
f"Validation error for {param_name}: {e}",
errors=_validation_error_details(e, param_name, embed=True),
)

# Check for Pydantic-like model (model_validate but not Satya)
elif inspect.isclass(param.annotation) and hasattr(param.annotation, "model_validate"):
try:
parsed_params[param_name] = param.annotation.model_validate(json_data)
except Exception as e:
raise RequestParsingError(f"Validation error for {param_name}: {e}")
raise RequestParsingError(
f"Validation error for {param_name}: {e}",
errors=_validation_error_details(e, param_name, embed=True),
)
# Check if parameter name exists in JSON data
elif param_name in json_data:
value = json_data[param_name]
Expand Down Expand Up @@ -1040,9 +1126,8 @@ async def enhanced_handler(**kwargs):
)

except RequestParsingError as e:
return ResponseHandler.format_json_response(
{"error": "Bad Request", "detail": str(e)}, 400
)
status, payload = _parsing_error_response(e)
return ResponseHandler.format_json_response(payload, status)
except Exception as e:
from turboapi.security import HTTPException

Expand Down Expand Up @@ -1223,9 +1308,8 @@ def enhanced_handler(**kwargs):
)

except RequestParsingError as e:
return ResponseHandler.format_json_response(
{"error": "Bad Request", "detail": str(e)}, 400
)
status, payload = _parsing_error_response(e)
return ResponseHandler.format_json_response(payload, status)
except Exception as e:
from turboapi.security import HTTPException

Expand Down Expand Up @@ -1415,7 +1499,8 @@ def fast_handler(**kwargs):
return (result[1], "application/json", _dumps(result[0]))
return (200, "application/json", _dumps(result))
except RequestParsingError as e:
return (400, "application/json", _dumps({"error": "Bad Request", "detail": str(e)}))
status, payload = _parsing_error_response(e)
return (status, "application/json", _dumps(payload))
except Exception as e:
if isinstance(e, HTTPException):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
Expand Down Expand Up @@ -1494,11 +1579,8 @@ def fast_handler_eager(**kwargs):
try:
return _run_eager(original_handler(**build_call_kwargs(kwargs)))
except RequestParsingError as e:
return (
400,
"application/json",
_dumps({"error": "Bad Request", "detail": str(e)}),
)
status, payload = _parsing_error_response(e)
return (status, "application/json", _dumps(payload))
except Exception as e:
if isinstance(e, HTTPException):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
Expand All @@ -1524,7 +1606,8 @@ async def fast_handler(**kwargs):
return (result[1], "application/json", _dumps(result[0]))
return (200, "application/json", _dumps(result))
except RequestParsingError as e:
return (400, "application/json", _dumps({"error": "Bad Request", "detail": str(e)}))
status, payload = _parsing_error_response(e)
return (status, "application/json", _dumps(payload))
except Exception as e:
if isinstance(e, HTTPException):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
Expand Down
Loading