JsonSchema.Net.Generation is an extension package to JsonSchema.Net that provides JSON Schema generation from .Net types.
Using it is quite simple. First you need a JsonSchemaBuilder
. Then…
1
var schema = schemaBuilder.FromType<MyType>().Build();
Done.
The validating converter described in this document requires AOT-incompatible reflection to operate, so it will not be usable in a Native AOT context.
IMPORTANT
Ideally, this functionality should be used to create a starting point in authoring a schema. The schemas output by this library should be reviewed by actual people prior to being put into a production system.
The JSON Schema team generally recommends against real-time schema generation.
The suggested approach is to have a dedicated schema generation data model, separate from your domain model and DTOs, from which schemas are generated. Generation can be done as an independent developer activity or as part of a CI/CD build chain. This helps separate serialization and validation concerns and prevent overburdening your domain/DTO models with a bunch of attributes.
Making better schemas
The above will give you a basic schema that will include the type
keyword, and maybe a listing of the properties. But many times you have more detailed requirements of the data you’re receiving. For example,
- some properties may be required
- string properties may have length requirements
- numeric properties may have value range requirements
All of these and more are supplied via a set of attributes that can be applied to properties, and some can be applied to to types. The following attributes are included in this package:
- Numeric values
Minimum
ExclusiveMinimum
Maximum
ExclusiveMaximum
MultipleOf
- Strings
MinLength
MaxLength
Pattern
- Arrays
MinItems
MaxItems
UniqueItems
- All
Id
Required
&Nullable
(see below)Obsolete
* (translates todeprecated
)JsonExclude
**Title
Description
Const
***Default
***ReadOnly
WriteOnly
- Conditional (see Conditionals)
If
Then
Else
* The [Obsolete]
attribute is System.Obsolete
. All of the others have been defined within this library. System.ComponentModel.DataAnnotations
support is currently in discussion.
** The [JsonExclude]
attribute functions equivalently to [JsonIgnore]
(see below). It is included to allow generation to skip a property or an enum member while allowing serialization to consider it.
*** Even though the const
and default
keywords in JSON Schema can accept any JSON value, because they are attributes, [Const]
and [Default]
can only accept values which are compile-time constants.
The
System.ComponentModel.DataAnnotations
annotations are not (and likely will not be) supported by this library. Defining the above attributes separately allows alignment with JSON Schema and separation of concerns between serialization and validation.
Simply add the attributes directly to the properties and the corresponding keywords will be added to the schema.
For properties typed with generic collections, like List<T>
, the schema will automatically generate an items
keyword and generate a schema for the indicated T
. If your T
is a numeric value or a string, then you can also apply the relevant attributes and they’ll be applied in the items
subschema.
For example, this object:
1
2
3
4
5
6
class MyClass
{
[UniqueItems(true)]
[Minimum(10)]
public List<int> MyList{ get; set; }
}
will be translated to this schema:
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"type": "object",
"properties": {
"MyList": {
"type": "array",
"items": {
"type": "integer",
"minimum": 10
},
"uniqueItems": true
}
}
}
The minimum
is applied to the items
because that keyword is not relevant for an array.
This means that the generator will have trouble determining where to apply keywords to properties like
List<List<T>>
because the attributes could be relevant for both the outer and inner lists.
The generator also supports these .Net-defined attributes:
JsonPropertyName
- supports custom property naming (more on naming below)JsonNumberHandling
* - supports allowing numeric values in strings or only as numbers as well as allowing theNaN
,Infinity
, and-Infinity
values.JsonIgnore
* - ignores a property or an enum member
* These attributes were introduced with .Net 5. The .Net Standard 2.0 version of the library also provides a definition for them.
The generator will handle most common types:
- numeric types (
int
,decimal
, etc.) bool
string
- enumerations (mapped to strings)
Uri
JsonPointer
(from JsonPointer.Net)JsonObject
,JsonArray
, &JsonValue
(from namespaceSystem.Text.Json.Nodes
)Guid
DateTime
- collections (
IEnumerable<T>
) - string-keyed dictionaries (
Dictionary<string, TValue>
) - enumeration-keyed dictionaries (keys are mapped to strings)
- POCOs
For POCOs, read-only properties and fields will be marked with a readOnly
keyword, and write-only properties (those with only a setter) will be marked with a writeOnly
keyword. These behaviors can be overridden by applying the appropriate keyword with a false
value.
Lastly, property names will either be listed as declared in code (default) or sorted by name. This is controlled via the SchemaGeneratorConfiguration.PropertyOrder
property.
Setting identifiers and referencing external schemas
In JSON Schema, the $id
keyword is the primary way to create an identifier for a schema. To create an identifier for a .Net type, you’ll use the [Id]
attribute along with a URI. This has two effects:
- If the attribute is found on the root type (the type used in the
.FromType<T>()
call), then the$id
keyword will be added to the schema. - If the attribute is found on a type used for a property, then a reference (
$ref
) will be created.
For example, let’s look at these classes:
1
2
3
4
5
6
7
8
9
10
11
[Id("https://docs.json-everything.net/foo")]
class Foo
{
public Bar Value { get; set; }
}
[Id("https://docs.json-everything.net/bar")]
class Bar
{
public int Number { get; set; }
}
When we call .FromType<Foo>()
, the following schema will be generated:
1
2
3
4
5
6
7
{
"$id": "https://docs.json-everything.net/foo",
"type": "object",
"properties": {
"Value": { "$ref": "https://docs.json-everything.net/bar" }
}
}
Notice that the attribute on Foo
was converted to an $id
keyword, but the attribute on Bar
was used in a reference.
Another way to apply references is through the configuration’s ExternalReferences
property. This property is a mapping that allows you to provide an $id
URI for a given type, and can be useful for when you don’t have the ability to modify a type, but you want to create a reference to it.
The
ExternalReferences
configuration will override any[Id]
attributes.
1
2
3
4
5
6
7
var config = new SchemaGenerationConfiguration
{
ExternalReferences =
{
[typeof(DateTime)] = "https://docs.json-everything.net/date-time"
}
}
Now when we generate a schema for
1
2
3
4
5
class Person
{
// ...
public DateTime BirthDate { get; set; }
}
we’ll get a reference for DateTime
instead of a schema.
1
2
3
4
5
6
7
{
"type": "object",
"properties": {
// ...
"BirthDate": { "$ref": "https://docs.json-everything.net/date-time" }
}
}
XML comment support
In addition to the explicit attributes above, the XML comment <Summary>
element can be configured to render to a description
keyword. Because .Net saves this information into an external XML file instead of into the reflection data, you’ll need to have a configuration object and register the XML filename.
1
2
3
4
5
6
var config = new SchemaGeneratorConfiguration();
// MyModel is any type from the assembly. A single registration will
// cover the entire assembly.
config.RegisterXmlCommentFile<MyModel>("MyAssembly.xml");
var schema = new JsonSchemaBuilder.FromType<MyModel>(config).Build();
Explicitly using the
[Description]
attribute will override XML comments, and XML comments on members will override XML comments on types.
It has been noted by a user that this extension method can be useful if the XML comments file is adjacent to the assembly in the file system.
1 2 3 4 public static void RegisterXmlCommentFile<T>(this SchemaGeneratorConfiguration configuration) { configuration.RegisterXmlCommentFile<T>(typeof(T).Assembly.Location.Replace(".dll", ".xml")) }This will likely be added in a future version.
Nullability
There is a discrepancy between how .Net does validation and how JSON Schema does validation that centers primarily around nullable types and the [Required]
attribute.
Those familiar with .Net validation will recognize that having [Required]
on your models will also protect against null values when deserializing. However, JSON Schema separates these two concepts, and this library strives to align with JSON Schema in order to give the most flexibility.
To this end, the [Required]
attribute will only be represented in generated schemas in the required
keyword.
However, for nullable types, it may or may not be appropriate to include null
in the type
keyword. JsonSchema.Net.Generation controls this behavior via the SchemaGeneratorConfiguration.Nullability
option with individual properties being overrideable via the [Nullable(bool)]
attribute.
There are four options:
Disabled
- This is the default. The resulting schemas will not havenull
in thetype
keyword unless[Nullable(true)]
is used.AllowForNullableValueTypes
- This will addnull
to thetype
keyword for nullable value types (i.e.Nullable<T>
) unless[Nullable(false)]
is used.AllowForReferenceTypes
- This will addnull
to thetype
keyword for reference types unless[Nullable(false)]
is used.AllowForAllTypes
- This is a combination of the previous two and will addnull
to the type keyword for any type unless[Nullable(false)]
is used.
This library cannot detect whether the consuming code has nullable reference types enabled. Therefore all reference types are considered nullable.
The library makes a distinction between nullable value types and reference types because value types must be explicitly nullable. This differs from reference types which are implicitly nullable, and there’s not a way (via the type itself) to make a reference type non-nullable.
Property naming
In addition to the [JsonPropertyName]
attribute, SchemaGeneratorConfiguration.PropertyNameResolver
allows you to define a custom method for altering property names from your code into the schema. The system will adjust property names accordingly.
SchemaGeneratorConfiguration config = new()
{
// All property names will be lowercase!
PropertyNameResolver = x => x.Name.ToLower()
};
For your convenience, the PropertyNameResolvers
static class defines a few commonly-used conventions:
ResolvedName | Example |
---|---|
PropertyNameResolvers.CamelCase | camelCase |
PropertyNameResolvers.PascalCase | PascalCase |
PropertyNameResolvers.KebabCase | kebab-case |
PropertyNameResolvers.UpperKebabCase | UPPER-KEBAB-CASE |
PropertyNameResolvers.SnakeCase | snake_case |
PropertyNameResolvers.UpperSnakeCase | UPPER_SNAKE_CASE |
They can be applied directly to the configuration property:
1
2
3
4
SchemaGeneratorConfiguration config = new()
{
PropertyNameResolver = PropertyNameResolvers.CamelCase
};
Note that the [JsonPropertyName]
attribute takes precedence, so you can still use this to get custom naming when you’ve configured a method.
Additional built-in support
There are a couple advanced features that bear mentioning.
First, the system does have some loop detection logic in order to support self-referencing or loop-referencing types.
Secondly, the system will collect common subschemas into a $defs
keyword at the root. Identification of a subschema is by its type and the collection of attributes it is processed with. The locations with these common subschemas will be replaced by a $ref
that points to the appropriate entry in $defs
.
Generating a properly descriptive-while-terse name is hard. This library makes a fair attempt at it, generating names like myType
for MyType
and arrayOfInteger
for int[]
or List<int>
. If this proves insufficient for your needs, implement your own naming as an ITypeNameGenerator
and assign it to SchemaGenerationContextOptimizer.TypeNameGenerator
.
If you only want to handle specific types in your generator and are happy with the library’s generation for others, simply return null from your generator and the library’s generation will be used.
Extending support
The above will work most of the time, but occasionally you may find that you need some additional support. Happily, the library is configured for you to provide that support yourself.
There are four areas that can be utilized in order to get the results you’re after.
- Generators
- Intents
- Attributes
- Refiners
These do not all need to be implemented.
Generators
These are the first phase of generation. When encountering a type, the system will find the first registered generator that can handle that type. The generator then creates keyword intents (see “Intents” below). The supported types list above is merely a list of the built-in generators.
To create a new generator, you’ll need to implement the ISchemaGenerator
interface and register it using the GeneratorRegistry.Register()
static method. This will insert your generator at the top of the list so that it has priority.
This means that the order your generators are registered is important: last one wins. So if you want one generator to have priority over another, register the higher priority one last.
This class doesn’t need to be complex. Here’s the implementation for the BooleanSchemaGenerator
:
1
2
3
4
5
6
7
8
9
10
11
12
internal class BooleanSchemaGenerator : ISchemaGenerator
{
public bool Handles(Type type)
{
return type == typeof(bool);
}
public void AddConstraints(SchemaGeneratorContextBase context)
{
context.Intents.Add(new TypeIntent(SchemaValueType.Boolean));
}
}
Very simple. It says that it handles booleans and then it does.
To explain how it does, we need to discuss intents.
The Context Object
The context holds all of the data you need to determine which intents need to be applied. It is defined by a base class, SchemaGeneratorContextBase
, and two derivations, TypeGenerationContext
and MemberGenerationContext
.
TypeGenerationContext
represents generation of just a type (including attributes present on the type itself), whereas MemberGenerationContext
represents generation of an object member, which will have a type (and its attributes) and possibly additional attributes as a member.
MemberGenerationContext
will only be created if there are handled attributes; attributes which are unhandled will be ignored, so two properties with the same type, but different sets of unhandled custom attributes will receive the same context object. See the Attributes section below for more on handling custom attributes.
The data exposed by contexts are:
Type
- the type for which a schema is being generatedReferenceCount
- the number of times this context has been usedIntents
- the collection of intents that represent this typeHash
- a hash value that can be used to identify this object
MemberGenerationContext
also defines:
BasedOn
- a context on which this context buildsAttributes
- additional attributes defined on the member
Intents
The JsonSchema
type and its keywords are immutable. So even if we could get to the keyword list inside the JsonSchemaBuilder
, we wouldn’t be able to edit the keywords to perform optimizations.
Happily, this has led to a new type that mimics keywords to hold their data until the keywords are ready to be built. That new type is the keyword intent, manifested in this library by the ISchemaKeywordIntent
interface.
The intent is keyword-specific and holds the data needed to actually build the keyword.
Once the system has analyzed the type and collected all of the intents, it extracts any common subschemas into a $defs
intent, then applies them to the builder that was provided at the beginning of the process. This application of the intents generates the keywords.
Most intents are pretty simple to implement. Here’s the TypeIntent
from above:
1
2
3
4
5
6
7
8
9
10
11
public class TypeIntent : ISchemaKeywordIntent
{
public SchemaValueType Type { get; set; }
public TypeIntent(SchemaValueType type)
{
Type = type;
}
public void Apply(JsonSchemaBuilder builder) => builder.Type(Type);
}
See? The Apply()
method just takes the builder, and adds a keyword with the data that it already collected. Pretty easy.
In v1.x of the library, implementing the equality methods (
Equals()
andGetHashCode()
) was required. As of v2.0, this is unnecessary.
This will work for most intents, but some keywords contain subschemas. For these, we don’t want to hold a subschema because, as mentioned before, they can’t be edited. Instead, we’ll hold a context object that represents the subschema: its type, attribute set, and the intents required to build it. For these intents, we also want to implement IContextContainer
. Here’s the ItemsIntent
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ItemsIntent : ISchemaKeywordIntent, IContextContainer
{
public SchemaGeneratorContextBase Context { get; private set; }
public ItemsIntent(SchemaGeneratorContextBase context)
{
Context = context;
}
public void Replace(int hashCode, SchemaGeneratorContextBase newContext)
{
if (Context.Hash == hashCode)
Context = newContext;
}
public void Apply(JsonSchemaBuilder builder) => builder.Items(Context.Apply());
}
As of v3, IContextContainer
requires only a single method: Replace()
.
Another method
GetContexts()
was used in v1.x but was no longer used as of v2.0 and was marked obsolete with v2.0.1.
Replace()
replaces a context with a given hash code with a new context. This is called when the system is creating $ref
intents that point to the new $defs
intent it’s building and distributing them throughout the context tree. Once all the $ref
s are distributed, the system will add the $defs
intent to the root context to be applied at the last step.
Generally intents for applicator keywords, which are keywords that have subschemas (anyOf
, allOf
, etc.), will need to implement this second interface. In most cases, you can just copy this code.
Attributes
The other source for intents are attributes. These are handled once the generator has completed adding the intents it needs to.
When processing, types and object properties are analyzed for the presence of any of the above attributes. Each of the attributes can then add its intents to the context.
To create your own attribute and have it processed by the system, it’ll need to implement IAttributeHandler<T>
as well.
The attribute itself is pretty simple. It’s just a class that inherits from Attribute
, implements IAttributeHandler<T>
, and carries some data. Here’s MaximumAttribute
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[AttributeUsage(AttributeTargets.Property)]
public class MaximumAttribute : Attribute, IAttributeHandler<MaximumAttribute>
{
public uint Value { get; }
public MaximumAttribute(uint value)
{
Value = value;
}
void IAttributeHandler.AddConstraints(SchemaGeneratorContextBase context)
{
if (!context.Type.IsNumber()) return;
context.Intents.Add(new MaximumIntent(Value));
}
}
The AddConstraints()
method works exactly the same as in the generator class. A key difference here is that you will need to guard against unrelated types. For instance, with MaximumAttribute
above, it doesn’t make sense to have maximum
on a non-numeric type, so we check it before adding the intent.
.IsNumber()
is an extension method onType
that determines if it’s a numeric type. It is defined in Json.More.Net. There are a few more of these helper extensions as well.
The occasion may arise where you want to handle an attribute that’s defined in some other assembly, and you can’t make it implement IAttributeHandler<T>
. For these cases, just implement the handler class, and then add it using one of the AttributeHandler.AddHandler()
static methods. A handler can be removed using the AttributeHandler.RemoveHandler<T>()
static method, passing the handler type for T
.
Some intents (e.g.
AnyOfIntent
) takeIEnumerable<ISchemaKeywordIntent[]>
. Note that this is a collection of intent arrays. In these cases, each array represents a separate subschema.The confusing bit is that these also have a
params
overload that appears to just takeISchemaKeywordIntent[]
. However, it works the same as the non-params
overload in that each array represents a subschema.
Refiners
Sometimes you may need to make minor adjustments to the generated schemas dynamically. For this you’ll need to create an implementation of ISchemaRefiner
.
Refiners are called after all intents have been generated for each type, recursively, throughout the process.
To implement a refiner, two methods will be needed:
bool ShouldRun(SchemaGeneratorContextBase)
which determines whether the refiner needs to run for the current generation iteration.void Run(SchemaGeneratorContextBase)
which makes whatever modifications are needed.
Remember that a this point, you’re stil working with intents. You can add new ones as well as modify or remove existing ones. You really have complete freedom within a refiner.
That’s it
That should get you generating schemas for all of your types. If you come up with ideas or implement your own extensions and think they’d be handy if they were built in, feel free to open up an issue or a pull request.