Skip to content

Commit 8a6efce

Browse files
committed
feat(validation): provide useful structured validation errors
1 parent bf596c0 commit 8a6efce

5 files changed

Lines changed: 68 additions & 7 deletions

File tree

cyclonedx/validation/__init__.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,23 @@ class ValidationError:
3737
data: Any
3838
"""Raw error data from one of the underlying validation methods."""
3939

40-
def __init__(self, data: Any) -> None:
40+
message: str
41+
"""Human-readable error message suitable for end-user presentation."""
42+
43+
path: tuple[Union[str, int], ...]
44+
"""Path to the offending value if known."""
45+
46+
def __init__(self, data: Any, *, message: Optional[str] = None,
47+
path: Iterable[Union[str, int]] = ()) -> None:
4148
self.data = data
49+
self.message = str(data) if message is None else message
50+
self.path = tuple(path)
4251

4352
def __repr__(self) -> str:
44-
return repr(self.data)
53+
return f'{self.__class__.__name__}(message={self.message!r}, path={self.path!r})'
4554

4655
def __str__(self) -> str:
47-
return str(self.data)
56+
return self.message
4857

4958

5059
class SchemabasedValidator(Protocol):

cyclonedx/validation/json.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,24 @@
5656

5757

5858
class JsonValidationError(ValidationError):
59+
@classmethod
60+
def __get_most_relevant_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonSchemaValidationError':
61+
if not e.context:
62+
return e
63+
# nested `context` errors generally provide more useful details than
64+
# generic parent messages, e.g. for oneOf/anyOf checks.
65+
child = max(e.context, key=lambda ce: len(ce.absolute_path))
66+
return cls.__get_most_relevant_jsve(child)
67+
5968
@classmethod
6069
def _make_from_jsve(cls, e: 'JsonSchemaValidationError') -> 'JsonValidationError':
6170
"""⚠️ This is an internal API. It is not part of the public interface and may change without notice."""
62-
# in preparation for https://github.com/CycloneDX/cyclonedx-python-lib/pull/836
63-
return cls(e)
71+
useful = cls.__get_most_relevant_jsve(e)
72+
return cls(
73+
e,
74+
message=useful.message,
75+
path=tuple(useful.absolute_path)
76+
)
6477

6578

6679
class _BaseJsonValidator(BaseSchemabasedValidator, ABC):

cyclonedx/validation/xml.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@ class XmlValidationError(ValidationError):
5151
@classmethod
5252
def _make_from_xle(cls, e: '_XmlLogEntry') -> 'XmlValidationError':
5353
"""⚠️ This is an internal API. It is not part of the public interface and may change without notice."""
54-
# in preparation for https://github.com/CycloneDX/cyclonedx-python-lib/pull/836
55-
return cls(e)
54+
return cls(e, message=e.message, path=(e.path,) if e.path else ())
5655

5756

5857
class _BaseXmlValidator(BaseSchemabasedValidator, ABC):

tests/test_validation_json.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,32 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t
113113
self.assertIsNotNone(validation_error.data)
114114

115115

116+
117+
def test_validation_error_has_useful_message_and_path(self) -> None:
118+
validator = JsonValidator(SchemaVersion.V1_6)
119+
test_data = '{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "metadata": {"timestamp": true}}'
120+
try:
121+
validation_error = validator.validate_str(test_data)
122+
except MissingOptionalDependencyException:
123+
self.skipTest('MissingOptionalDependencyException')
124+
self.assertIsNotNone(validation_error)
125+
assert validation_error is not None
126+
self.assertTrue(validation_error.message)
127+
self.assertEqual(('metadata', 'timestamp'), validation_error.path)
128+
self.assertNotEqual(str(validation_error.data), str(validation_error))
129+
130+
def test_validation_error_prefers_nested_context_message(self) -> None:
131+
validator = JsonValidator(SchemaVersion.V1_6)
132+
test_data = '{"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "components": [{"type": "library", "name": "demo", "version": 1}]}'
133+
try:
134+
validation_error = validator.validate_str(test_data)
135+
except MissingOptionalDependencyException:
136+
self.skipTest('MissingOptionalDependencyException')
137+
self.assertIsNotNone(validation_error)
138+
assert validation_error is not None
139+
self.assertEqual(('components', 0, 'version'), validation_error.path)
140+
self.assertIn('is not of type', validation_error.message)
141+
116142
@ddt
117143
class TestJsonStrictValidator(TestCase):
118144

tests/test_validation_xml.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,17 @@ def test_validate_expected_error_iterator(self, schema_version: SchemaVersion, t
111111
self.assertGreater(len(validation_errors), 0)
112112
for validation_error in validation_errors:
113113
self.assertIsNotNone(validation_error.data)
114+
115+
def test_validation_error_has_useful_message_and_path(self) -> None:
116+
validator = XmlValidator(SchemaVersion.V1_6)
117+
test_data = '<bom xmlns="http://cyclonedx.org/schema/bom/1.6" version="1"><metadata><timestamp>not-a-date</timestamp></metadata></bom>'
118+
try:
119+
validation_error = validator.validate_str(test_data)
120+
except MissingOptionalDependencyException:
121+
self.skipTest('MissingOptionalDependencyException')
122+
self.assertIsNotNone(validation_error)
123+
assert validation_error is not None
124+
self.assertTrue(validation_error.message)
125+
self.assertTrue(validation_error.path)
126+
self.assertNotEqual(str(validation_error.data), str(validation_error))
127+

0 commit comments

Comments
 (0)