Json Schema Patterns in .NET - Maps of strings to strongly typed values

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.
Turning an object
into a strongly typed map using unevaluatedProperties
One of the more popular data structures in the API landscape is the map (or dictionary) of a string-based key to some strongly-typed value.
In .NET you might traditionally use a type that implements IDictionary<string, T>
such as Dictionary<string, MyObject>
or, for the readonly case an IReadOnlyDictionary<string, T>
such as ImmutableDictionary<string, MyObject>
.
It's very easy to represent that type in JSON schema. We simply define a schema with type: "object"
, then set the unevaluatedProperties
to the schema for the type that is the target of map.
File: map-of-string-to-type.json
{
"type": "object",
"unevaluatedProperties": { "$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
}
}
}
As in previous examples, you should use additionalProperties
instead of unevaluatedProperties
for older drafts.
Generate the code:
generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api map-of-string-to-type.json
Generating: HeightEntity
Generating: MapOfStringToType
Generating: ConstrainedString
Generating: PersonClosed
The code generator recognizes this as a strongly typed map.
In addition to providing you with strongly typed accessors and enumerators, it implements IReadOnlyDictionary<JsonPropertyName, T>
so you can use an instance with standard LINQ operators if required.
Notice that we use JsonPropertyName
instead of string
for the key. This is convertible to string
(e.g. with a cast), but is more allocation-friendly. For example, you can do zero-allocation comparisons with string and UTF8 byte spans.
using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;
string jsonMapString =
"""
{
"beans": {
"familyName": "Smith",
"givenName": "John",
"otherNames": "Edward,Michael",
"birthDate": "2004-01-01",
"height": 1.8
},
"bangers": {
"familyName": "Johnson",
"givenName": "Alice",
"birthDate": "2000-02-02",
"height": 1.6
},
"bacon": {
"familyName": "Williams",
"givenName": "Robert",
"otherNames": "James,Thomas",
"birthDate": "1995-03-03",
"height": 1.7
},
"burgers": {
"familyName": "Brown",
"givenName": "Jessica",
"birthDate": "1990-04-04",
"height": 1.9
}
}
""";
// Parse a map from JSON
using ParsedValue<MapOfStringToType> parsedMap = ParsedValue<MapOfStringToType>.Parse(jsonMapString);
MapOfStringToType map = parsedMap.Instance;
// Enumerate the strongly typed items
foreach (JsonObjectProperty<PersonClosed> item in map.EnumerateObject())
{
PersonClosed val = item.Value;
Console.WriteLine($"{item.Name}: {item.Value}");
}
// Get a known value from the map (throws if not present)
PersonClosed bangers = map["bangers"];
// Try to get a known value from the map
if (map.TryGetProperty("burgers", out PersonClosed burgersPerson))
{
Console.WriteLine(burgersPerson);
}
// Update the map with a strongly typed value
map = map.SetProperty(
"fish",
PersonClosed.Create(new LocalDate(1991, 3, 7), "Irving", "Henry"));
// Construct a map from name/value tuples
var newMap = MapOfStringToType.FromProperties(
("one", map["bacon"]),
("two", map["fish"]));
// Manipulate a map with LINQ operators
FrozenDictionary<string, string> dictionaryOfStringToString =
map
.Where(kvp => kvp.Value.BirthDate > new LocalDate(1995,1,1))
.ToFrozenDictionary(
kvp => (string)kvp.Key,
kvp => $"{kvp.Value.FamilyName}, {kvp.Value.GivenName}");