Skip to content

Looking at the future json-schema drafts #3

Closed
@sorinsarca

Description

@sorinsarca

Well, I have to say that I'm somehow disappointed. draft-08's focus is schema reuse
and I don't see any improvement. Here are some proposals, which will "help" reusability

Ok, now lets take it one by one, but first I'll define the reused schema:

{
    "$id": "user",
    "type": "object",
    "properties": {
        "name": {"type": "string"}
    },
    "required": ["name"],
    "additionalProperties": false
}

$merge & $patch

Extending it with $merge

{
    "$id": "extended-user",
    "$merge": {
        "source": {"$ref": "user"},
        "with": {
            "properties": {
                "age": {"type": "integer"}
            },
            "required": ["age"]
        }
    }
}

The result is

{
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer"}
    },
    "required": ["age"],
    "additionalProperties": false
}

Yes, the name is no longer required because was overwritten by extended-user's $merge.
So it doesn't work how you expect. Wait! I could use as an allOf and put there the required.
And if the user also have an allOf? You guessed, the same problem, so it won't work.

Don't dispare, it's not all lost. Here comes $patch:

{
    "$id": "extended-user",
    "$patch": {
        "source": {"$ref": "user"},
        "with": [
            {"op": "add", "path": "/properties/age", "value": {
                "type": "integer"
            }},
            {"op": "add", "path": "/required", "value": "age"}
        ]
    }
}

Now, isn't that readable?

If you still think that it is readable, try adding three more properties to extended-user.
For a better taste just use a pinch of remove operation.
Now just do the same in other schemas and after two months you'll loose your time
trying to understand what that schema really does.
Probably you'll end up moving the properties from user to extended-user just because it is easier to read and debug.

$spread

Extending it with $spread

{
    "$id": "extended-user",
    "properties": {
        "age": {"type": "integer"},
        "$spread": [
            {"$ref": "user"}
        ]
    },
    "required": ["age"]
}

The result is

{
    "type": "object",
    "properties": {
        "age": {"type": "integer"},
        "name": {"type": "string"}
    },
    "required": ["age"]
}

Ok, in order to also make name required you have to manually add it.
Also you have to manually add additionalProperties. But what if user also have an allOf?

Yes, you guessed, it won't work.

unevaluatedProperties

This one is a little bit trickier. You can use it in conjunction with allOf, anyOf, oneOf.
The ideea is that unevaluatedProperties knows what properties from ***Of were checked.

Extending it with unevaluatedProperties

{
    "$id": "extended-user",
    "properties": {
        "age": {"type": "integer"}
    },
    "required": ["age"],
    "allOf": [
        {"$ref": "user"}
    ],
    "unevaluatedProperties": false
}

Well, it will not work because user has additionalProperties set to false.

Anyway, the working design for unevaluatedProperties is not in our case.
It cannot be used for extending, only for ignoring this or that but in a limited way, because it will produce unexpected behaviour eventually.

Here it is an example of usage (we don't care about user schema for now)

{
    "$id": "hip-hop",
    "properties": {
        "common": {"type": "string"}
    },
    "required": ["common"],
    "anyOf": [
        {
            "properties": {
                "foo": {"type": "integer"}
            },
            "required": ["foo"]
        },
        {
            "properties": {
                "bar": {"type": "string"},
                "baz": {"type": "number"}
            },
            "required": ["bar"]
        }
    ],
    "unevaluatedProperties": false
}

Examples of data

1 - valid (common & first item of anyOf)

{
    "common": "this is common and required",
    "foo": 1
}

2 - valid (common & second item of anyOf)

{
    "common": "this is common and required",
    "bar": "bar value",
    "baz": 1
}

3 - valid (common & second item of anyOf, baz is not required)

{
    "common": "this is common and required",
    "bar": "bar value"
}

4 - invalid (baz is not present in first item of anyOf and bar is not present in the second one)

{
    "common": "this is common and required",
    "foo": 1,
    "baz": 1
}

5 - ???

{
    "common": "this is common and required",
    "foo": 1,
    "bar": "bar value",
    "baz": 1
}

Well, in theory this should be valid. unevaluatedProperties must know what properties were checked.
But, if an implementation of json-schema decides to do some optimizations, this will not work anymore. In case of anyOf once you've found a valid schema it will not make sense to check remaining schemas.
So, if json-schema doesn't allow optimizations by design it means that apps using it will spend most of the time checking things that don't make sense checking.
And if this is the case, you better start typing if-else and forget about json-schema.

Epilogue

Maybe you saw, but we already added a new keyword $map (besides $filters and $vars)
for opis/json-schema to allow a simple extending.

$map solves the following problem:

Given a schema and an object, map the properties of the object to match the schema properties.

So, if the schema is:

{
    "$id": "example",
    "type": "object",
    "properties": {
        "foo": {"type": "string"},
        "bar": {"type": "number"}
    }
}

and the current object is

{
    "a": "value for foo",
    "b": 123
}

you can validate it using $ref and $map

{
    "$ref": "example",
    "$map": {
        "foo": {"$ref": "0/a"},
        "bar": {"$ref": "0/b"}
    }
}

Please note that inside $map (and $vars) the $ref property is a (relative) json pointer for
current object.

Here is how we can extend the user

{
    "$id": "extended-user",
    "type": "object",
    "properties": {
        "age": {"type": "integer"}
    },
    "required": ["age"],
    "allOf": [
        {
            "$ref": "user",
            "$map": {
                "name": {"$ref": "0/name"}
            }
        }
    ]
}

You can even have other property for name in your extended schema

{
    "$id": "extended-user",
    "type": "object",
    "properties": {
        "full-name": {"type": "string"},
        "age": {"type": "integer"}
    },
    "required": ["full-name", "age"],
    "allOf": [
        {
            "$ref": "user",
            "$map": {
                "name": {"$ref": "0/full-name"}
            }
        }
    ]
}

I know that this example is too simple, and it doesn't make sense to just validate again
if name is a string since you already checked full-name, but what if user schema
also contains an allOf? What if the constraints for name are more complex?

Here is a more complex example (using two base schemas for our extended-user schema)

{
    "$id": "user",
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "active": {"type": "boolean"},
        "required": ["name", "active"]
    },
    "allOf": [
        ... other checks for user
    ],
    "additionalProperties": false
}
{
    "$id": "user-permissions",
    "type": "object",
    "properties": {
        "realm": {
            "type": "string"
        },
        "permissions": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "enabled": {"type": "boolean"}
                },
                "required": ["name", "enabled"],
                "additionalProperties": false,
                "allOf": [
                    ... other checks for permission
                ]
            }
        }
    },
    "required": ["realm", "permissions"],
    "additionalProperties": false
}

Our extended user

{
    "$id": "extended-user",
    "type": "object",
    "properties": {
        "first-name": {"type": "string"},
        "last-name": {"type": "string"},
        "is-admin": {"type": "boolean"},
        "admin-permissions": {
            "type": "array",
            "items": {
                "enum": ["create", "read", "update", "delete"]
            }
        },
    },
    "required": ["first-name", "last-name", "is-admin", "admin-permissions"],
    "additionalProperties": false,
    "allOf": [
        {
            "$ref": "user",
            "$map": {
                "name": {"$ref": "0/last-name"},
                "active": true
            }
        },
        {
            "$ref": "user-permissions",
            "$map": {
                "realm": "administration",
                "permissions": {
                    "$ref": "0/admin-permissions",
                    "$each": {
                        "name": {"$ref": "0"},
                        "enabled": {"$ref": "2/is-admin"}
                    }
                }
            }
        }
    ]
}

So if the data for extended-user schema is

{
    "first-name": "Json-Schema",
    "last-name": "Opis",
    "is-admin": true,
    "admin-permissions": ["create", "delete"]
}

the mapped data provided to user schema (first item of allOf) will be

{
    "name": "Opis",
    "active": true
}

and the mapped data provided to user-permissions schema (second item of allOf) will be

{
    "realm": "administration",
    "permissions": [
        {
            "name": "create",
            "enabled": true
        },
        {
            "name": "delete",
            "enabled": true
        }
    ]
}

As you can see, with $map you can add only what properties you want, you can handle nested properties, you can provide default values, and you can even use $each to map arrays.

The advantage is that I can change extended-user schema however I want without touching user and user-permissions schemas.

And for validation this is verbose, clear and flexible.

Anyway, another method you can use to simplify your schemas is using $ref together with $vars.

Here is an example where $vars is handy

{
    "$id": "settings-type",
    "definitions": {
        "type-A": {
            ...
        },
        "type-B": {
            ...
        },
        ...
    }
}
{
    "type": "object",
    "properties": {
        "type": {"enum": ["A", "B", ...]},
        "settings": {
            "$ref": "settings-type#/definitions/type-{typeName}",
            "$vars": {
                "typeName": {"$ref", "1/type"}
            }
        }
    }
    "required": ["type", "settings"]
}

Without $vars you'll probably need an anyOf or oneOf which will be very slow.
But in this way you can add as many definitions as you want to settings-type schema,
or even better (and recommended), you can use different schema files for each type
and load only needed schemas, because it doesn't make sanse to load and check things that
will never change the final result.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions