Json Schema Patterns in .NET - Constraining a base type
In this series we are cataloging common patterns with JSON Schema and the .NET code generated by Corvus.JsonSchema.
It is especially useful for developers who want to move to a schema-first approach, but are unsure how that will map to their .NET code.
We are focusing on draft 2020-12 and the dotnet8.0 code generation, but very similar patterns apply for older versions of both .NET and draft 2019-09, draft 7, and even draft 6. We will highlight the key differences as we go.
If you have no experience of JSON Schema at all, I would recommend you read the getting started step-by-step documentation provided by the JSON Schema team.
Adding constraints
Whether a type is open or closed, you can further constrain it in a schema.
Remember that a closed type in JSON Schema is not like a "sealed" type in an OO language like C#. You can still base another schema on that type - it's just that it will not allow extra properties that are not present on the base type.
In this case, we will use $ref
to declare that we are basing our new schema on the person-closed.json
schema.
Notice how we can reference schema in external documents, not just the local schema document.
This would work equally well with the person-open.json
we used when extending a base type.
We then constrain it by defining a new version of the height
property.
Again, we use $ref
to base the new schema for the height
property on the schema of the height
property on the base schema. Then we add a new minimum
value to constrain it to be greater than 2.0
.
Notice how we don't just have to reference schemas defined in a $defs
section. We can reference any schema defined in the document.
I don't generally encourage this pattern; ideally if a type is intended to be shared and composed in this way, I like to indicate that by embedding it in the $defs
section and documenting it properly.
File: person-tall.json
{
"title": "A tall person",
"$ref": "./person-closed.json",
"properties": {
"height": {
"$ref": "./person-closed.json#/properties/height",
"minimum": 2.0,
}
}
}
In draft 6 and draft 7, $ref
cannot be used in this way. It acts as a reference to the target type and ignores the adjacent values. This is often a bit surprising! Instead, you can use allOf
with a single value to get the same effect. It is a little clunkier to read, but works in the same way.
{ "allOf": [{"$ref": "./person-closed.json"}]}
File: person-closed.json
{
"title": "The person schema https://schema.org/Person",
"type": "object",
"required": [ "familyName", "givenName", "birthDate" ],
"properties": {
"familyName": { "$ref": "#/$defs/constrainedString" },
"givenName": { "$ref": "#/$defs/constrainedString" },
"otherNames": { "$ref": "#/$defs/constrainedString" },
"birthDate": {
"type": "string",
"format": "date"
},
"height": {
"type": "number",
"format": "double",
"exclusiveMinimum": 0.0,
"maximum": 3.0
}
},
"unevaluatedProperties": false,
"$defs": {
"constrainedString": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
}
}
When we validate, the Height
property on PersonTall
will be constrained both by characteristics of the base type (such that 0 < height <= 3.0
) and the new height >= 2.0
. The new constraints are composed from both the base and the derived schema.
This "composition" behaviour is often surprising to people familiar with OO languages. You can't "turn off" the base constraints in the way you can with, say, C# virtual methods that do not call the base from the derived type, because they are composed together.
You may also be surprised to learn that it is not even necessary to include this $ref
to the base "height" schema to get the desired validation behaviour! If you just added the minimum
constraint to the new height
property, it would validate as desired.
However, $ref
is the way we describe to the code generator that we are basing this new HeightEntity
type on the one from the base schema, rather than merely defining an isolated constraint. In that way, the generated type inherits all the other characteristics from the one on which it is based.
Are your constraints compatible?
Another consequence of this composition behaviour is that any additional constraints you apply should be compatible with the base type's existing constraints (if any).
Neither JSON Schema itself, nor Corvus.JsonSchema
enforce this rule - but any schema that doesn't follow it will not be especially useful!
Imagine, for example, defining a schema that required an entity to be both an object
and a string
. That's perfectly possible in JSON Schema; and the code generator will emit "correct" code that happily compiles. But no entity will ever be valid.
(Contrast this with a constraint that an entity can be either an object
or a string
- that would be absolutely fine; this kind of "sum type" is common in e.g. typescript, but is unusual in .NET languages. We will see later how Corvus.JsonSchema enables these kinds of structures in dotnet.)
Generate the code:
generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person-tall.json
Generating: PersonTall
Generating: HeightEntity
Generating: HeightEntity
Generating: PersonClosed
Generating: ConstrainedString
Notice that it has generated both PersonTall
and PersonClosed
; each has its own HeightEntity
.
using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;
// Create a tall person, although Anne is not
// tall enough to be a valid tall person!
PersonTall personTall = PersonTall.Create(
birthDate: new LocalDate(1820, 1, 17),
familyName: "Brontë",
givenName: "Anne",
height: 1.57);
// Implicit conversion to the base type
PersonClosed personClosedFromTall = personTall;
// personTall is not valid because of the additional constraint.
if (personTall.IsValid())
{
Console.WriteLine("personTall is valid");
}
else
{
Console.WriteLine("personTall is not valid");
}
// But personClosedFromTall is valid because it does not have the
// additional constraint.
if (personClosedFromTall.IsValid())
{
Console.WriteLine("personClosedFromTall is valid");
}
else
{
Console.WriteLine("personClosedFromTall is not valid");
}