Mappers

While json schema standard looks very flexible and powerfull, it still lacks in providing reusability. And no, we are not talking about splitting schema into multiple documents, nor using definitions for common validations, these are great, but what happens if you need to change your existing data structure, or when you want to use schemas from 3rd parties and you have no control over their property names?

Take a look at the following scenario:

Some 3rd party provides a basic user validator

{
    "$id": "standard-user.json",
    "type": "object",
    "properties": {
        "name": {
            "type": "string"
        },
        "birthday": {
            "type": "string",
            "format": "date"
        }
    },
    "required": ["name", "birthday"],
    "additionalProperties": false
}

So, starting today, our site user must comply with the above standard-user.json schema (because let’s say it is a law), but the problem is that we already have a schema

{
    "$id": "our-user.json",
    "type": "object",
    "properties": {
        "firstName": {
            "type": "string"
        },
        "lastName": {
            "type": "string"
        },
        "email": {
            "type": "string",
            "format": "email"
        }
    },
    "required": ["firstName", "lastName", "email"],
    "additionalProperties": false
}

Now, how can we do this without renaming properties nor copying validation rules from standard-user.json to our-user.json? Not to mention that our-user.json doesn’t have any information about birthday but contains additional information (email) and standard-user.json is restricted to name and birthday by additionalProperties keyword.

The simplest answer is to map our data structure to the 3rd party data structure and then validate it. Something like

{
    "firstName": "John",
    "lastName": "Doe",
    "email": "johndoe@example.com"
}

to be converted to

{
    "name": "John",
    "birthday": "1970-01-01"
}

before beeing sent to standard-user.json for validation.

We can do that thanks to a new non-standard keyword named $map, designed for advanced schema reuse.

And to solve our problem we only need to prepend the following rule to our-user.json

{
    "allOf": [
        {
            "$ref": "standard-user.json",
            "$map": {
                "name": {"$ref": "/firstName"},
                "birthday": "1970-01-01"
            }
        }
    ]
}

General structure

In a json schema document, $map is evaluated like $vars, the difference is that $map can also be an array ($vars can only be an object) and can only be used in conjunction with $ref.

$map keyword is enabled by default, to disable it use Opis\JsonSchema\Validator::mapSupport(false). Also, please note that $map will not work if $vars support is disabled.

Example for $map

{
    "$ref": "some-ref.json",
    "$map": {
        "prop1": 1,
        "prop2": "something",
        "dynamic-prop": {"$ref": "/dynamic"}
    }
}

In the above example, before the current data is passed to some-ref.json it is processed by $map, so in the end it will look somthing like

{
    "prop1": 1,
    "prop2": "something",
    "dynamic-prop": "value of /dynamic"
}

Mapping arrays using $each

If you want to map every value of an array you can use $each keyword.

{
    "type": "object",
    "properties": {
        "title": {
            "type": "string"
        },
        "list": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "index": {"type": "number"},
                    "name": {"type": "string"}
                }
            }
        }
    },
    "allOf": [
        {
            "$ref": "other-schema.json",
            "$map": {
                "name": {"$ref": "/title"},
                "rows": {
                    "$ref": "/list",
                    "$each": {
                        "id": {"$ref": "0/index"},
                        "title": {"$ref": "0/name"},
                        "weight": {"$ref": "0#"}
                    }
                },
                "hide-title": true
            }
        }
    ]
}

Considering data to be

{
    "title": "Some title",
    "list": [
        {"index": 5, "name": "A"},
        {"index": 10, "name": "B"},
        {"index": 8, "name": "C"}
    ]
}

the mapped data by $map will be

{
    "name": "Some title",
    "rows": [
        {"id": 5, "title": "A", "weight": 0},
        {"id": 10, "title": "B", "weight": 1},
        {"id": 8, "title": "C", "weight": 2}
    ],
    "hide-title": true
}

Complex example

Here is a more complex example using two base schemas user and user-permissions from a 3rd party, for our extended-user schema.

User schema (3rd party, cannot be changed)

{
    "$id": "user",
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "active": {"type": "boolean"},
        "required": ["name", "active"]
    },
    "allOf": [
        {"$comment": "And other validations for user..."}
    ],
    "additionalProperties": false
}

User permission schema (3rd party, cannot be changed)

{
    "$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": [
                    {"$comment": "And other validations for permission..."}
                ]
            }
        }
    },
    "required": ["realm", "permissions"],
    "additionalProperties": false
}

Our extended user schema (using $map to comply with the 3rd party schemas)

{
    "$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
        }
    ]
}

Now we are compliant with both 3rd party schemas without changing our initial data structure.