Skip to content

Experimenting with inheritance and unexpected members using JSON Schema ==> use "unevaluatedProperties": false #61

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
rouault opened this issue Sep 23, 2024 · 0 comments

Comments

@rouault
Copy link

rouault commented Sep 23, 2024

TLDR; summary: use "unevaluatedProperties": false when inheritance is involved (if using JSON Schema Draft >= 2019-09) to be able to reject unexpected members

Long version:

Let's play a bit with the example of "Best Practice for OGC - UML to JSON encoding rules" (https://portal.ogc.org/files/?artifact_id=108010&version=1#toc27), paragraph 7.3.3.4 "Inheritance"

Let's consider:

  • an instance document test.json containing:
{
  "propertyA": 2,
  "propertyB": "x"
}
  • the (actually "one among many possibilities") corresponding JSON schema, test.schema.json defining a class TypeA with a propertyA and a class TypeB, extending TypeA, with a propertyB:
{
  "$schema": "http://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TypeA": {
      "properties": {
        "propertyA": {
          "type": "number"
        }
      },
      "required": [
        "propertyA"
      ]
    },
    "TypeB": {
      "allOf": [
        {
          "$ref": "#/$defs/TypeA"
        },
        {
          "type": "object",
          "properties": {
            "propertyB": {
              "type": "string"
            }
          },
          "required": [
            "propertyB"
          ]
        }
      ]
    }
  },
  "$ref": "#/$defs/TypeB"
}

This is a direct copy&paste from the above reference best practice document

Now let's use the check-jsonschema Python utility (using latest version 0.29.2 at time of writing):

$ check-jsonschema --schemafile test.schema.json test.json
ok -- validation done

Life is good as expected.

Now let's consider a variation of our document with a unexpected property. Let's call it test_unexpected.json :

{
  "propertyA": 2,
  "propertyB": "x",
  "unexpected": "foo"
}

Let's validate it:

$ check-jsonschema --schemafile test.schema.json test_unexpected.json
ok -- validation done

The success of test_unexpected is expected (pun intended). The reason is that by default a JSON schema allows additional properties. You need to use an explicit keyword in a JSON schema to disallow them: additionalProperties: false. That's one of the key thing I wanted for the PROJJSON schema. When there are non-mandatory members, I wanted to be able to catch typos, by rejecting additional properties. That way it would catch someone mispelling "datum_ensemble" as "datum_ensembble" , or putting a lowercase where an uppercase is expected, or forgetting a underscore, or whatever.
And that's where things are tricky. My findings is that inheritance using allOff is fine if used alone, additionalProperties: false is fine if used alone, but if you start combining them together, things break apart.

Let's see by modifying test.schema.json as test_add_properties_false.schema.json with

{
  "$schema": "http://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TypeA": {
      "properties": {
        "propertyA": {
          "type": "number"
        }
      },
      "required": [
        "propertyA"
      ]
    },
    "TypeB": {
      "allOf": [
        {
          "$ref": "#/$defs/TypeA"
        },
        {
          "type": "object",
          "properties": {
            "propertyB": {
              "type": "string"
            }
          },
          "required": [
            "propertyB"
          ]
        }
      ],
      "additionalProperties": false
    }
  },
  "$ref": "#/$defs/TypeB"
}

Now let's validate the doc that has the unexpected member:

$ check-jsonschema --schemafile test_add_properties_false.schema.json test_unexpected.json 
Schema validation errors were encountered.
  test_unexpected.json::$: Additional properties are not allowed ('propertyA', 'propertyB', 'unexpected' were unexpected)

It gets rejected, but or a wrong reason. It also considers propertyA and propertyB to be unexpected.
OK, let's try with a valid instance:

$ check-jsonschema --schemafile test_add_properties_false.schema.json test.json 
Schema validation errors were encountered.
  test.json::$: Additional properties are not allowed ('propertyA', 'propertyB' were unexpected)

Same issue. That's not good. So this is not the way to go.

Let's experiment with another approach for test.json.schema. Let's call it test2.json.schema:

{
  "$schema": "http://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TypeA": {
      "properties": {
        "propertyA": {
          "type": "number"
        }
      },
      "required": [
        "propertyA"
      ]
    },
    "TypeB": {
      "type": "object",
      "allOf": [{"$ref": "#/$defs/TypeA"}],
      "properties": {
        "propertyB": {
          "type": "string"
        }
      },
      "required": [
        "propertyB"
      ]
    }
  },
  "$ref": "#/$defs/TypeB"
}

So basically we move the allOf constraint inside typeB

Valid documents still validate:

$ check-jsonschema --schemafile test2.schema.json  test.json
ok -- validation done
$ check-jsonschema --schemafile test2.schema.json test_unexpected.json
ok -- validation done

Now let's add additionalProperties:false in it to create a test2_add_properties_false.schema.json schema:

{
  "$schema": "http://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TypeA": {
      "properties": {
        "propertyA": {
          "type": "number"
        }
      },
      "required": [
        "propertyA"
      ]
    },
    "TypeB": {
      "type": "object",
      "allOf": [{"$ref": "#/$defs/TypeA"}],
      "properties": {
        "propertyB": {
          "type": "string"
        }
      },
      "required": [
        "propertyB"
      ],
      "additionalProperties": false
    }
  },
  "$ref": "#/$defs/TypeB"
}

Let' s try it:

$ check-jsonschema --schemafile test2_add_properties_false.schema.json test.json
Schema validation errors were encountered.
  test.json::$: Additional properties are not allowed ('propertyA' was unexpected)
$ check-jsonschema --schemafile test2_add_properties_false.schema.json test_unexpected.json
Schema validation errors were encountered.
  test_unexpected.json::$: Additional properties are not allowed ('propertyA', 'unexpected' were unexpected)

So things are slightly better in which it doesn't reject anymore propertyB, but it rejects the inherited propertyA.

This is why in projjsonschema, I found out that you have to re-mention inherits member of upper classes. So let's try that and create test_redefine_everything.schema.json with:

{
  "$schema": "http://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TypeA": {
      "properties": {
        "propertyA": {
          "type": "number"
        }
      },
      "required": [
        "propertyA"
      ]
    },
    "TypeB": {
      "type": "object",
      "allOf": [{"$ref": "#/$defs/TypeA"}],
      "properties": {
        "propertyA": {},
        "propertyB": {
          "type": "string"
        }
      },
      "required": [
        "propertyA",
        "propertyB"
      ],
      "additionalProperties": false
    }
  },
  "$ref": "#/$defs/TypeB"
}

And now we get the expected results!

$ check-jsonschema --schemafile test_redefine_everything.schema.json test.json
ok -- validation done
$ check-jsonschema --schemafile test_redefine_everything.schema.json test_unexpected.json
Schema validation errors were encountered.
  test_unexpected.json::$: Additional properties are not allowed ('unexpected' was unexpected)

Which correlates with the practice in projjson.schema.json, like the following one where geodetic_crs has to mention the members inherited from object_usage:

    "geodetic_crs": {
      "type": "object",
      "properties": {
        "type": { "type": "string", "enum": ["GeodeticCRS", "GeographicCRS"] },
        "name": { "type": "string" },
        "datum": {
            "oneOf": [
                { "$ref": "#/definitions/geodetic_reference_frame" },
                { "$ref": "#/definitions/dynamic_geodetic_reference_frame" }
            ]
        },
        "datum_ensemble": { "$ref": "#/definitions/datum_ensemble" },
        "coordinate_system": { "$ref": "#/definitions/coordinate_system" },
        "deformation_models": {
          "type": "array",
          "items": { "$ref": "#/definitions/deformation_model" }
        },
        "$schema" : {},
        "scope": {},
        "area": {},
        "bbox": {},
        "vertical_extent": {},
        "temporal_extent": {},
        "usages": {},
        "remarks": {},
        "id": {}, "ids": {}
      },
      "required" : [ "name" ],
      "description": "One and only one of datum and datum_ensemble must be provided",
      "allOf": [
        { "$ref": "#/definitions/object_usage" },
        { "$ref": "#/definitions/one_and_only_one_of_datum_or_datum_ensemble" }
      ],
      "additionalProperties": false
    },

This is why I say that inheritance in JSON schema is not a prime concept.

But... all of that was true in the draf-07 era. draft 2019-09 brings a new keyword, "unevaluatedProperties", which is a kind of "additionalProperties", but that works with inheritance: https://json-schema.org/understanding-json-schema/reference/object#unevaluatedproperties

So let's now slightly edit the original test.schema.json as test_unevaluatedProperties.schema.json by adding a unevaluatedProperties: false member in it:

{
  "$schema": "http://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TypeA": {
      "properties": {
        "propertyA": {
          "type": "number"
        }
      },
      "required": [
        "propertyA"
      ]
    },
    "TypeB": {
      "allOf": [
        {
          "$ref": "#/$defs/TypeA"
        },
        {
          "type": "object",
          "properties": {
            "propertyB": {
              "type": "string"
            }
          },
          "required": [
            "propertyB"
          ]
        }
      ],
      "unevaluatedProperties": false
    }
  },
  "$ref": "#/$defs/TypeB"
}

And now:

$ check-jsonschema --schemafile test_unevaluatedProperties.schema.json test_une
test_unevaluatedProperties.schema.json  test_unexpected.json                    

$ check-jsonschema --schemafile test_unevaluatedProperties.schema.json test_unexpected.json 
Schema validation errors were encountered.
  test_unexpected.json::$: Unevaluated properties are not allowed ('unexpected' was unexpected)

Yes!!! Success. So the Best Practice document should be updated to mention that "unevaluatedProperties": false must be used with inheritance if using JSON Schema Draft >= 2019-09. And this is the key to simplify projjson.schema.json without all the tedious copy&paste bloat I had to do at the time of Draft 07 (PROJJSON was initially released in July 2019)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant