Introducing Corvus.Text.Json V5: Standalone Evaluator and Annotations
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at the mutable builder pattern.
Now let's look at a different code generation mode. You'll want this if you're building schema-driven tooling.
Not every tool needs a full type system
The source generator we covered in post 2 produces strongly-typed C# models: property accessors, validation, serialization, mutable builders, the works. That's exactly what you want when you're building an application that uses JSON data.
But some tools don't use the data. They describe it. A form generator reads the schema's title, description, and default keywords to render input fields. A documentation tool extracts examples and deprecated flags. A configuration editor shows readOnly and writeOnly hints.
These tools need annotations, not types.
What are JSON Schema annotations?
The JSON Schema specification defines a set of keywords that are annotation-producing. They carry metadata about the data rather than constraints on it:
| Keyword | Purpose |
|---|---|
title |
Human-readable name for the schema or property |
description |
Longer explanation of what the data means |
default |
A default value for the property |
examples |
An array of example values |
deprecated |
Whether this property is deprecated |
readOnly |
Whether this property is read-only |
writeOnly |
Whether this property should not be returned in responses |
format |
A format hint (e.g., "email", "date-time") |
contentMediaType |
MIME type of string content |
contentEncoding |
Encoding of string content (e.g., "base64") |
These annotations flow through the validation process according to the specification. For example, annotations from a then branch are only collected if the if condition passes. Annotations from composition keywords (allOf, anyOf, oneOf) are merged according to the spec rules.
Getting this right requires a fully compliant evaluator. The V5 standalone evaluator provides exactly that.
Generating the standalone evaluator
With the source generator
Set EmitEvaluator = true on the attribute to generate both the typed model and the standalone evaluator:
using Corvus.Text.Json;
namespace MyApp.Models;
[JsonSchemaTypeGenerator("Schemas/person.json", EmitEvaluator = true)]
public readonly partial struct Person;
With the CLI tool
To generate only the evaluator (no typed models):
corvusjson jsonschema Schemas/person.json \
--rootNamespace MyApp.Evaluators \
--outputPath Generated/ \
--codeGenerationMode SchemaEvaluationOnly
Or generate both:
corvusjson jsonschema Schemas/person.json \
--rootNamespace MyApp.Models \
--outputPath Generated/ \
--codeGenerationMode Both
The standalone evaluator is a single static class, considerably smaller than the full type hierarchy. It supports the same schema drafts (4, 6, 7, 2019-09, 2020-12, OpenAPI 3.0) and the same validation semantics.
Collecting annotations
To collect annotations, run the evaluator in Verbose mode and use JsonSchemaAnnotationProducer:
using var doc = ParsedJsonDocument<JsonElement>.Parse(jsonText);
JsonElement instance = doc.RootElement;
// Validate in Verbose mode - this collects all annotations
using var collector = JsonSchemaResultsCollector.Create(
JsonSchemaResultsLevel.Verbose);
instance.EvaluateSchema(collector);
// Enumerate annotations - zero-allocation ref struct enumerator
foreach (JsonSchemaAnnotationProducer.Annotation annotation
in JsonSchemaAnnotationProducer.EnumerateAnnotations(collector))
{
Console.WriteLine(
$" {annotation.GetInstanceLocationText()} " +
$"[{annotation.GetKeywordText()}] " +
$"= {annotation.GetValueText()}");
}
Each Annotation has four pieces of information:
- Instance location - the JSON Pointer to the value being annotated (e.g.,
""for root,"/name"for a property) - Keyword - which annotation keyword produced it (e.g.,
"title","description") - Schema location - where in the schema the annotation was defined
- Value - the raw JSON value of the annotation
The Annotation type is a ref struct whose spans reference the internal buffers of the collector. It's only valid during the current iteration. If you need to capture values for later use, call the string accessors (GetKeywordText(), GetValueText(), etc.).
Writing annotations as JSON
For structured output, WriteAnnotationsTo produces a JSON object grouped by instance location, then keyword, then schema location. This is useful when feeding the output into other tools, such as a form renderer:
using var collector = JsonSchemaResultsCollector.Create(
JsonSchemaResultsLevel.Verbose);
instance.EvaluateSchema(collector);
using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer,
new JsonWriterOptions { Indented = true }))
{
JsonSchemaAnnotationProducer.WriteAnnotationsTo(collector, writer);
}
This produces output like:
{
"": {
"title": {
"#": "\"Person\""
},
"description": {
"#": "\"A person with a name and optional age\""
}
},
"/name": {
"title": {
"#/properties/name": "\"Full name\""
}
}
}
A form generator can walk this structure and render appropriate inputs for each annotated property. It can use each title as a label alongside the description as help text, pre-fill defaults where provided, and apply readOnly/writeOnly constraints to control editability.
When to use which mode
| Scenario | Mode |
|---|---|
| Application code that consumes JSON data | TypeGeneration (default) |
| Form generator, documentation tool, schema-driven UI | SchemaEvaluationOnly |
| Application code that also exposes a schema-driven API | Both |
| Validation-only gateway or middleware | SchemaEvaluationOnly |
Next up
We've now covered the core V5 model - types, validation, pooled memory, mutation, and annotations. In the next post, we'll start looking at the query and transformation languages, beginning with JSONata.