Schema and contract inspection
ShapeShift can describe what it would write without writing anything. Two layers are available:
- A format-neutral contract model (
ShapeShift.Schema) produced from PolyType shapes and the serializer's own converter selection and policies. - A JSON Schema projection (
ShapeShift.Json) that renders any contract as a standards-based JSON Schema 2020-12 document, optionally annotated with MessagePack-specific keywords.
Both layers are NativeAOT-safe and require no reflection: the contract is built by the same shape visitor machinery that builds converters.
The contract model
ShapeShiftSerializer<TEncoder, TDecoder>.GetContract returns a DataContract
describing the wire shape of a type for that serializer instance, honoring the
naming policy, default-value policies, enum and surrogate settings, and any
custom converters that have been registered.
internal static void DescribeContract()
{
JsonSerializer serializer = new();
DataContract contract = serializer.GetContract<Product>();
foreach (PropertyContract property in ((ObjectContract)contract).Properties)
{
Console.WriteLine($"{property.Name} ({property.Type.Kind}) required={property.IsRequired} nullable={property.IsNullable}");
}
}
DataContract.Kind discriminates the node type:
DataContractKind |
Concrete type | Wire shape |
|---|---|---|
Primitive |
PrimitiveContract |
A single scalar token, identified by PrimitiveDataType. |
Object |
ObjectContract |
A string-keyed map of declared properties, or (when Encoding is Positional) a vector whose elements are located by PropertyContract.Position. |
Sequence |
SequenceContract |
A vector of elements; IsSet marks set semantics. |
RectangularArray |
RectangularArrayContract |
A two-element [dimensions, row-major values] envelope. |
Map |
MapContract |
A string-keyed map, or a vector of [key, value] pairs. |
Enum |
EnumContract |
A name string or the underlying numeric value. |
Optional |
OptionalContract |
The underlying value, or null. |
Union |
UnionContract |
A two-element [discriminator, value] array. |
Surrogate |
SurrogateContract |
The surrogate type's contract. |
Dynamic |
DynamicContract |
Any value (ShapeShiftValue and format DOM types). |
Undocumented |
UndocumentedContract |
A custom converter that did not describe itself. |
Contracts are immutable reference types (not records) because the graph may be
cyclic: a recursive type's contract references itself. Each type is described
exactly once per serializer instance, so reference equality is meaningful and
can be used to detect recursion and shared definitions. ReferencedContracts
enumerates a node's immediate children for graph walks.
Contract generation is not supported when PreserveReferences is enabled: the
reference-tracking envelope is a runtime protocol rather than a static shape, so
GetContract throws NotSupportedException instead of publishing a schema that
payloads would not satisfy.
Nullability, requiredness, and default-value policies
PropertyContract reports the serializer's actual behavior rather than the CLR
declaration alone:
Nameis the wire name after the naming policy;DeclaredNameis the shape-declared name (which honorsPropertyShapeAttribute.Name);MemberNameis the CLR property or field name, which neither the naming policy nor an alias ever changes.MemberNameis whatGetPathmatches a member reached through an expression against; it isnullfor an entry with no corresponding CLR member, such as a constructor parameter that has no matching property.IsRequiredistrueonly when the deserializer will fault if the property is missing, so it accounts forDeserializeDefaultValuesPolicy.AllowMissingValuesForRequiredProperties.IsNullableisfalseonly when the deserializer will reject an explicit null, so it accounts forDeserializeDefaultValuesPolicy.AllowNullValuesForNonNullablePropertiesand the nullability of both the getter and the setter or constructor parameter.IsAlwaysWrittenisfalsewhenSerializeDefaultValuesPolicymay omit the property.DefaultValuecarries the value that would be omitted when the policy makes it available.Positionis set only for a member of a positionally encoded object, where it is the permanent index the member occupies in the vector.
Object encodings
ObjectContract.Encoding records whether an object is written as a map of named
properties (the default for every format) or as a vector whose elements are
located by position. Only a format that offers a positional mode ever produces
the latter; today that is
MessagePack's [MsgPackArrayContract].
In a positional contract every PropertyContract carries a Position, and the
positions no property claims are the holes left by retired members, which
writers fill with null placeholders.
Custom converters
Custom converters may override GetContract to describe themselves. The
ContractContext<TEncoder, TDecoder> argument exposes the shape being described
(when there is one) and can build contracts for nested types, so a converter
that wraps a value can describe the wrapper precisely.
internal class HexInt32Converter : ShapeShiftConverter<int, JsonEncoder, JsonDecoder>
{
public override int Read(ref JsonDecoder decoder, SerializationContext<JsonEncoder, JsonDecoder> context)
=> Convert.ToInt32(decoder.ReadString(), 16);
public override void Write(ref JsonEncoder encoder, in int value, SerializationContext<JsonEncoder, JsonDecoder> context)
=> encoder.WriteValue(value.ToString("X8", System.Globalization.CultureInfo.InvariantCulture));
// Without this override the schema would say "this value is undocumented"
// rather than describing a shape the converter does not actually produce.
public override DataContract? GetContract(ContractContext<JsonEncoder, JsonDecoder> context)
=> new PrimitiveContract(typeof(int), PrimitiveDataType.String);
}
A converter that does not override GetContract yields an
UndocumentedContract naming the converter type. This is deliberate: an
undocumented contract is honest, whereas inferring the shape from the converter's
CLR type would routinely be wrong, and a wrong schema is worse than an absent
one.
JSON Schema projection
JsonSerializer.GetJsonSchema projects the contract to a
JSON Schema 2020-12
document as a System.Text.Json.Nodes.JsonObject.
internal static void GenerateJsonSchema()
{
JsonSerializer serializer = new();
JsonObject schema = serializer.GetJsonSchema<Product>();
Console.WriteLine(Render(schema));
}
JsonSchema.Create accepts any DataContract, so a contract obtained from a
different serializer (for example, MsgPackSerializer) can be projected too.
Recursive contracts, and contracts referenced more than once, are emitted into
$defs and referenced with $ref. Definition names are derived from the CLR
type name and de-duplicated.
JSON-specific representation choices
The projection documents the choices that ShapeShift.Json actually makes:
| Value | JSON Schema |
|---|---|
DateTime |
{"type":"string","format":"date-time"} (ISO 8601) |
DateTimeOffset |
[date-time, offsetMinutes], with the offset constrained to ±840 |
TimeSpan |
{"type":"string"} with a $comment; the invariant c format is not an ISO 8601 duration, so no format keyword is claimed |
byte[] / binary |
{"type":"string","contentEncoding":"base64"} |
Int128, UInt128, BigInteger |
{"type":"integer"}; the fixed-width forms carry minimum/maximum bounds |
decimal |
{"type":"number"} with a $comment; JSON keeps the full precision as a number literal, though an IEEE 754 consumer may not |
char |
{"type":"string","minLength":1,"maxLength":1} |
Rune |
{"type":"integer"} (a Unicode scalar value) |
| Fixed-width integers | {"type":"integer"} with minimum/maximum bounds |
float, double, Half |
{"type":"number"}, widened to anyOf with "NaN"/"Infinity"/"-Infinity" only when AllowNamedFloatingPointValues is set |
| String-keyed dictionaries | {"type":"object","additionalProperties":<value>} |
| Other dictionaries | An array of two-element [key, value] arrays |
| Rectangular arrays | A two-element [dimensions, row-major values] array |
| Enums | anyOf of the name form and the numeric form, with the serialized form first |
| Unions | oneOf over [discriminator, value] tuples; the base case uses a null discriminator |
| Dynamic values | An unconstrained schema (true-equivalent) with a $comment |
Objects declare additionalProperties: false because the deserializer rejects
unknown properties, unless the type declares an extension-data member, in which
case the keyword is omitted and a $comment explains why. readOnly and
writeOnly reflect properties that cannot be written or read respectively.
Non-standard information uses x--prefixed keywords, which validators ignore:
x-shapeshift-undocumented, x-shapeshift-converter,
x-shapeshift-surrogate-for, x-shapeshift-enum-serialized-as,
x-shapeshift-enum-flags, and x-shapeshift-max-binary-length.
Security limits
Schemas are portable by default and therefore carry no size limits. Pass
JsonSchemaLimits to emit maxItems, maxProperties, maxLength, and
x-shapeshift-max-binary-length matching a SerializationContext's configured
limits, which lets a gateway reject oversized documents before the serializer
sees them.
internal static void GenerateSchemaWithLimits()
{
JsonSerializer serializer = new();
SerializationContext<JsonEncoder, JsonDecoder> context = new();
JsonObject schema = serializer.GetJsonSchema<Product>(new JsonSchemaOptions
{
Limits = JsonSchemaLimits.FromContext(context),
});
Console.WriteLine(Render(schema));
}
MessagePack annotations
JsonSchemaProfile.MessagePack projects the same contract with MessagePack's
representation choices and annotations. JSON Schema remains the description
language; the x-msgpack-type and x-msgpack-extension keywords record the
MessagePack token family and extension type code that would actually be used.
internal static void GenerateMessagePackAnnotatedSchema()
{
MsgPackSerializer serializer = new();
JsonObject schema = JsonSchema.Create(
serializer.GetContract<Product>(),
new JsonSchemaOptions { Profile = JsonSchemaProfile.MessagePack });
Console.WriteLine(Render(schema));
}
Differences from the JSON profile:
- Binary uses the
binfamily (x-msgpack-type: bin) rather than base64 text. DateTimeuses the standard timestamp extension (x-msgpack-extension: -1).decimal,Int128,UInt128,BigInteger, andTimeSpanuse the reserved ShapeShift extension codes documented in MessagePack extension types.TimeSpanis an integer tick count rather than a string.- Named floating-point values are never offered, because MessagePack encodes non-finite values natively.
- Objects, arrays, and maps carry
x-msgpack-typedescribing the token family.
A positionally encoded object is projected as {"type":"array"} with one
prefixItems entry per position. Each entry carries the member's name as its
title; a position no member claims is described as {"type":"null"} with a
$comment explaining that writers emit null there and readers ignore whatever
they find. minItems is the number of positions up to and including the last
required member, because a shorter array cannot supply it.
Sample
The complete sample used above is SchemaGeneration.cs.