diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 626eac4..e700c56 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.2.1 +current_version = 0.2.3 tag = True tag_name = {new_version} commit = True diff --git a/openapi_schema_validator/__init__.py b/openapi_schema_validator/__init__.py index c1085a6..bfb16ba 100644 --- a/openapi_schema_validator/__init__.py +++ b/openapi_schema_validator/__init__.py @@ -5,7 +5,7 @@ __author__ = 'Artur Maciag' __email__ = 'maciag.artur@gmail.com' -__version__ = '0.2.1' +__version__ = '0.2.3' __url__ = 'https://github.com/p1c2u/openapi-schema-validator' __license__ = '3-clause BSD License' diff --git a/openapi_schema_validator/_validators.py b/openapi_schema_validator/_validators.py index 2f25660..ec5ab4a 100644 --- a/openapi_schema_validator/_validators.py +++ b/openapi_schema_validator/_validators.py @@ -1,7 +1,73 @@ from jsonschema._utils import find_additional_properties, extras_msg +from jsonschema._validators import oneOf as _oneOf, anyOf as _anyOf, allOf as _allOf + from jsonschema.exceptions import ValidationError, FormatError +def handle_discriminator(validator, _, instance, schema): + """ + Handle presence of discriminator in anyOf, oneOf and allOf. + The behaviour is the same in all 3 cases because at most 1 schema will match. + """ + discriminator = schema['discriminator'] + prop_name = discriminator['propertyName'] + prop_value = instance.get(prop_name) + if not prop_value: + # instance is missing $propertyName + yield ValidationError( + "%r does not contain discriminating property %r" % (instance, prop_name), + context=[], + ) + return + + # Use explicit mapping if available, otherwise try implicit value + ref = discriminator.get('mapping', {}).get(prop_value) or f'#/components/schemas/{prop_value}' + + if not isinstance(ref, str): + # this is a schema error + yield ValidationError( + "%r mapped value for %r should be a string, was %r" % ( + instance, prop_value, ref), + context=[], + ) + return + + try: + validator.resolver.resolve(ref) + except: + yield ValidationError( + "%r reference %r could not be resolved" % ( + instance, ref), + context=[], + ) + return + + yield from validator.descend(instance, { + "$ref": ref + }) + + +def anyOf(validator, anyOf, instance, schema): + if 'discriminator' not in schema: + yield from _anyOf(validator, anyOf, instance, schema) + else: + yield from handle_discriminator(validator, anyOf, instance, schema) + + +def oneOf(validator, oneOf, instance, schema): + if 'discriminator' not in schema: + yield from _oneOf(validator, oneOf, instance, schema) + else: + yield from handle_discriminator(validator, oneOf, instance, schema) + + +def allOf(validator, allOf, instance, schema): + if 'discriminator' not in schema: + yield from _allOf(validator, allOf, instance, schema) + else: + yield from handle_discriminator(validator, allOf, instance, schema) + + def type(validator, data_type, instance, schema): if instance is None: return diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index 8521c64..4e69c58 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -26,9 +26,9 @@ u"enum": _validators.enum, # adjusted to OAS u"type": oas_validators.type, - u"allOf": _validators.allOf, - u"oneOf": _validators.oneOf, - u"anyOf": _validators.anyOf, + u"allOf": oas_validators.allOf, + u"oneOf": oas_validators.oneOf, + u"anyOf": oas_validators.anyOf, u"not": _validators.not_, u"items": oas_validators.items, u"properties": _validators.properties, @@ -63,6 +63,13 @@ def __init__(self, *args, **kwargs): self.write = kwargs.pop('write', None) super(OAS30Validator, self).__init__(*args, **kwargs) + def evolve(self, **kwargs): + # jsonschema4 interface compatibility workaround + validator = super(OAS30Validator, self).evolve(**kwargs) + validator.read = self.read + validator.write = self.write + return validator + def iter_errors(self, instance, _schema=None): if _schema is None: # creates a copy by value from schema to prevent mutation diff --git a/pyproject.toml b/pyproject.toml index 2c3e086..7d8c078 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ output = "reports/coverage.xml" [tool.poetry] name = "openapi-schema-validator" -version = "0.2.1" +version = "0.2.3" description = "OpenAPI schema validation for Python" authors = ["Artur Maciag "] license = "BSD-3-Clause" diff --git a/tests/integration/test_validators.py b/tests/integration/test_validators.py index 9b5e458..5f862c5 100644 --- a/tests/integration/test_validators.py +++ b/tests/integration/test_validators.py @@ -196,6 +196,9 @@ def test_required_read_only(self): validator.validate({"another_prop": "hello"}) validator = OAS30Validator(schema, format_checker=oas30_format_checker, write=True) + with pytest.raises(ValidationError, + match="Tried to write read-only property with hello"): + validator.validate({"some_prop": "hello"}) assert validator.validate({"another_prop": "hello"}) is None def test_required_write_only(self): @@ -217,6 +220,9 @@ def test_required_write_only(self): validator.validate({"another_prop": "hello"}) validator = OAS30Validator(schema, format_checker=oas30_format_checker, read=True) + with pytest.raises(ValidationError, + match="Tried to read write-only property with hello"): + validator.validate({"some_prop": "hello"}) assert validator.validate({"another_prop": "hello"}) is None def test_oneof_required(self): @@ -237,3 +243,116 @@ def test_oneof_required(self): validator = OAS30Validator(schema, format_checker=oas30_format_checker) result = validator.validate(instance) assert result is None + + @pytest.mark.parametrize('schema_type', [ + 'oneOf', 'anyOf', 'allOf', + ]) + def test_oneof_discriminator(self, schema_type): + # We define a few components schemas + components = { + "MountainHiking": { + "type": "object", + "properties": { + "discipline": { + "type": "string", + # we allow both the explicitely matched mountain_hiking discipline + # and the implicitely matched MoutainHiking discipline + "enum": ["mountain_hiking", "MountainHiking"] + }, + "length": { + "type": "integer", + } + }, + "required": ["discipline", "length"] + }, + "AlpineClimbing": { + "type": "object", + "properties": { + "discipline": { + "type": "string", + "enum": ["alpine_climbing"] + }, + "height": { + "type": "integer", + }, + }, + "required": ["discipline", "height"] + }, + "Route": { + # defined later + } + } + components['Route'][schema_type] = [ + {"$ref": "#/components/schemas/MountainHiking"}, + {"$ref": "#/components/schemas/AlpineClimbing"}, + ] + + # Add the compoments in a minimalis schema + schema = { + "$ref": "#/components/schemas/Route", + "components": { + "schemas": components + } + } + + if schema_type != 'allOf': + # use jsonschema validator when no discriminator is defined + validator = OAS30Validator(schema, format_checker=oas30_format_checker) + with pytest.raises(ValidationError, match="is not valid under any of the given schemas"): + validator.validate({ + "something": "matching_none_of_the_schemas" + }) + assert False + + if schema_type == 'anyOf': + # use jsonschema validator when no discriminator is defined + validator = OAS30Validator(schema, format_checker=oas30_format_checker) + with pytest.raises(ValidationError, match="is not valid under any of the given schemas"): + validator.validate({ + "something": "matching_none_of_the_schemas" + }) + assert False + + discriminator = { + "propertyName": "discipline", + "mapping": { + "mountain_hiking": "#/components/schemas/MountainHiking", + "alpine_climbing": "#/components/schemas/AlpineClimbing", + } + } + schema['components']['schemas']['Route']['discriminator'] = discriminator + + # Optional: check we return useful result when the schema is wrong + validator = OAS30Validator(schema, format_checker=oas30_format_checker) + with pytest.raises(ValidationError, match="does not contain discriminating property"): + validator.validate({ + "something": "missing" + }) + assert False + + # Check we get a non-generic, somehow usable, error message when a discriminated schema is failing + with pytest.raises(ValidationError, match="'bad_string' is not of type integer"): + validator.validate({ + "discipline": "mountain_hiking", + "length": "bad_string" + }) + assert False + + # Check explicit MountainHiking resolution + validator.validate({ + "discipline": "mountain_hiking", + "length": 10 + }) + + # Check implicit MountainHiking resolution + validator.validate({ + "discipline": "MountainHiking", + "length": 10 + }) + + # Check non resolvable implicit schema + with pytest.raises(ValidationError, match="reference '#/components/schemas/other' could not be resolved"): + result = validator.validate({ + "discipline": "other" + }) + assert False