A custom System.Text.Json converter for ArchUnit that reads a JSON unit object, resolves its concrete type by a Kind discriminator (handling legacy "$kind" lowercase values), and deserializes into the claimed type or into an ArchOpaqueUnit when unknown. On write it ensures in-memory units get a claimed Kind if possible and then serializes the concrete runtime type.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sandbox;
namespace Sunless.Architecture;
// Reads a top-level unit into whatever type claims its kind, and into a bare carrier when nothing does.
//
// [JsonPolymorphic] cannot do this job: JsonDerivedType is an attribute on the BASE type, so core would have to
// name every module's unit type, and System.Text.Json throws on a discriminator it does not recognise with no
// read-side equivalent of JsonUnknownDerivedTypeHandling. After the split an unrecognised kind is the ordinary
// not-installed case, and throwing there means the plan reads null and the next commit writes an empty one over it.
public sealed class ArchUnitConverter : JsonConverter<ArchUnit>
{
// Only the base: asking for a subclass must deserialize normally or this converter calls itself forever.
public override bool CanConvert( Type type ) => type == typeof( ArchUnit );
public override ArchUnit Read( ref Utf8JsonReader reader, Type type, JsonSerializerOptions options )
{
using var document = JsonDocument.ParseValue( ref reader );
var element = document.RootElement;
var claimed = ArchKinds.Load().For( KindOf( element ) );
var resolved = claimed?.Payload;
if ( resolved is null || !typeof( ArchUnit ).IsAssignableFrom( resolved ) )
{
return Opaque( element, options );
}
return JsonSerializer.Deserialize( element.GetRawText(), resolved, options ) as ArchUnit;
}
public override void Write( Utf8JsonWriter writer, ArchUnit value, JsonSerializerOptions options )
{
// A unit built in memory by something that forgot to say what it is would otherwise go to disk unnamed and
// read back opaque, which loses it on the round trip it was meant to survive.
if ( !value.Kind.Known && ArchKinds.Load().Claiming( value.GetType() ) is { } manifest )
{
value.Kind = manifest.Kind;
}
JsonSerializer.Serialize( writer, value, value.GetType(), options );
}
// Version 3 wrote a lower-case polymorphic discriminator; every kind elsewhere in the document is the
// capitalised ident, so the two spellings meet here rather than anywhere a module can see.
static ArchKind KindOf( JsonElement element )
{
if ( element.TryGetProperty( "Kind", out var named ) && named.ValueKind == JsonValueKind.String )
{
return new ArchKind( named.GetString() ?? "" );
}
if ( !element.TryGetProperty( "$kind", out var legacy ) || legacy.ValueKind != JsonValueKind.String )
{
return ArchKind.None;
}
return legacy.GetString() switch
{
"building" => ArchKind.Building,
"road" => ArchKind.Road,
var other => new ArchKind( other ?? "" )
};
}
static ArchUnit Opaque( JsonElement element, JsonSerializerOptions options )
{
var carrier = JsonSerializer.Deserialize<ArchOpaqueUnit>( element.GetRawText(), options ) ?? new ArchOpaqueUnit();
carrier.Kind = KindOf( element );
return carrier;
}
}