Skip to content

Commit 3eeb771

Browse files
committed
Merge pull request #30 from gberaudo/support_oneof_discriminator
Validate oneOf, anyOf and allOf with discriminator
1 parent a0a13e7 commit 3eeb771

File tree

3 files changed

+182
-3
lines changed

3 files changed

+182
-3
lines changed

openapi_schema_validator/_validators.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,73 @@
11
from jsonschema._utils import find_additional_properties, extras_msg
2+
from jsonschema._validators import oneOf as _oneOf, anyOf as _anyOf, allOf as _allOf
3+
24
from jsonschema.exceptions import ValidationError, FormatError
35

46

7+
def handle_discriminator(validator, _, instance, schema):
8+
"""
9+
Handle presence of discriminator in anyOf, oneOf and allOf.
10+
The behaviour is the same in all 3 cases because at most 1 schema will match.
11+
"""
12+
discriminator = schema['discriminator']
13+
prop_name = discriminator['propertyName']
14+
prop_value = instance.get(prop_name)
15+
if not prop_value:
16+
# instance is missing $propertyName
17+
yield ValidationError(
18+
"%r does not contain discriminating property %r" % (instance, prop_name),
19+
context=[],
20+
)
21+
return
22+
23+
# Use explicit mapping if available, otherwise try implicit value
24+
ref = discriminator.get('mapping', {}).get(prop_value) or f'#/components/schemas/{prop_value}'
25+
26+
if not isinstance(ref, str):
27+
# this is a schema error
28+
yield ValidationError(
29+
"%r mapped value for %r should be a string, was %r" % (
30+
instance, prop_value, ref),
31+
context=[],
32+
)
33+
return
34+
35+
try:
36+
validator.resolver.resolve(ref)
37+
except:
38+
yield ValidationError(
39+
"%r reference %r could not be resolved" % (
40+
instance, ref),
41+
context=[],
42+
)
43+
return
44+
45+
yield from validator.descend(instance, {
46+
"$ref": ref
47+
})
48+
49+
50+
def anyOf(validator, anyOf, instance, schema):
51+
if 'discriminator' not in schema:
52+
yield from _anyOf(validator, anyOf, instance, schema)
53+
else:
54+
yield from handle_discriminator(validator, anyOf, instance, schema)
55+
56+
57+
def oneOf(validator, oneOf, instance, schema):
58+
if 'discriminator' not in schema:
59+
yield from _oneOf(validator, oneOf, instance, schema)
60+
else:
61+
yield from handle_discriminator(validator, oneOf, instance, schema)
62+
63+
64+
def allOf(validator, allOf, instance, schema):
65+
if 'discriminator' not in schema:
66+
yield from _allOf(validator, allOf, instance, schema)
67+
else:
68+
yield from handle_discriminator(validator, allOf, instance, schema)
69+
70+
571
def type(validator, data_type, instance, schema):
672
if instance is None:
773
return

openapi_schema_validator/validators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
u"enum": _validators.enum,
2727
# adjusted to OAS
2828
u"type": oas_validators.type,
29-
u"allOf": _validators.allOf,
30-
u"oneOf": _validators.oneOf,
31-
u"anyOf": _validators.anyOf,
29+
u"allOf": oas_validators.allOf,
30+
u"oneOf": oas_validators.oneOf,
31+
u"anyOf": oas_validators.anyOf,
3232
u"not": _validators.not_,
3333
u"items": oas_validators.items,
3434
u"properties": _validators.properties,

tests/integration/test_validators.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,116 @@ def test_oneof_required(self):
237237
validator = OAS30Validator(schema, format_checker=oas30_format_checker)
238238
result = validator.validate(instance)
239239
assert result is None
240+
241+
@pytest.mark.parametrize('schema_type', [
242+
'oneOf', 'anyOf', 'allOf',
243+
])
244+
def test_oneof_discriminator(self, schema_type):
245+
# We define a few components schemas
246+
components = {
247+
"MountainHiking": {
248+
"type": "object",
249+
"properties": {
250+
"discipline": {
251+
"type": "string",
252+
# we allow both the explicitely matched mountain_hiking discipline
253+
# and the implicitely matched MoutainHiking discipline
254+
"enum": ["mountain_hiking", "MountainHiking"]
255+
},
256+
"length": {
257+
"type": "integer",
258+
}
259+
},
260+
"required": ["discipline", "length"]
261+
},
262+
"AlpineClimbing": {
263+
"type": "object",
264+
"properties": {
265+
"discipline": {
266+
"type": "string",
267+
"enum": ["alpine_climbing"]
268+
},
269+
"height": {
270+
"type": "integer",
271+
},
272+
},
273+
"required": ["discipline", "height"]
274+
},
275+
"Route": {
276+
# defined later
277+
}
278+
}
279+
components['Route'][schema_type] = [
280+
{"$ref": "#/components/schemas/MountainHiking"},
281+
{"$ref": "#/components/schemas/AlpineClimbing"},
282+
]
283+
284+
# Add the compoments in a minimalis schema
285+
schema = {
286+
"$ref": "#/components/schemas/Route",
287+
"components": {
288+
"schemas": components
289+
}
290+
}
291+
292+
if schema_type != 'allOf':
293+
# use jsonschema validator when no discriminator is defined
294+
validator = OAS30Validator(schema, format_checker=oas30_format_checker)
295+
with pytest.raises(ValidationError, match="is not valid under any of the given schemas"):
296+
validator.validate({
297+
"something": "matching_none_of_the_schemas"
298+
})
299+
assert False
300+
301+
if schema_type == 'anyOf':
302+
# use jsonschema validator when no discriminator is defined
303+
validator = OAS30Validator(schema, format_checker=oas30_format_checker)
304+
with pytest.raises(ValidationError, match="is not valid under any of the given schemas"):
305+
validator.validate({
306+
"something": "matching_none_of_the_schemas"
307+
})
308+
assert False
309+
310+
discriminator = {
311+
"propertyName": "discipline",
312+
"mapping": {
313+
"mountain_hiking": "#/components/schemas/MountainHiking",
314+
"alpine_climbing": "#/components/schemas/AlpineClimbing",
315+
}
316+
}
317+
schema['components']['schemas']['Route']['discriminator'] = discriminator
318+
319+
# Optional: check we return useful result when the schema is wrong
320+
validator = OAS30Validator(schema, format_checker=oas30_format_checker)
321+
with pytest.raises(ValidationError, match="does not contain discriminating property"):
322+
validator.validate({
323+
"something": "missing"
324+
})
325+
assert False
326+
327+
# Check we get a non-generic, somehow usable, error message when a discriminated schema is failing
328+
with pytest.raises(ValidationError, match="'bad_string' is not of type integer"):
329+
validator.validate({
330+
"discipline": "mountain_hiking",
331+
"length": "bad_string"
332+
})
333+
assert False
334+
335+
# Check explicit MountainHiking resolution
336+
validator.validate({
337+
"discipline": "mountain_hiking",
338+
"length": 10
339+
})
340+
341+
# Check implicit MountainHiking resolution
342+
validator.validate({
343+
"discipline": "MountainHiking",
344+
"length": 10
345+
})
346+
347+
# Check non resolvable implicit schema
348+
with pytest.raises(ValidationError, match="reference '#/components/schemas/other' could not be resolved"):
349+
result = validator.validate({
350+
"discipline": "other"
351+
})
352+
assert False

0 commit comments

Comments
 (0)