Skip to content

Commit 6e9621c

Browse files
authored
Merge pull request #30 from gberaudo/support_oneof_discriminator
Validate oneOf, anyOf and allOf with discriminator
2 parents 9f07f01 + 1543afe commit 6e9621c

File tree

3 files changed

+184
-3
lines changed

3 files changed

+184
-3
lines changed

Diff for: openapi_schema_validator/_validators.py

+66
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

Diff for: openapi_schema_validator/validators.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
u"enum": _validators.enum,
2828
# adjusted to OAS
2929
u"type": oas_validators.type,
30-
u"allOf": _validators.allOf,
31-
u"oneOf": _validators.oneOf,
32-
u"anyOf": _validators.anyOf,
30+
u"allOf": oas_validators.allOf,
31+
u"oneOf": oas_validators.oneOf,
32+
u"anyOf": oas_validators.anyOf,
3333
u"not": _validators.not_,
3434
u"items": oas_validators.items,
3535
u"properties": _validators.properties,

Diff for: tests/integration/test_validators.py

+115
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,121 @@ def test_oneof_required(self):
240240
assert result is None
241241

242242

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

0 commit comments

Comments
 (0)