Skip to content

Commit 3607d93

Browse files
authored
Merge pull request #1 from Nestmed/add-custom-validation
Add custom validation
2 parents f3864d4 + a2d8b8f commit 3607d93

File tree

3 files changed

+335
-0
lines changed

3 files changed

+335
-0
lines changed

jsonschema/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
Draft202012Validator,
2323
validate,
2424
)
25+
from jsonschema.custom_validators import (
26+
CustomValidator,
27+
custom_validate,
28+
)
2529

2630

2731
def __getattr__(name):
@@ -117,4 +121,6 @@ def __getattr__(name):
117121
"TypeChecker",
118122
"ValidationError",
119123
"validate",
124+
"CustomValidator",
125+
"custom_validate",
120126
]

jsonschema/custom_validators.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Custom validator implementation that allows None values for any property and has special handling
3+
for enum values and additionalProperties validation.
4+
"""
5+
from __future__ import annotations
6+
7+
from typing import Any, Dict, Iterator, Callable, List, Tuple, Union
8+
9+
from jsonschema import ValidationError, Draft7Validator, validators
10+
from jsonschema.validators import extend
11+
12+
13+
def extend_with_default(validator_class):
14+
"""
15+
Creates a custom validator that:
16+
1. Allows None for any type, especially objects
17+
2. Allows None for any enum property by default
18+
3. Adds special handling for additionalProperties validation
19+
4. Skips validation for missing or None properties
20+
"""
21+
validate_properties = validator_class.VALIDATORS["properties"]
22+
validate_type = validator_class.VALIDATORS["type"]
23+
validate_enum = validator_class.VALIDATORS.get("enum")
24+
validate_additional_properties = validator_class.VALIDATORS.get("additionalProperties")
25+
26+
def set_defaults(validator, properties, instance, schema):
27+
# Skip validation if instance is None
28+
if instance is None:
29+
return
30+
31+
for property, subschema in properties.items():
32+
# If the property is missing in the instance, skip validation for it
33+
if property not in instance or instance.get(property) is None:
34+
continue
35+
for error in validate_properties(
36+
validator,
37+
properties,
38+
instance,
39+
schema,
40+
):
41+
yield error
42+
43+
def ignore_none(validator, types, instance, schema):
44+
# Allow None for any type, especially objects
45+
if instance is None:
46+
return
47+
for error in validate_type(validator, types, instance, schema):
48+
yield error
49+
50+
def enum_with_nullable(validator, enums, instance, schema):
51+
# Allow None for any enum property by default
52+
if instance is None:
53+
return
54+
if instance not in enums:
55+
yield ValidationError(f"{instance} is not one of {enums}")
56+
57+
def validate_additional(validator, additional_properties, instance, schema):
58+
# Ensure that instance is not None before iterating
59+
if instance is None:
60+
return
61+
# Raise an error if additional properties are not allowed in the schema
62+
if not additional_properties:
63+
for property in instance:
64+
if property not in schema.get("properties", {}):
65+
yield ValidationError(f"Additional property '{property}' is not allowed.")
66+
67+
return validators.extend(
68+
validator_class,
69+
{
70+
"properties": set_defaults,
71+
"type": ignore_none,
72+
"enum": enum_with_nullable,
73+
"additionalProperties": validate_additional,
74+
},
75+
)
76+
77+
78+
CustomValidator = extend_with_default(Draft7Validator)
79+
80+
81+
def custom_validate(instance: Any, schema: Dict[str, Any]) -> None:
82+
"""
83+
Validate an instance against a schema using the custom validator that
84+
allows None values for any property and has special handling for enum values
85+
and additionalProperties validation.
86+
87+
Args:
88+
instance: The instance to validate
89+
schema: The schema to validate against
90+
91+
Raises:
92+
ValidationError: If the instance is invalid
93+
"""
94+
CustomValidator(schema).validate(instance)
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import unittest
2+
from jsonschema import ValidationError
3+
from jsonschema.custom_validators import custom_validate
4+
5+
6+
class TestCustomValidator(unittest.TestCase):
7+
8+
def setUp(self):
9+
# Sample schema for various test cases
10+
self.schema = {
11+
"type": "object",
12+
"properties": {
13+
"Sodium": {
14+
"type": "integer",
15+
"description": "Sodium level in mg."
16+
},
17+
"Carbohydrate": {
18+
"type": "string",
19+
"enum": ["Low", "High"]
20+
},
21+
"FluidRestriction": {
22+
"type": "integer",
23+
"description": "Fluid restriction in cc/24 hours."
24+
},
25+
"Diet": {
26+
"type": "object",
27+
"properties": {
28+
"HighProtein": {
29+
"type": "integer"
30+
},
31+
"LowProtein": {
32+
"type": "integer"
33+
},
34+
"DietType": {
35+
"type": "string",
36+
"enum": ["Vegetarian", "Non-Vegetarian", "Vegan"]
37+
}
38+
},
39+
"additionalProperties": False
40+
}
41+
},
42+
"required": ["Sodium"],
43+
"additionalProperties": False
44+
}
45+
46+
def test_valid_instance(self):
47+
instance = {
48+
"Sodium": 140,
49+
"Carbohydrate": "Low",
50+
"FluidRestriction": 1500,
51+
"Diet": {
52+
"HighProtein": 100,
53+
"DietType": "Vegan"
54+
}
55+
}
56+
try:
57+
custom_validate(instance, self.schema)
58+
except ValidationError:
59+
self.fail("custom_validate raised ValidationError unexpectedly!")
60+
61+
def test_missing_required_property(self):
62+
instance = {
63+
"Carbohydrate": "Low",
64+
"FluidRestriction": 1500
65+
}
66+
with self.assertRaises(ValidationError):
67+
custom_validate(instance, self.schema)
68+
69+
def test_enum_with_nullable_valid(self):
70+
instance = {
71+
"Sodium": 140,
72+
"Carbohydrate": None # Enum property is None
73+
}
74+
try:
75+
custom_validate(instance, self.schema)
76+
except ValidationError:
77+
self.fail("custom_validate raised ValidationError unexpectedly!")
78+
79+
def test_enum_with_nullable_invalid(self):
80+
instance = {
81+
"Sodium": 140,
82+
"Carbohydrate": "Medium" # Not in the enum
83+
}
84+
with self.assertRaises(ValidationError):
85+
custom_validate(instance, self.schema)
86+
87+
def test_enum_subproperty_with_nullable_valid(self):
88+
instance = {
89+
"Sodium": 140,
90+
"Diet": {
91+
"DietType": None # Enum subproperty is None
92+
}
93+
}
94+
try:
95+
custom_validate(instance, self.schema)
96+
except ValidationError:
97+
self.fail("custom_validate raised ValidationError unexpectedly!")
98+
99+
def test_enum_subproperty_with_nullable_invalid(self):
100+
instance = {
101+
"Sodium": 140,
102+
"Diet": {
103+
"DietType": "Keto" # Not in the enum for DietType
104+
}
105+
}
106+
with self.assertRaises(ValidationError):
107+
custom_validate(instance, self.schema)
108+
109+
def test_ignore_none_for_missing_properties(self):
110+
instance = {
111+
"Sodium": 140,
112+
"Carbohydrate": None
113+
}
114+
try:
115+
custom_validate(instance, self.schema)
116+
except ValidationError:
117+
self.fail("custom_validate raised ValidationError unexpectedly!")
118+
119+
def test_reject_additional_properties(self):
120+
instance = {
121+
"Sodium": 140,
122+
"Carbohydrate": "Low",
123+
"ExtraField": "NotAllowed" # Extra field not in the schema
124+
}
125+
with self.assertRaises(ValidationError):
126+
custom_validate(instance, self.schema)
127+
128+
def test_allow_missing_non_required_fields(self):
129+
instance = {
130+
"Sodium": 140 # Only the required field is present
131+
}
132+
try:
133+
custom_validate(instance, self.schema)
134+
except ValidationError:
135+
self.fail("custom_validate raised ValidationError unexpectedly!")
136+
137+
def test_allow_none_type_handling(self):
138+
# Test with None as the entire instance (should pass)
139+
instance = None
140+
try:
141+
custom_validate(instance, self.schema)
142+
except ValidationError:
143+
self.fail("custom_validate raised ValidationError unexpectedly!")
144+
145+
def test_nested_object_with_additional_properties(self):
146+
# Nested schema with additionalProperties = False
147+
nested_schema = {
148+
"type": "object",
149+
"properties": {
150+
"Diet": {
151+
"type": "object",
152+
"properties": {
153+
"Sodium": {"type": "integer"},
154+
"FluidRestriction": {"type": "integer"}
155+
},
156+
"additionalProperties": False
157+
}
158+
}
159+
}
160+
161+
valid_instance = {
162+
"Diet": {
163+
"Sodium": 140,
164+
"FluidRestriction": 1500
165+
}
166+
}
167+
168+
invalid_instance = {
169+
"Diet": {
170+
"Sodium": 140,
171+
"ExtraField": "NotAllowed" # Additional field in nested object
172+
}
173+
}
174+
175+
try:
176+
custom_validate(valid_instance, nested_schema)
177+
except ValidationError:
178+
self.fail("custom_validate raised ValidationError unexpectedly for valid instance!")
179+
180+
with self.assertRaises(ValidationError):
181+
custom_validate(invalid_instance, nested_schema)
182+
183+
def test_nested_object_none_valid(self):
184+
# Test with None as a nested object
185+
instance = {
186+
"Sodium": 140,
187+
"Diet": None # Should be valid since Diet is not required
188+
}
189+
try:
190+
custom_validate(instance, self.schema)
191+
except ValidationError:
192+
self.fail("custom_validate raised ValidationError unexpectedly!")
193+
194+
def test_nested_object_missing_valid(self):
195+
# Test with missing nested object
196+
instance = {
197+
"Sodium": 140 # Diet object is missing but should be valid
198+
}
199+
try:
200+
custom_validate(instance, self.schema)
201+
except ValidationError:
202+
self.fail("custom_validate raised ValidationError unexpectedly!")
203+
204+
def test_exclude_any_field(self):
205+
# Test that any non-required field can be excluded without raising an error
206+
instance = {
207+
"Sodium": 140 # Only the required field is present
208+
}
209+
try:
210+
custom_validate(instance, self.schema)
211+
except ValidationError:
212+
self.fail("custom_validate raised ValidationError unexpectedly!")
213+
214+
def test_enum_field_nullable_and_missing(self):
215+
# Test that a nullable enum field can be missing or None
216+
instance = {
217+
"Sodium": 140 # Carbohydrate is missing
218+
}
219+
try:
220+
custom_validate(instance, self.schema)
221+
except ValidationError:
222+
self.fail("custom_validate raised ValidationError unexpectedly!")
223+
224+
instance_with_none = {
225+
"Sodium": 140,
226+
"Carbohydrate": None # Carbohydrate is explicitly set to None
227+
}
228+
try:
229+
custom_validate(instance_with_none, self.schema)
230+
except ValidationError:
231+
self.fail("custom_validate raised ValidationError unexpectedly!")
232+
233+
234+
if __name__ == "__main__":
235+
unittest.main()

0 commit comments

Comments
 (0)