.NET JsonElement and Schema Validation
In a recent blog, I talked about how JsonElement
has a trap for the unwary when it comes to handling invalid input. I also mentioned that our recommended way of dealing with that kind of problem is to use our Corvus.JsonSchema
library to validate the input. In this post, I'll show what that looks like.
Accessing JSON properties
In the previous blog I showed this example input:
{
"id": 1331,
"action": "frobnicate"
}
Here's how we would validate that the input conforms to our expectations, and then go on to use it:
using JsonDocument doc = JsonDocument.Parse(json);
Command c = Command.FromJson(doc.RootElement);
if (c.IsValid())
{
int id = c.Id;
Console.WriteLine($"Command id {id} received)");
}
This begins with some pretty ordinary initialization of System.Text.Json
's JsonDocument
. The next line wraps this in a type called Command
, which is generated from our schema. (I'll show how in a moment.) A single method call, IsValid
, checks that the document is correctly formed, and then we can go on to work with it. Notice that the generated Command
type provides an Id
property corresponding to the id
in the input JSON. (As you have probably guessed, it also offers an Action
property.)
This looks a lot like ordinary JSON deserialization: we were able to access the id
from the JSON by writing c.Id
. But as you'll see later, this generated Command
wrapper type can operate over twice as quickly as JsonSerializer
, and can put considerably lower demands on the garbage collector. And it also gives us the ability to validate incoming data.
So how do we generate these wrapper types?
Define your schema (aka state your assumptions)
The key feature of our preferred approach to JSON handling is that we create a formal definition of what constitutes valid JSON input for our code. We use the language designed specifically for this job: JSON Schema.
This is the aspect most likely to encounter resistance from developers. The idea of introducing a new language to our codebase, JSON Schema, can put people off. Traditional serialization can seem more attractive because you can remain entirely within the world of C#. But we think this reaction is a mistake. JSON Schema offers many benefits, which can more than compensate for the friction.
Consider the example JSON input shown above. We might look at this and think we understand what's expected. There's a property called id
that looks like an integer, and a property called action
that's a string. So when you look at this schema definition that captures that, your first response might be to think that this is making a bit of a meal of things:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "JSON Schema for an incoming command",
"$defs": {
"Command": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"action": {
"type": "string",
"enum": ["frobnicate", "kludgify"]
}
}
}
}
}
However, this is much less vague than the hand-wavey informal description. For example this clarifies that the action
property should be one of a small fixed set of strings. It also specifies precisely what values are allowed for the id
field. This explicitly rules out floating point numbers ("type": "integer"
) and negative numbers ("minimum": 0
). It also states that the value will fit in a 32-bit signed integer storage location by specifying a format
of int32
.
This might prompt questions: are these appropriate restrictions? Might our system grow to a point where a 32-bit integer is too small?
It would have been so much easier just to ignore all this and pretend that it was good enough to say "it's just a number." But confronting these kinds of questions is one of the benefits of nailing things down formally. If a developer complains about being asked to be precise about what is or is not acceptable as input, we can reasonably ask: really? You're happy to leave these things to chance?
Look at almost any internet standard that has passed the test of time, and you will find that it specifies very precisely how data should be structured. IP, TCP, the various email standards, HTTP—they all use formal grammars that describe unambiguously exactly how communication should occur. (There's a notorious exception. Early versions of HTML were infamously loosely defined. This did eventually get fixed, but unfortunately, the resulting standards were quite a long way away from how the majority of websites were by then actually using HTML in practice. Browsers had to guess whether sites were built for the long-established de facto informal standards, or the more recently defined formal standards. It took the best part of a decade to resolve the resulting mess.)
A popular online messaging platform formerly known as Twitter went through a period of upheaval when the number of messages in the system grew into the billions, meaning that a 32-bit identifier was too small: since they had never fully specified what a message identifier really was, numerous client libraries had made assumptions that caused things to break when those assumptions proved false.
Of course, even if they had clarified up front exactly what an id is they might still have made the mistake of choosing an unsuitable representation. In the schema I've shown above, I've baked in the decision that the number of things being identified here will never grow into multiple billions. But the fact that I've explicitly stated that assumption in a schema did at least force me to consider whether this is likely to be correct. Waiting until after you've published an API to think about what its messages mean is likely to cause pain if your API becomes successful.
Another potential benefit of using JSON Schema is that you're not tied to any one programming language. I'm using C#, but there's no reason the exact same schema couldn't be used to generate adapters for any number of other languages.
Generating C# wrappers
Next, we will generate C# wrappers from this schema using the Corvus.Json.JsonSchema.TypeGeneratorTool
tool. You can install that with this command:
dotnet tool install --global Corvus.Json.JsonSchema.TypeGeneratorTool
or if you already have it and want to ensure that it's up to date you can run this:
dotnet tool update --global Corvus.Json.JsonSchema.TypeGeneratorTool
We can now run this tool against our schema file. In addition to the filename, we need to provide an argument specifying the namespace into which we'd like our C# wrappers to be generated (--rootNamespace
). The tool also needs to know which type within the schema we want it to generate code for. Schema files often contain multiple types, so we can say which one we want with the --rootPath
option:
generatejsonschematypes --rootNamespace JsonSchemaSample.Api --rootPath '#/$defs/Command' .\CommandSchema.json
This displays the following:
Generating: Command
Generating: ActionEntity
Generating: IdEntity
This shows that the tool has recognized that there's actually more than one type here. Only one of them is explicitly named in the schema: Command
. But the two properties that type defines, action
and id
, each have their own type. They are not merely a string and a number. They have additional constraints. Although these are anonymous types in the schema, the tool generates named types to represent them in C#. Here are all of the types it creates for the schema shown above:
Type | Purpose |
---|---|
Command |
Wrapper type for the schema's Command type |
Command.ActionEntity |
Wrapper representing the Command type's action property's type |
Command.ActionEntity.EnumValues |
Describes the values the action property can have |
Command.IdEntity |
Wrapper representing the Command type's id property's type |
Command.JsonPropertyNames |
Provides UTF-8 representations of each of the Command type's property names |
The tool creates a surprising number of files. The code generator creates partial types, putting different aspects of its handling into different files, but this is just an implementation detail. What matters is the actual types it creates.
To use these types, you can just include these generated source files in a project. (We normally do this by putting schema files in a folder inside your project dedicated to that, and we normally create a Generated
folder beneath that to hold the generator tool output. The .NET build tools will treat generated source files in this folder like any other source code in the project.)
The generated code depends on the Corvus.Json.ExtendedTypes
library so you'll need to add a reference to that NuGet package. We can then use these generated wrappers to work with JSON that we expect to conform to that schema.
Using the wrappers
The tool generates wrappers that work on top of JsonElement
values. So to use them we must first parse the JSON into a JsonDocument
. The generated Command
wrapper offers a FromJson
, into which we can pass the document RootElement
. In fact I showed all of that earlier, but I'm going to expand a little on that first example:
using JsonDocument doc = JsonDocument.Parse(json);
Command c = Command.FromJson(doc.RootElement);
if (c.IsValid())
{
int id = c.Id;
switch ((string)c.Action) // This allocates a string on the GC heap!
{
case "frobnicate":
// App-specific processing would go here...
Console.WriteLine($"Frobnicating {id}");
break;
case "kludgify":
// App-specific processing would go here...
Console.WriteLine($"Frobnicating {id}");
break;
}
}
Notice that I've called c.IsValid()
, and I only go on to process the JSON if this returns true. This means I can be confident that the JSON fully conforms to the schema. There will be no missing properties, and everything will have the correct JSON type (e.g., no strings where numbers are expected) and will be within the range of values deemed acceptable by the schema. (For example, the id
will not be negative, it will be a whole number, and it will not be too large to fit in an int
.)
If your application wants to report any validation problems it discovers, there's also a Validate
method that enables you to obtain a list of all the ways in which the input did not conform to the schema.
Note: IsValid
is not defined directly by the generated code. It's an extension method defined in the Corvus.Json.ExtendedTypes
library, and you will need a using Corvus.Json;
directive to be able to use it. This calls the more flexible but more complex Validate
method for you.
By the way, these wrapper types do not insist on valid input. If IsValid
returns false you are still free to retrieve the properties (or you could just not call either IsValid
or Validate
at all). It's just that you can't then be sure you won't get invalid values (such as JsonValueKind.Undefined
, or a numeric value that is outside of the required range) which could then result in exceptions when you try to convert those values to a .NET type.
Implicit conversions
I was able to write the obvious code for retrieving the id
property:
int id = c.Id;
However, if you look at the Id
property, you'll see that its type is Command.IdEntity
. The assignment to int
works because the schema specifies that the id
field in the JSON must be an integer with a format
of int32
. This causes the code generator to emit an implicit
conversion operator to int
. What if we had not specified that constraint, and had instead stated only that we require the number to be a positive integer?
"id": {
"type": "integer",
"minimum": 0
},
With this schema, we could not safely assume that id
property values would fit in an int32
. It allows positive integers larger than int.MaxValue
. The generated wrappers would still provide a way to retrieve the value as an int
. They would just make the relevant conversion operator explicit
instead.
The idea here is to emulate the implicit conversions that C# makes available for the built-in numeric types. C# will silently convert between numeric types if it is certain that the conversion will not fail. For example, it allows an int
to be converted to a long
because long
can represent every possible int
value. It won't convert int
to short
though because short
is a 16-bit integer, and can't represent most of the values int
can. More subtly, C# also won't implicitly convert an int
to ulong
, even though the 64-bit ulong
has a much wider range than the 32-bit int
. That's because ulong
's range does not completely contain int
's range—they merely overlap. (E.g., int
can represent -1
but ulong
can't.)
If the schema requires that a particular property will always have values that are compatible with a built-in numeric type, the Corvus.JsonSchema
generator will generate an implicit
conversion to that type. This means that if you find yourself needing to write casts to get the property values out, it's possible that the conversion will fail at runtime (just like casting an int
with (ulong)
might fail at runtime) even if the JSON passes schema validation. This might be an indication that your schema is not as specific as you need it to be (or that your code is not as general as it should be).
Note: these implicit
conversion operators can fail if the JSON is not valid. One of the goals of the Corvus.JsonSchema
library is to allow programs to work with invalid data if they wish. If IsValid
returns false
you are free to attempt to proceed anyway (because the real world is messy, and often data doesn't quite conform to the constraints we would like). But if you do this, you lose the guarantee that implicit
conversions will always succeed. This is arguably a violation of the usual guideline that cast operators should only be implicit
if they can never fail. However, we felt that it was useful and sufficient to guarantee this only for known-valid documents. In cases where you work only with validated data, the availability of implicit
conversions provides you with useful information from the schema, and they also enable the most natural code.
Strings and allocations
You might have noticed that we did need to cast c.Action
to string
:
switch ((string)c.Action) // This allocates a string on the GC heap!
Why would we need that? The schema type for action
definitely guarantees that if the JSON is valid, that property will have a value that is a valid, non-null string
. (It's more specific: it guarantees that it will be one of two particular string
values. But the key point is that there's no way conversion to string
can fail if the JSON is valid.)
"action": {
"type": "string",
"enum": ["frobnicate", "kludgify"]
}
You might have expected an implicit conversion to string
to be available here. In fact, only an explicit
conversion is defined, even though this conversion is guaranteed to succeed if the document is valid. Why is that?
We handle string
differently from numeric types because conversion to string
entails allocation. A central goal of Corvus.JsonSchema
to enable extremely efficient low-allocation JSON processing. Silently generating string
instances due to implicit conversions would not be consistent with that goal—it would make it too easy to cause allocations by accident. So we require a deliberate statement of intent before we will allocate a string.
In fact, it's not necessary to convert the action
property to a .NET string in this case. It has only two possible values and we can test to see which it is without allocating anything on the GC with the following rather clunky code:
if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.FrobnicateUtf8))
{
// App-specific processing would go here...
Console.WriteLine($"Frobnicating {id}");
}
When Corvus.JsonSchema
makes a string-like property available, it will always provide this EqualsUtf8Bytes
method. Remember that System.Text.Json
works directly against UTF-8-encoded JSON. It will not convert it into the UTF-16 encoding used by .NET's string
type unless you force it to. This ability to process the data directly in its native representation without copying or re-encoding is a critical factor in the very high performance System.Text.Json
is able to achieve.
This EqualsUtf8Bytes
method lets us ask "is this property's value equal to this UTF-8 encoded text?" Asking the question in this way offers a couple of advantages. First, it removes any need to create a string
representation of the action
property's value, so we avoid allocating anything on the GC heap. Second, it means the string comparison doesn't need to deal with two different encodings—we are comparing the UTF-8 encoding that is present in the source JSON document with another UTF-8-encoded string. (It still might not be as simple as a byte-for-byte comparison because JSON text values may include escaped characters, e.g. "Json string with \"quotes\" in it"
. but it's still going to be faster to compare a JSON string value without also having to deal with different encodings.)
The Command.ActionEntity.EnumValues.FrobnicateUtf8
returns a UTF-8 byte sequence representing the text frobnicate
. Corvus.JsonSchema
generates properties like this when a string
-type schema is constrained to be one of the values in an enum
array. It generates one such property for each string entry in the enum
. (Be aware that JSON schema also allows an enum
to include null
as a possible value, and also the boolean true
and false
values. The generated EnumValues
does not include properties for these when an enum
schema type uses them, because they aren't strings.). These properties return a ReadOnlySpan<byte>
, and they do so in a way that exploit's C#'s ability to create these directly from raw pointers into the part of your .dll
that contains the UTF-8 bytes, meaning these properties never need to allocate anything on the GC heap, and they are extremely efficient in practice.
An alternative way to switch
The fact that JSON Schema enum
types are more general than .NET ones is just one instance of a more general capability: we can write schemas where a particular property might be any of several different types. (The anyOf
combining keyword is a more powerful example of the same basic idea.) That's why we don't just generate a .NET enum
type. It would only be able to handle quite a narrow subset of what schemas can do.
Instead, whenever a property might be one of several different things, the generated code includes a Match
method that determines which particular one of those things is present. (This works both for enum
and anyOf
types.) You supply Match
with a callback for each case. Conceptually this is similar to a switch
statement but allows for the more complex tests that may need to be performed when a property won't merely be one of a fixed set of strings. Corvus.JsonSchema
tends to encourage a functional programming style, so it presumes that these callbacks will return values, instead of just having some side effect. So imagine you had something like this:
private async Task<IActionResult> FrobnicateAsync(int id)
{
this.logger.LogInformation("Frobnicating {id}", id);
await this.messaging.QueueFrobnicateCommandAsync(id);
return Accepted();
}
and a similar KludgifyAsync
method, you could write this:
if (c.IsValid())
{
int id = c.Id;
IActionResult result = await c.Action.Match(
id,
matchFrobnicate: FrobnicateAsync,
matchKludgify: KludgifyAsync,
defaultMatch: _ => throw new InvalidOperationException(
"Match should succeed when validation succeeded"));
}
A useful feature of this is that the generated Match
method's signature requires you to cover all the possibilities defined by the schema, something a switch
statement does not enforce. (In code where we are aiming to minimize allocations, we might do something a little more complex to avoid allocating a delegates that implicitly capture the this
reference, but that's a topic for another blog.)
Wrapper performance
I've mentioned performance a few times now, so I should present some supporting evidence. I've written a benchmark comparing the various options which produced the following results:
Method | Mean | Error | Ratio | Gen0 | Allocated | Alloc Ratio |
---|---|---|---|---|---|---|
ClassicSerialization | 289.96 ns | 5.697 ns | 1.00 | 0.0105 | 88 B | 1.00 |
CorvusSchemaStringSwitch | 109.60 ns | 0.398 ns | 0.38 | 0.0057 | 48 B | 0.55 |
CorvusSchemaStringSwitchValidate | 798.26 ns | 6.143 ns | 2.75 | 0.0057 | 48 B | 0.55 |
CorvusSchemaNoStringSwitch | 98.50 ns | 1.152 ns | 0.34 | - | - | 0.00 |
CorvusSchemaNoStringSwitchValidate | 788.65 ns | 3.867 ns | 2.72 | - | - | 0.00 |
CorvusSchemaMatchSwitch | 90.67 ns | 1.087 ns | 0.31 | - | - | 0.00 |
CorvusSchemaMatchSwitchValidate | 764.45 ns | 10.360 ns | 2.64 | - | - | 0.00 |
The full code is shown at the end of this article. The ClassicSerialization
benchmark uses the System.Text.Json
JsonSerializer
to deserialize the JSON into a .NET object. It takes under a third of a microsecond to process the example document, which is not too shabby, and it allocates 88 bytes on the GC heap to do so. This includes the output object, a CommandJsonSerializable
, which has two properties: an int
and an enum, so that should be just 8 bytes of data and another 16 bytes of object header, a total of 24 bytes. The deserializer also constructs a BitArray
(12 bytes of data, plus 8 bytes of header and another 4 of padding, which is 32 bytes), which in turn wraps an int[]
of length 1. It appears that uses 8 bytes for the length and it pads out the data to 8 bytes for alignment, so that's another 32. So those three objects—the deserialized result, the BitArray
and the int[]
that it wraps, come to 88 bytes.
The CorvusSchemaStringSwitch
benchmark uses code using the wrapper generated by Corvus.JsonSchema
but is otherwise written to look as similar as possible to the classic serialization example. It operates at a slight disadvantage: because enumerations in JSON schema can list values that have a mixture of values—you're allowed to write "enum": ["text", 42, false]
for example—they don't in general map onto .NET enum
types and Corvus.JsonSchema
does not currently attempt to present them as a .NET enum
even though that would be viable in this particular schema. That means that the Corvus.JsonSchema
code must work directly with the action
property as it appears in the JSON: as text. As you've seen above, we can just ask for the string
so we can use an ordinary switch
, which means Corvus.JsonSchema
has no choice but to allocate memory in that case. Even so, it is well over twice as fast, and allocates considerably less memory than JsonSerializer
did: 48 bytes. That's the "frobnicate"
string, which is 10 characters long, so it will require 20 bytes, except .NET sticks a trailing NUL (\0
) on the end so with that plus alignment, in practice that's going to get rounded up to 24 bytes, plus another 4 for the length. On a 64-bit process that will be rounded up to the nearest multiple of 8, 32 bytes, and then another 16 bytes for the object header in a 64-bit process, which brings us to the 48 bytes for the string.
But notice the CorvusSchemaNoStringSwitch
benchmark a little further down. This executes the same logic, it just uses that slightly more convoluted if
/else
construct. That enabled it to avoid allocating any memory on the GC heap at all! (We're still working with the value in its native JSON form, so it's still text, it's just that we avoided using .NET's string
type.) It was also usefully faster than the CorvusSchemaStringSwitch
benchmark, and almost three times faster than classic JSON serialization.
The CorvusSchemaMatchSwitch
benchmark uses the Match
method also described earlier instead of a C# switch
statement. Like the CorvusSchemaNoStringSwitch
version, this avoids allocating a string, and also seems to work out marginally faster still. That's good because we prefer this approach. It makes it harder to accidentally leave out handling for some cases.
I've also included three benchmarks that use the IsValid
method. These do slow things down considerably, but realistically, unless your JSON input is coming from a source where, for some reason, it's safe to assume that it is in exactly the form you require, you should be doing validation anyway. We can't easily compare that with JsonSerialization
because it doesn't have any directly equivalent functionality. It will have to be performing some basic checks to make sure that the int
input really is a number in the JSON. But it's not actually at all obvious what JsonSerializer
will and won't check for you, or what the behaviour will be in cases where the input isn't quite what you expect.
Conclusion
JSON Schema enables us to define precisely how a JSON document should be structured. The Corvus.JsonSchema
libraries and tools can take a schema and generate code that makes it nearly as easy to work with incoming JSON as ordinary JSON deserialization, but with significantly better performance. And if you choose, this generated code can also validate incoming documents, enabling you to be certain that input is in the correct form before you try to process it.
Although it can take a bit more effort to write a schema than just diving straight in, the process can help to reveal ambiguities in your design. Also, because schemas are not specific to any single language, they can be helpful when you are using multiple languages. For example, if a web service back end is written in C# while the UI uses TypeScript, there are tools able to generate TypeScript type definitions from a schema, similar to the tool I showed in this article for creating C# types. With a schema, both languages can share the same underlying type definitions. (See https://json-schema.org/implementations#generators-from-schemas for a list of such tools.)
Benchmark code
Here is the source code used to produce the benchmark results shown earlier.
public class CommandJsonSerializable
{
public required int Id { get; init; }
public required ActionEnum Action { get; init; }
}
public enum ActionEnum
{
Frobnicate,
Kludgify
}
[MemoryDiagnoser]
public class JsonProcessingPerf
{
private static readonly byte[] json = """
{
"id": 1331,
"action": "frobnicate"
}
"""u8.ToArray();
private static readonly JsonSerializerOptions options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
private static readonly JsonDocument doc = JsonDocument.Parse(json);
static JsonProcessingPerf()
{
options.Converters.Add(new JsonStringEnumConverter());
}
[Benchmark(Baseline = true)]
public int ClassicSerialization()
{
CommandJsonSerializable js = JsonSerializer.Deserialize<CommandJsonSerializable>(doc, options)!;
int id = js.Id;
switch (js.Action)
{
case ActionEnum.Frobnicate: return id + 1;
case ActionEnum.Kludgify: return id + 2;
}
return 0;
}
[Benchmark]
public int CorvusSchemaStringSwitch()
{
Command c = Command.FromJson(doc.RootElement);
int id = c.Id;
switch ((string)c.Action)
{
case "frobnicate": return id + 1;
case "Kludgify": return id + 2;
}
return 0;
}
[Benchmark]
public int CorvusSchemaStringSwitchValidate()
{
Command c = Command.FromJson(doc.RootElement);
if (c.IsValid())
{
int id = c.Id;
switch ((string)c.Action)
{
case "frobnicate": return id + 1;
case "Kludgify": return id + 2;
}
}
return 0;
}
[Benchmark]
public int CorvusSchemaNoStringSwitch()
{
Command c = Command.FromJson(doc.RootElement);
int id = c.Id;
if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.FrobnicateUtf8))
{
return id + 1;
}
else if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.KludgifyUtf8))
{
return id + 2;
}
return 0;
}
[Benchmark]
public int CorvusSchemaNoStringSwitchValidate()
{
Command c = Command.FromJson(doc.RootElement);
if (c.IsValid())
{
int id = c.Id;
if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.FrobnicateUtf8))
{
return id + 1;
}
else if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.KludgifyUtf8))
{
return id + 2;
}
} return 0;
}
[Benchmark]
public int CorvusSchemaMatchSwitch()
{
Command c = Command.FromJson(doc.RootElement);
int id = c.Id;
return c.Action.Match(
id,
matchFrobnicate: static id => id + 1,
matchKludgify: static id => id + 2,
defaultMatch: static _ => 0);
}
[Benchmark]
public int CorvusSchemaMatchSwitchValidate()
{
Command c = Command.FromJson(doc.RootElement);
int id = c.Id;
if (c.IsValid())
{
return c.Action.Match(
id,
matchFrobnicate: static id => id + 1,
matchKludgify: static id => id + 2,
defaultMatch: static _ => 0);
}
return 0;
}
}