fix: strip 'body' root from validation error summary

This commit is contained in:
CREDO23 2026-07-23 19:39:15 +02:00
parent 15962d205f
commit 0118744c20
2 changed files with 49 additions and 2 deletions

View file

@ -243,7 +243,12 @@ def _validation_error_handler(
]
def _segment(field: dict[str, Any]) -> str:
loc = " -> ".join(field["loc"])
# Drop the "body" request root so model-level errors read as a plain
# sentence and field errors read as "field -> sub", not "body -> field".
path = field["loc"]
if path and path[0] == "body":
path = path[1:]
loc = " -> ".join(path)
return f"{loc}: {field['msg']}" if loc else field["msg"]
summary = "; ".join(_segment(f) for f in fields)

View file

@ -14,6 +14,7 @@ import json
import pytest
from fastapi import HTTPException
from pydantic import BaseModel, model_validator
from starlette.testclient import TestClient
from app.exceptions import (
@ -32,6 +33,23 @@ from app.exceptions import (
pytestmark = pytest.mark.unit
class _RequireOne(BaseModel):
"""Body model with a cross-field rule, used to test the 422 summary.
Defined at module scope (not inside the app factory) so FastAPI's ``Body``
``TypeAdapter`` can resolve the forward reference.
"""
a: str | None = None
b: str | None = None
@model_validator(mode="after")
def _at_least_one(self):
if not self.a and not self.b:
raise ValueError("Provide at least one of 'a' or 'b'.")
return self
# ---------------------------------------------------------------------------
# Helpers - lightweight FastAPI app that re-uses the real global handlers
# ---------------------------------------------------------------------------
@ -39,7 +57,7 @@ pytestmark = pytest.mark.unit
def _make_test_app():
"""Build a minimal FastAPI app with the same handlers as the real one."""
from fastapi import FastAPI
from fastapi import Body, FastAPI
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel
@ -124,6 +142,10 @@ def _make_test_app():
async def validated(item: Item):
return item.model_dump()
@app.post("/require-one")
async def require_one(payload: _RequireOne = Body(...)):
return payload.model_dump()
return app
@ -277,6 +299,26 @@ class TestValidationErrorHandler:
body = _assert_envelope(resp, 422)
assert body["error"]["code"] == "VALIDATION_ERROR"
def test_field_error_drops_body_root(self, client):
# The "body" request root is noise in the human summary; the field name
# is kept so clients can still attach the error inline.
resp = client.post("/require-one", json={"a": 123})
body = _assert_envelope(resp, 422)
msg = body["error"]["message"]
assert "body" not in msg
assert "a:" in msg
assert body["error"]["fields"][0]["loc"] == ["body", "a"]
def test_model_level_error_reads_as_sentence(self, client):
# A cross-field rule (loc == ["body"]) must read as a plain sentence,
# not "body: ...", while fields still carry the location for clients.
resp = client.post("/require-one", json={})
body = _assert_envelope(resp, 422)
assert body["error"]["message"] == (
"Validation failed: Provide at least one of 'a' or 'b'."
)
assert body["error"]["fields"][0]["loc"] == ["body"]
# ---------------------------------------------------------------------------
# SurfSenseError class hierarchy unit tests