2026-11-02 18:23:19 +01:00
using System.Text ;
using System.Text.Json.Serialization ;
using System.Text.Json.Serialization.Metadata ;
2026-11-11 19:30:00 +01:00
using SharedTools.Text ;
2026-11-02 18:23:19 +01:00
namespace AIStudio.Tests.Serialization ;
/// <summary>
/// Describes the contract System.Text.Json uses for a type as text, so that two contracts can be compared and
/// a difference shows up readable in the message of a test.
/// </summary>
public static class JsonContractDescription
{
/// <summary>
/// Describes everything in a contract which decides what gets written and what a read accepts.
/// </summary>
/// <param name="typeInfo">The contract.</param>
/// <returns>One line for the type, and one line per property, polymorphism, and callback.</returns>
public static string Describe ( JsonTypeInfo typeInfo )
{
//
// For a struct, reflection takes the default of the struct and sets every property, while source generation
// passes the values to the constructor of a positional record struct. Both give the same struct, as long as
// no parameter has a default value of its own, which JsonDefaultsTests checks. A class with required
// properties is alike: source generation sets them while it creates the object, reflection right after.
// Hence, the description leaves out how such a type gets created:
//
var isCreatedWithItsValues = typeInfo . Kind is JsonTypeInfoKind . Object && ( typeInfo . Type . IsValueType || typeInfo . Properties . Any ( property => property . IsRequired ));
var creation = isCreatedWithItsValues
? "created with its values"
: $"converter {ConverterName(typeInfo.Converter)}, created {(typeInfo.CreateObject is null ? " otherwise " : " without arguments ")}" ;
var text = new StringBuilder ();
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $"{TypeName(typeInfo.Type)}: {typeInfo.Kind}, {creation}" );
text . AppendLineLF ( $" handling: numbers {typeInfo.NumberHandling}, unmapped members {typeInfo.UnmappedMemberHandling}, object creation {typeInfo.PreferredPropertyObjectCreationHandling}" );
text . AppendLineLF ( $" callbacks: serializing {typeInfo.OnSerializing is not null}, serialized {typeInfo.OnSerialized is not null}, deserializing {typeInfo.OnDeserializing is not null}, deserialized {typeInfo.OnDeserialized is not null}" );
2026-11-02 18:23:19 +01:00
if ( typeInfo . KeyType is { } keyType )
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $" key: {TypeName(keyType)}" );
2026-11-02 18:23:19 +01:00
if ( typeInfo . ElementType is { } elementType )
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $" element: {TypeName(elementType)}" );
2026-11-02 18:23:19 +01:00
if ( typeInfo . PolymorphismOptions is { } polymorphism )
{
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $" polymorphism: discriminator {polymorphism.TypeDiscriminatorPropertyName}, unrecognized ignored {polymorphism.IgnoreUnrecognizedTypeDiscriminators}, unknown types {polymorphism.UnknownDerivedTypeHandling}" );
2026-11-02 18:23:19 +01:00
foreach ( var derivedType in polymorphism . DerivedTypes )
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $" derived: {TypeName(derivedType.DerivedType)} as {derivedType.TypeDiscriminator}" );
2026-11-02 18:23:19 +01:00
}
foreach ( var property in typeInfo . Properties )
{
//
// An ignored property keeps its name in both contracts, but only reflection keeps its type:
//
if ( IsIgnored ( property ))
{
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $" property {property.Name}: ignored" );
2026-11-02 18:23:19 +01:00
continue ;
}
var setting = isCreatedWithItsValues
? $"set {property.Set is not null || property.AssociatedParameter is not null}"
: $"set {property.Set is not null}, constructor parameter {property.AssociatedParameter?.Name ?? " none "}" ;
2026-11-11 19:30:00 +01:00
text . AppendLineLF ( $" property {property.Name}: {TypeName(property.PropertyType)}, order {property.Order}, get {property.Get is not null}, {setting}, required {property.IsRequired}, extension data {property.IsExtensionData}, conditional {property.ShouldSerialize is not null}, converter {ConverterName(property.CustomConverter)}, nullable get {property.IsGetNullable}, nullable set {property.IsSetNullable}, numbers {property.NumberHandling}, object creation {property.ObjectCreationHandling}" );
2026-11-02 18:23:19 +01:00
}
return text . ToString ();
}
/// <summary>
/// Whether System.Text.Json neither writes nor reads a property, e.g., one marked with JsonIgnore.
/// </summary>
/// <param name="property">The property of a contract.</param>
public static bool IsIgnored ( JsonPropertyInfo property ) => property is { Get : null , Set : null , AssociatedParameter : null };
/// <summary>
/// Returns the name of a type the way C# writes it, e.g., Dictionary<String, Int32>.
/// </summary>
/// <param name="type">The type.</param>
public static string TypeName ( Type type )
{
var arity = type . Name . IndexOf ( '`' );
var name = arity < 0 ? type . Name : $"{type.Name[..arity]}<{string.Join(" , ", type.GetGenericArguments().Select(TypeName))}>" ;
return type is { IsNested : true , IsGenericParameter : false } ? $"{TypeName(type.DeclaringType!)}.{name}" : name ;
}
//
// Reflection reads the constructor parameters of a record through converters of its own: one for up to four
// parameters, and others whose names end with "WithReflection". Source generation reads them all with the
// converter for many parameters, which reads the same JSON. The same goes for immutable collections:
//
private static string ConverterName ( JsonConverter ? converter )
{
if ( converter is null )
return "none" ;
var name = converter . GetType (). Name ;
if ( name . Contains ( "ObjectWithParameterizedConstructorConverter" , StringComparison . Ordinal ))
return $"ObjectWithParameterizedConstructorConverter<{TypeName(converter.Type!)}>" ;
return TypeName ( converter . GetType ()). Replace ( "WithReflection" , string . Empty , StringComparison . Ordinal );
}
}