Json Schema Patterns in .NET - Data Object Validation
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.
What is JSON Schema validation, and what are its strengths and limitations?
JSON Schema lets you define structural rules constraining the shape of the JSON documents you expect to conform to that schema.
The schema document itself statically defines the rules, and a validation tool of some kind (the types emitted by Corvus.JsonSchema
, in this case) applies the rules to the instance of a JSON document that you wish to validate.
There are other ways to apply JSON schema. For example there are tools that will apply the schema to an instance and collect annotations, for use by another downstream tool. But in this article we are talking strictly about validation.
Having applied those rules, if it was valid, you know you have "structurally valid data" over which you can reason based on the constraints you have applied in the schema.
The rules of JSON Schema are powerful, but certainly do not support every kind of business rule you could imagine.
In particular, they cannot deal with "non-local" context. For example, there is no way to reference external context (e.g. "the current time", "this value somewhere else in the document", "some other system state from outside the document").
So "validation" is typically in two phases:
- Structural validation with JSON schema
- Semantic validation with traditional business rules in a stateful context
Essentially, you can dynamically apply your business rules to the document in some stateful context, safe in the knowledge that the document is structurally sound.
With that in mind, we can build on a subset of the Person
schema from schema.org, and use JSON Schema to apply constraints to the properties.
For example, it is common to limit string field sizes in APIs, and also to make some fields required
(rather than the default which is optional).
In this case, we are requiring familyName
, givenName
, and dateOfBirth
.
These strings are constrained to a range of 1 to 256 characters if present using minLength
and maxLength
. (Though otherNames
is also allowed to be missing entirely.)
Our height
is constrained to be a positive, double-precision floating point number, with a maximum value of 3.0
, if present. (It too can be missing entirely.)
File: person-constraints.json
{
"title": "The person schema https://schema.org/Person",
"type": "object",
"required": [ "familyName", "givenName", "birthDate" ],
"properties": {
"familyName": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"givenName": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"otherNames": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"birthDate": {
"type": "string",
"format": "date"
},
"height": {
"type": "number",
"format": "double",
"exclusiveMinimum": 0.0,
"maximum": 3.0
}
}
}
There are many other type-specific constraints you can apply, from a regular expression pattern
on a string, to numerous format
values on strings or numbers such as date-time
, uint16
, uuid
.
It is worth getting familiar with these constraints, and the .NET types emitted when we use them, as they will be the ones you use most commonly in your schema.
Generate the code:
generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person-constraints.json
Generating: PersonConstraints
Generating: GivenNameEntity
Generating: FamilyNameEntity
Generating: OtherNamesEntity
Generating: HeightEntity
Custom types are generated for the constrained properties
GivenNameEntity
for GivenName
FamilyNameEntity
for FamilyName
OtherNamesEntity
for OtherNames
and
HeightEntity
for Height
.
These can be used as "specialized" versions of the primitive types on which they are based.
using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;
// Required fields are not-nullable in the create method
var personConstraints = PersonConstraints.Create(
birthDate: new LocalDate(1820, 1, 17),
familyName: "Brontë",
givenName: "Anne",
height: 1.52,
// Invalid string length
otherNames: string.Empty);
// Fast, zero-allocation boolean-only validation
if (!personConstraints.IsValid())
{
// Detailed validation (best used only when fast validation indicates an invalid condition)
var validationResults = personConstraints.Validate(ValidationContext.ValidContext, ValidationLevel.Detailed);
foreach(var result in validationResults.Results)
{
Console.WriteLine(result);
}
}
// Conversion to double is available implicitly as it is now constrained by format, and does not allocate.
double heightValue = personConstraints.Height;