< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.AsyncResponseJson
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseJson.cs
Line coverage
78%
Covered lines: 48
Uncovered lines: 13
Coverable lines: 61
Total lines: 212
Line coverage: 78.6%
Branch coverage
100%
Covered branches: 10
Total branches: 10
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
get_Resolver()100%11100%
get_Default()100%11100%
get_CaseInsensitive()100%11100%
get_IgnoreNullWrites()100%11100%
Serialize(...)100%11100%
Serialize(...)100%1150%
Deserialize(...)100%11100%
SerializeToUtf8Bytes(...)100%1150%
DeserializeCaseInsensitive(...)100%11100%
GetTypeInfo(...)100%11100%
GetTypeInfo(...)100%2118.18%
IsRegistrationGuidance(...)100%11100%
MemberRegistrationGuidance(...)100%11100%
CreateReflectionResolverIfEnabled()100%22100%
CreateReflectionResolver()100%11100%
GetTypeInfo(...)100%88100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseJson.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Text.Json;
 3using System.Text.Json.Serialization;
 4using System.Text.Json.Serialization.Metadata;
 5
 6namespace AsyncResponse;
 7
 8/// <summary>
 9/// The library's single JSON entry point: every internal serialization site goes through these
 10/// options and helpers instead of the reflection-based <see cref="JsonSerializer"/> overloads, so
 11/// the packages carry no trim/AOT warnings (IL2026/IL3050).
 12/// <para>
 13/// Metadata resolution order: library wire types (<see cref="AsyncResponseJsonContext"/>, source
 14/// generated) → user-registered resolvers (<see cref="AsyncResponseJsonSerialization"/>) → the
 15/// runtime reflection resolver when the app has it enabled
 16/// (<see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, true for every non-trimmed app).
 17/// Behavior for existing apps is therefore unchanged; trimmed/AOT apps must register their payload
 18/// types and otherwise get an actionable error naming the type.
 19/// </para>
 20/// </summary>
 21internal static class AsyncResponseJson
 22{
 1723    private static readonly IJsonTypeInfoResolver? _reflectionResolver = CreateReflectionResolverIfEnabled();
 24
 25    /// <summary>The full resolver chain, for options that need to prepend their own metadata.</summary>
 17526    public static IJsonTypeInfoResolver Resolver { get; } = new ChainResolver();
 27
 28    /// <summary>Serializer-default settings (case-sensitive, write nulls) over the resolver chain.</summary>
 4836629    public static JsonSerializerOptions Default { get; } = new() { TypeInfoResolver = Resolver };
 30
 31    /// <summary>
 32    /// Case-insensitive property matching, for broker-ingress reads — the historical behavior of
 33    /// the library's defensive deserialization paths.
 34    /// </summary>
 5339235    public static JsonSerializerOptions CaseInsensitive { get; } = new()
 1736    {
 1737        TypeInfoResolver = Resolver,
 1738        PropertyNameCaseInsensitive = true
 1739    };
 40
 41    /// <summary>Omits null properties on write; used for the durable-flow ledger.</summary>
 4588742    public static JsonSerializerOptions IgnoreNullWrites { get; } = new()
 1743    {
 1744        TypeInfoResolver = Resolver,
 1745        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 1746    };
 47
 48    /// <summary>Serializes with default settings, resolving metadata through the chain.</summary>
 49    public static string Serialize<T>(T value)
 50    {
 51        try
 52        {
 1359953            return JsonSerializer.Serialize(value, GetTypeInfo<T>(Default));
 54        }
 255        catch (NotSupportedException ex) when (!IsRegistrationGuidance(ex))
 56        {
 57            // The serializer throws its own NotSupportedException when it hits an unregistered
 58            // type while WALKING the graph — an object-typed member (CallbackParam.Value) whose
 59            // runtime type, an enum say, has no source-generated metadata. That throw never
 60            // passes through GetTypeInfo below, so without this catch the actionable
 61            // register-your-type guidance is skipped exactly where it is hardest to diagnose.
 262            throw MemberRegistrationGuidance(typeof(T), ex);
 63        }
 1359564    }
 65
 66    /// <summary>
 67    /// Serializes by the value's runtime type — the counterpart of the reflection-based
 68    /// <c>JsonSerializer.Serialize(value, value.GetType())</c> pattern.
 69    /// </summary>
 70    public static string Serialize(object value, Type runtimeType)
 71    {
 72        try
 73        {
 7174            return JsonSerializer.Serialize(value, GetTypeInfo(runtimeType, Default));
 75        }
 076        catch (NotSupportedException ex) when (!IsRegistrationGuidance(ex))
 77        {
 078            throw MemberRegistrationGuidance(runtimeType, ex);
 79        }
 6980    }
 81
 82    /// <summary>
 83    /// Deserializes with default settings (case-sensitive property matching, like the bare
 84    /// <c>JsonSerializer.Deserialize&lt;T&gt;(json)</c> these callsites used before).
 85    /// </summary>
 86    public static T? Deserialize<T>(string json)
 687        => JsonSerializer.Deserialize(json, GetTypeInfo<T>(Default));
 88
 89    /// <summary>
 90    /// Serializes with default settings straight to UTF-8 bytes — the wire form without the
 91    /// UTF-16 string detour. Used by the in-memory channel's per-waiter wire materialization,
 92    /// where the string round-trip doubled the publish-path allocations.
 93    /// </summary>
 94    internal static byte[] SerializeToUtf8Bytes<T>(T value)
 95    {
 96        try
 97        {
 3464298            return JsonSerializer.SerializeToUtf8Bytes(value, GetTypeInfo<T>(Default));
 99        }
 0100        catch (NotSupportedException ex) when (!IsRegistrationGuidance(ex))
 101        {
 0102            throw MemberRegistrationGuidance(typeof(T), ex);
 103        }
 34640104    }
 105
 106    /// <summary>
 107    /// Case-insensitive UTF-8 deserialization — the byte-level twin of the string/JsonElement
 108    /// conversion path in <c>ReflectionExtensions.ConvertTo</c> (which resolves through
 109    /// <see cref="CaseInsensitive"/>), so switching a call site between the two never changes
 110    /// property matching.
 111    /// </summary>
 112    internal static T? DeserializeCaseInsensitive<T>(ReadOnlySpan<byte> utf8Json)
 30876113        => JsonSerializer.Deserialize(utf8Json, GetTypeInfo<T>(CaseInsensitive));
 114
 115    /// <summary>Resolves typed metadata for <typeparamref name="T"/> from <paramref name="options"/>.</summary>
 116    public static JsonTypeInfo<T> GetTypeInfo<T>(JsonSerializerOptions options)
 138080117        => (JsonTypeInfo<T>)GetTypeInfo(typeof(T), options);
 118
 119    /// <summary>
 120    /// Resolves metadata for <paramref name="type"/> from <paramref name="options"/>, translating
 121    /// the serializer's "no metadata" failure into guidance to register a context.
 122    /// </summary>
 123    public static JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
 124    {
 125        try
 126        {
 127            // Collectible-context (plugin) types are deliberately NOT special-cased here: the
 128            // runtime's serializer pins a collectible AssemblyLoadContext through process-wide
 129            // static caches (member accessors) no matter which JsonSerializerOptions instance is
 130            // used — verified empirically against .NET 10 with a fresh options per call — so a
 131            // per-call options copy would add cost without restoring unloadability. The library's
 132            // own type caches skip collectible types (see UnresolvableTypeNames call sites); the
 133            // supported plugin pattern keeps payload/service CONTRACT types in a non-collectible
 134            // contracts assembly, under which nothing here ever sees a collectible type. See
 135            // AsyncResponseTypeResolution docs.
 152982136            return options.GetTypeInfo(type);
 137        }
 0138        catch (NotSupportedException ex)
 139        {
 0140            var guidance = new NotSupportedException(
 0141                $"No JSON metadata is available for '{type}'. This app runs without reflection-based " +
 0142                "System.Text.Json (trimmed/Native AOT), so payload types must be registered at startup: " +
 0143                $"declare [JsonSerializable(typeof({type.Name}))] on a JsonSerializerContext and call " +
 0144                $"{nameof(AsyncResponseJsonSerialization)}.{nameof(AsyncResponseJsonSerialization.RegisterResolver)}(You
 0145                ex);
 0146            guidance.Data[RegistrationGuidanceMarker] = true;
 0147            throw guidance;
 148        }
 152982149    }
 150
 151    /// <summary>Marks the guidance-carrying NotSupportedException so wrappers never re-wrap it.</summary>
 152    private const string RegistrationGuidanceMarker = "asyncresponse.registration_guidance";
 153
 154    private static bool IsRegistrationGuidance(NotSupportedException ex)
 2155        => ex.Data.Contains(RegistrationGuidanceMarker);
 156
 157    /// <summary>
 158    /// Guidance for a metadata failure raised mid-serialization by a member's RUNTIME type (the
 159    /// root type itself resolved fine); the inner exception names the exact path.
 160    /// </summary>
 161    private static NotSupportedException MemberRegistrationGuidance(Type rootType, NotSupportedException inner)
 162    {
 2163        var guidance = new NotSupportedException(
 2164            $"A value reached through '{rootType}' has a runtime type with no JSON metadata — the inner exception names 
 2165            "path (typically an object-typed member such as a worker-call argument whose runtime type is an enum or othe
 2166            "unregistered type). This app runs without reflection-based System.Text.Json (trimmed/Native AOT), so that t
 2167            "must be registered at startup: declare [JsonSerializable(typeof(...))] on a JsonSerializerContext and call 
 2168            $"{nameof(AsyncResponseJsonSerialization)}.{nameof(AsyncResponseJsonSerialization.RegisterResolver)}(YourCon
 2169            inner);
 2170        guidance.Data[RegistrationGuidanceMarker] = true;
 2171        return guidance;
 172    }
 173
 174    private static IJsonTypeInfoResolver? CreateReflectionResolverIfEnabled()
 175    {
 17176        if (!JsonSerializer.IsReflectionEnabledByDefault)
 2177            return null;
 178
 15179        return CreateReflectionResolver();
 180
 181        [UnconditionalSuppressMessage("Trimming", "IL2026",
 182            Justification = "Reachable only when JsonSerializer.IsReflectionEnabledByDefault is true; trimmed and AOT bu
 183        [UnconditionalSuppressMessage("AOT", "IL3050",
 184            Justification = "Same guard: the feature switch is false under Native AOT, so the reflection resolver is nev
 15185        static IJsonTypeInfoResolver CreateReflectionResolver() => new DefaultJsonTypeInfoResolver();
 186    }
 187
 188    /// <summary>
 189    /// Library wire types first (their contract is fixed and must not be overridden), then
 190    /// user-registered resolvers, then the reflection fallback when available. Consulting the
 191    /// live registration snapshot per lookup lets startup-time registration order be forgiving;
 192    /// results are cached per options instance by the serializer itself.
 193    /// </summary>
 194    private sealed class ChainResolver : IJsonTypeInfoResolver
 195    {
 196        public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
 197        {
 2304198            var info = ((IJsonTypeInfoResolver)AsyncResponseJsonContext.Default).GetTypeInfo(type, options);
 2304199            if (info is not null)
 819200                return info;
 201
 3131202            foreach (var resolver in AsyncResponseJsonSerialization.Resolvers)
 203            {
 87204                info = resolver.GetTypeInfo(type, options);
 87205                if (info is not null)
 13206                    return info;
 207            }
 208
 1472209            return _reflectionResolver?.GetTypeInfo(type, options);
 210        }
 211    }
 212}