From 684bfbaf0bd5e4cbb0d6b46eda20edd482873061 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 4 Mar 2026 00:32:15 +0000 Subject: [PATCH] Add structured details for validation errors --- docs/validation.md | 12 +++- openapi_core/validation/exceptions.py | 35 +++++++++++ .../test_request_unmarshaller.py | 6 ++ .../validation/test_strict_json_validation.py | 61 +++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/docs/validation.md b/docs/validation.md index 72656df5..dc76e830 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -64,7 +64,17 @@ from openapi_core import V31RequestValidator errors = list(V31RequestValidator(spec).iter_errors(request)) ``` -Some high-level errors wrap detailed schema errors. To access nested schema details: +Validation errors expose structured details directly: + +```python +for error in openapi.iter_request_errors(request): + details = getattr(error, "details", {}) + print(details.get("message")) + for schema_error in details.get("schema_errors", []): + print(schema_error["message"], schema_error["path"]) +``` + +Some high-level errors wrap detailed schema errors in `__cause__`. You can still access those low-level objects directly: ```python for error in openapi.iter_request_errors(request): diff --git a/openapi_core/validation/exceptions.py b/openapi_core/validation/exceptions.py index 95b87cda..36cea031 100644 --- a/openapi_core/validation/exceptions.py +++ b/openapi_core/validation/exceptions.py @@ -1,11 +1,46 @@ """OpenAPI core validation exceptions module""" from dataclasses import dataclass +from typing import Any from openapi_core.exceptions import OpenAPIError +def _schema_error_to_dict(schema_error: Exception) -> dict[str, Any]: + message = getattr(schema_error, "message", str(schema_error)) + raw_path = getattr(schema_error, "path", ()) + try: + path = list(raw_path) + except TypeError: + path = [] + return { + "message": message, + "path": path, + } + + @dataclass class ValidationError(OpenAPIError): + @property + def details(self) -> dict[str, Any]: + cause = self.__cause__ + schema_errors: list[dict[str, Any]] = [] + if cause is not None: + cause_schema_errors = getattr(cause, "schema_errors", None) + if cause_schema_errors is not None: + schema_errors = [ + _schema_error_to_dict(schema_error) + for schema_error in cause_schema_errors + ] + + return { + "message": str(self), + "error_type": self.__class__.__name__, + "cause_type": ( + cause.__class__.__name__ if cause is not None else None + ), + "schema_errors": schema_errors, + } + def __str__(self) -> str: return f"{self.__class__.__name__}: {self.__cause__}" diff --git a/tests/integration/unmarshalling/test_request_unmarshaller.py b/tests/integration/unmarshalling/test_request_unmarshaller.py index 0eefa3f0..73ad27d8 100644 --- a/tests/integration/unmarshalling/test_request_unmarshaller.py +++ b/tests/integration/unmarshalling/test_request_unmarshaller.py @@ -163,6 +163,12 @@ def test_missing_body(self, request_unmarshaller): assert len(result.errors) == 1 assert type(result.errors[0]) == MissingRequiredRequestBody + assert result.errors[0].details == { + "message": "Missing required request body", + "error_type": "MissingRequiredRequestBody", + "cause_type": None, + "schema_errors": [], + } assert result.body is None assert result.parameters == Parameters( header={ diff --git a/tests/integration/validation/test_strict_json_validation.py b/tests/integration/validation/test_strict_json_validation.py index 738ba9c9..eb5c4e04 100644 --- a/tests/integration/validation/test_strict_json_validation.py +++ b/tests/integration/validation/test_strict_json_validation.py @@ -192,6 +192,67 @@ def test_request_validator_error_message_includes_cause_details() -> None: assert "'30' is not of type 'integer'" in error_message +def test_request_validator_error_details_are_structured() -> None: + spec = _spec_schema_path() + validator = V30RequestValidator(spec) + + request_json = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "username": "Test User", + "age": "30", + } + request = MockRequest( + "http://example.com", + "post", + "/users", + content_type="application/json", + data=json.dumps(request_json).encode("utf-8"), + ) + + with pytest.raises(InvalidRequestBody) as exc_info: + validator.validate(request) + + details = exc_info.value.details + assert details["error_type"] == "InvalidRequestBody" + assert details["cause_type"] == "InvalidSchemaValue" + assert details["schema_errors"] == [ + { + "message": "'30' is not of type 'integer'", + "path": ["age"], + } + ] + + +def test_response_validator_error_details_are_structured() -> None: + spec = _spec_schema_path() + validator = V30ResponseValidator(spec) + + request = MockRequest("http://example.com", "get", "/users") + response_json = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "username": "Test User", + "age": "30", + } + response = MockResponse( + json.dumps(response_json).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + with pytest.raises(InvalidData) as exc_info: + validator.validate(request, response) + + details = exc_info.value.details + assert details["error_type"] == "InvalidData" + assert details["cause_type"] == "InvalidSchemaValue" + assert details["schema_errors"] == [ + { + "message": "'30' is not of type 'integer'", + "path": ["age"], + } + ] + + def test_response_validator_strict_json_nested_types() -> None: """Test that nested JSON structures (arrays, objects) remain strict.""" spec_dict = {