Skip to content

Commit cc2225e

Browse files
authored
Merge pull request #1129 from python-openapi/feature/structured-validation-error-details
Add structured details for validation errors
2 parents 7d97d29 + 684bfba commit cc2225e

File tree

4 files changed

+113
-1
lines changed

4 files changed

+113
-1
lines changed

docs/validation.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,17 @@ from openapi_core import V31RequestValidator
6464
errors = list(V31RequestValidator(spec).iter_errors(request))
6565
```
6666

67-
Some high-level errors wrap detailed schema errors. To access nested schema details:
67+
Validation errors expose structured details directly:
68+
69+
```python
70+
for error in openapi.iter_request_errors(request):
71+
details = getattr(error, "details", {})
72+
print(details.get("message"))
73+
for schema_error in details.get("schema_errors", []):
74+
print(schema_error["message"], schema_error["path"])
75+
```
76+
77+
Some high-level errors wrap detailed schema errors in `__cause__`. You can still access those low-level objects directly:
6878

6979
```python
7080
for error in openapi.iter_request_errors(request):
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,46 @@
11
"""OpenAPI core validation exceptions module"""
22

33
from dataclasses import dataclass
4+
from typing import Any
45

56
from openapi_core.exceptions import OpenAPIError
67

78

9+
def _schema_error_to_dict(schema_error: Exception) -> dict[str, Any]:
10+
message = getattr(schema_error, "message", str(schema_error))
11+
raw_path = getattr(schema_error, "path", ())
12+
try:
13+
path = list(raw_path)
14+
except TypeError:
15+
path = []
16+
return {
17+
"message": message,
18+
"path": path,
19+
}
20+
21+
822
@dataclass
923
class ValidationError(OpenAPIError):
24+
@property
25+
def details(self) -> dict[str, Any]:
26+
cause = self.__cause__
27+
schema_errors: list[dict[str, Any]] = []
28+
if cause is not None:
29+
cause_schema_errors = getattr(cause, "schema_errors", None)
30+
if cause_schema_errors is not None:
31+
schema_errors = [
32+
_schema_error_to_dict(schema_error)
33+
for schema_error in cause_schema_errors
34+
]
35+
36+
return {
37+
"message": str(self),
38+
"error_type": self.__class__.__name__,
39+
"cause_type": (
40+
cause.__class__.__name__ if cause is not None else None
41+
),
42+
"schema_errors": schema_errors,
43+
}
44+
1045
def __str__(self) -> str:
1146
return f"{self.__class__.__name__}: {self.__cause__}"

tests/integration/unmarshalling/test_request_unmarshaller.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ def test_missing_body(self, request_unmarshaller):
163163

164164
assert len(result.errors) == 1
165165
assert type(result.errors[0]) == MissingRequiredRequestBody
166+
assert result.errors[0].details == {
167+
"message": "Missing required request body",
168+
"error_type": "MissingRequiredRequestBody",
169+
"cause_type": None,
170+
"schema_errors": [],
171+
}
166172
assert result.body is None
167173
assert result.parameters == Parameters(
168174
header={

tests/integration/validation/test_strict_json_validation.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,67 @@ def test_request_validator_error_message_includes_cause_details() -> None:
192192
assert "'30' is not of type 'integer'" in error_message
193193

194194

195+
def test_request_validator_error_details_are_structured() -> None:
196+
spec = _spec_schema_path()
197+
validator = V30RequestValidator(spec)
198+
199+
request_json = {
200+
"id": "123e4567-e89b-12d3-a456-426614174000",
201+
"username": "Test User",
202+
"age": "30",
203+
}
204+
request = MockRequest(
205+
"http://example.com",
206+
"post",
207+
"/users",
208+
content_type="application/json",
209+
data=json.dumps(request_json).encode("utf-8"),
210+
)
211+
212+
with pytest.raises(InvalidRequestBody) as exc_info:
213+
validator.validate(request)
214+
215+
details = exc_info.value.details
216+
assert details["error_type"] == "InvalidRequestBody"
217+
assert details["cause_type"] == "InvalidSchemaValue"
218+
assert details["schema_errors"] == [
219+
{
220+
"message": "'30' is not of type 'integer'",
221+
"path": ["age"],
222+
}
223+
]
224+
225+
226+
def test_response_validator_error_details_are_structured() -> None:
227+
spec = _spec_schema_path()
228+
validator = V30ResponseValidator(spec)
229+
230+
request = MockRequest("http://example.com", "get", "/users")
231+
response_json = {
232+
"id": "123e4567-e89b-12d3-a456-426614174000",
233+
"username": "Test User",
234+
"age": "30",
235+
}
236+
response = MockResponse(
237+
json.dumps(response_json).encode("utf-8"),
238+
status_code=200,
239+
content_type="application/json",
240+
)
241+
242+
with pytest.raises(InvalidData) as exc_info:
243+
validator.validate(request, response)
244+
245+
details = exc_info.value.details
246+
assert details["error_type"] == "InvalidData"
247+
assert details["cause_type"] == "InvalidSchemaValue"
248+
assert details["schema_errors"] == [
249+
{
250+
"message": "'30' is not of type 'integer'",
251+
"path": ["age"],
252+
}
253+
]
254+
255+
195256
def test_response_validator_strict_json_nested_types() -> None:
196257
"""Test that nested JSON structures (arrays, objects) remain strict."""
197258
spec_dict = {

0 commit comments

Comments
 (0)