Setting defaults when validating a jsonschema with anyOf keyword

Viewed 429

I'd like to make use of the defaults that are defined within my schema. I found that there is already an example within the python-jsonschema faq: https://python-jsonschema.readthedocs.io/en/stable/faq/

The example extends the default validator for the property keyword and sets the defaults as required. However, I hit an issue as soon as I use the anyOf keyword within the same schema.

Let me give you an example:

from jsonschema import Draft7Validator, validators


def extend_with_default(validator_class):
    validate_properties = validator_class.VALIDATORS["properties"]

    def set_defaults(validator, properties, instance, schema):
        for property, subschema in properties.items():
            if "default" in subschema:
                instance.setdefault(property, subschema["default"])

        for error in validate_properties(
            validator, properties, instance, schema,
        ):
            yield error

    return validators.extend(
        validator_class, {"properties": set_defaults},
    )


DefaultValidatingDraft7Validator = extend_with_default(Draft7Validator)

obj = {
    "my_list": [{"class_name": "some_class"}]
}
schema = {
    "properties": {
        "my_list": {
            "type": "array",
            "items": {
                "anyOf": [
                    {
                        "type": "object",
                        "properties": {
                            "class_name": {
                                "const": "some_class"
                            },
                            "some_property": {
                                "type": "number",
                                "default": 1
                            }
                        },
                        "required": ["class_name", "some_property"],
                        "additionalProperties": False
                    },
                    {
                        "type": "object",
                        "properties": {
                            "class_name": {
                                "const": "another_class"
                            },
                            "another_property": {
                                "type": "number",
                                "default": 1
                            }
                        },
                        "required": ["class_name", "another_property"],
                        "additionalProperties": False
                    }
                ]
            }
        }
    }
}

DefaultValidatingDraft7Validator(schema).validate(obj)
print(obj)

This example actually works as intended. Running it provides the following output:

{'my_list': [{'class_name': 'some_class', 'some_property': 1}]}

So the property some_property was correctly set with the default value of 1. However, if we now change the class_name within the object to another_class, which fits the second entry within the anyOf list, we get the following issue:

obj = {
    "my_list": [{"class_name": "another_class"}]
}

=>

jsonschema.exceptions.ValidationError: {'class_name': 'another_class', 'some_property': 1, 'another_property': 1} is not valid under any of the given schemas

Failed validating 'anyOf' in schema['properties']['my_list']['items']:
    {
        "anyOf": [
            {
                "type": "object",
                "properties": {
                    "class_name": {
                        "const": "some_class"
                    },
                    "some_property": {
                        "type": "number",
                        "default": 1
                    }
                },
                "required": ["class_name", "some_property"],
                "additionalProperties": False
            },
            {
                "type": "object",
                "properties": {
                    "class_name": {
                        "const": "another_class"
                    },
                    "another_property": {
                        "type": "number",
                        "default": 1
                    }
                },
                "required": ["class_name", "another_property"],
                "additionalProperties": False
            }
        ]
    }

On instance['my_list'][0]:
    {'another_property': 1,
     'class_name': 'another_class',
     'some_property': 1}

While iterating over the anyOf list, the instance given to anyOf was already changed by the first subschema. The anyOf validator calls all relevant validators of each subschema, as a result the properties validator of the first subschema inserts the defaults of the first subschema into the current instance. This also happens when the validation of a subschema is not successful within anyOf. As a result, the first subschema which does not fit in this example inserts the property 'some_property': 1:

obj = {'class_name': 'another_class', 'some_property': 1, 'another_property': 1}

So now as anyOf gets to the second subschema which usually would fit the object, another key has been added to the instance, causing the validation to fail as well as no additionalProperties are allowed. As a result no valid schema was found within anyOf and we get the above error.

So how can this issue be fixed? My approach is to have anyOf store the value of the instance while iterating over the list of subschemas. If a subschema does not match, all changes done by this subschema should be reverted. Unfortunately until now, I was not able to implement this behavior.

For reference, this is what my latest try looks like:

def extend_with_default(validator_class):
    validate_properties = validator_class.VALIDATORS["properties"]

    def set_defaults(validator, properties, instance, schema):
        for property, subschema in properties.items():
            if "default" in subschema:
                instance.setdefault(property, subschema["default"])

        for error in validate_properties(
            validator, properties, instance, schema,
        ):
            yield error

    def any_of(validator, subschemas, instance, schema):
        instance_copy = instance.copy()

        all_errors = []
        for index, subschema in enumerate(subschemas):
            errs = list(validator.descend(instance, subschema, schema_path=index))
            if not errs:
                break
            instance = instance_copy  # Make sure an instance that did not fit is not modified
            all_errors.extend(errs)
        else:
            yield ValidationError(
                "%r is not valid under any of the given schemas" % (instance,),
                context=all_errors,
            )

    return validators.extend(
        validator_class, {"properties": set_defaults, "anyOf": any_of},
    )

Internally this seems to work, the verification also works. But for some reason the content of obj which was given as {"my_list": [{"class_name": "another_class"}]} is now:

{'my_list': [{'class_name': 'another_class', 'some_property': 1}]}

I don't understand why. I guess the dictionaries are changed while being passed through the validators as they are mutable. So trying to reset the instance does probably not have the desired effect in a global context. However, I am not able to figure out how to fix this. Can someone assist?

0 Answers
Related