| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using System.Text.Json.Serialization; |
| | | 4 | | using System.Text.Json.Serialization.Metadata; |
| | | 5 | | |
| | | 6 | | namespace 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> |
| | | 21 | | internal static class AsyncResponseJson |
| | | 22 | | { |
| | 17 | 23 | | private static readonly IJsonTypeInfoResolver? _reflectionResolver = CreateReflectionResolverIfEnabled(); |
| | | 24 | | |
| | | 25 | | /// <summary>The full resolver chain, for options that need to prepend their own metadata.</summary> |
| | 175 | 26 | | public static IJsonTypeInfoResolver Resolver { get; } = new ChainResolver(); |
| | | 27 | | |
| | | 28 | | /// <summary>Serializer-default settings (case-sensitive, write nulls) over the resolver chain.</summary> |
| | 48366 | 29 | | 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> |
| | 53392 | 35 | | public static JsonSerializerOptions CaseInsensitive { get; } = new() |
| | 17 | 36 | | { |
| | 17 | 37 | | TypeInfoResolver = Resolver, |
| | 17 | 38 | | PropertyNameCaseInsensitive = true |
| | 17 | 39 | | }; |
| | | 40 | | |
| | | 41 | | /// <summary>Omits null properties on write; used for the durable-flow ledger.</summary> |
| | 45887 | 42 | | public static JsonSerializerOptions IgnoreNullWrites { get; } = new() |
| | 17 | 43 | | { |
| | 17 | 44 | | TypeInfoResolver = Resolver, |
| | 17 | 45 | | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |
| | 17 | 46 | | }; |
| | | 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 | | { |
| | 13599 | 53 | | return JsonSerializer.Serialize(value, GetTypeInfo<T>(Default)); |
| | | 54 | | } |
| | 2 | 55 | | 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. |
| | 2 | 62 | | throw MemberRegistrationGuidance(typeof(T), ex); |
| | | 63 | | } |
| | 13595 | 64 | | } |
| | | 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 | | { |
| | 71 | 74 | | return JsonSerializer.Serialize(value, GetTypeInfo(runtimeType, Default)); |
| | | 75 | | } |
| | 0 | 76 | | catch (NotSupportedException ex) when (!IsRegistrationGuidance(ex)) |
| | | 77 | | { |
| | 0 | 78 | | throw MemberRegistrationGuidance(runtimeType, ex); |
| | | 79 | | } |
| | 69 | 80 | | } |
| | | 81 | | |
| | | 82 | | /// <summary> |
| | | 83 | | /// Deserializes with default settings (case-sensitive property matching, like the bare |
| | | 84 | | /// <c>JsonSerializer.Deserialize<T>(json)</c> these callsites used before). |
| | | 85 | | /// </summary> |
| | | 86 | | public static T? Deserialize<T>(string json) |
| | 6 | 87 | | => 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 | | { |
| | 34642 | 98 | | return JsonSerializer.SerializeToUtf8Bytes(value, GetTypeInfo<T>(Default)); |
| | | 99 | | } |
| | 0 | 100 | | catch (NotSupportedException ex) when (!IsRegistrationGuidance(ex)) |
| | | 101 | | { |
| | 0 | 102 | | throw MemberRegistrationGuidance(typeof(T), ex); |
| | | 103 | | } |
| | 34640 | 104 | | } |
| | | 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) |
| | 30876 | 113 | | => 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) |
| | 138080 | 117 | | => (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. |
| | 152982 | 136 | | return options.GetTypeInfo(type); |
| | | 137 | | } |
| | 0 | 138 | | catch (NotSupportedException ex) |
| | | 139 | | { |
| | 0 | 140 | | var guidance = new NotSupportedException( |
| | 0 | 141 | | $"No JSON metadata is available for '{type}'. This app runs without reflection-based " + |
| | 0 | 142 | | "System.Text.Json (trimmed/Native AOT), so payload types must be registered at startup: " + |
| | 0 | 143 | | $"declare [JsonSerializable(typeof({type.Name}))] on a JsonSerializerContext and call " + |
| | 0 | 144 | | $"{nameof(AsyncResponseJsonSerialization)}.{nameof(AsyncResponseJsonSerialization.RegisterResolver)}(You |
| | 0 | 145 | | ex); |
| | 0 | 146 | | guidance.Data[RegistrationGuidanceMarker] = true; |
| | 0 | 147 | | throw guidance; |
| | | 148 | | } |
| | 152982 | 149 | | } |
| | | 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) |
| | 2 | 155 | | => 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 | | { |
| | 2 | 163 | | var guidance = new NotSupportedException( |
| | 2 | 164 | | $"A value reached through '{rootType}' has a runtime type with no JSON metadata — the inner exception names |
| | 2 | 165 | | "path (typically an object-typed member such as a worker-call argument whose runtime type is an enum or othe |
| | 2 | 166 | | "unregistered type). This app runs without reflection-based System.Text.Json (trimmed/Native AOT), so that t |
| | 2 | 167 | | "must be registered at startup: declare [JsonSerializable(typeof(...))] on a JsonSerializerContext and call |
| | 2 | 168 | | $"{nameof(AsyncResponseJsonSerialization)}.{nameof(AsyncResponseJsonSerialization.RegisterResolver)}(YourCon |
| | 2 | 169 | | inner); |
| | 2 | 170 | | guidance.Data[RegistrationGuidanceMarker] = true; |
| | 2 | 171 | | return guidance; |
| | | 172 | | } |
| | | 173 | | |
| | | 174 | | private static IJsonTypeInfoResolver? CreateReflectionResolverIfEnabled() |
| | | 175 | | { |
| | 17 | 176 | | if (!JsonSerializer.IsReflectionEnabledByDefault) |
| | 2 | 177 | | return null; |
| | | 178 | | |
| | 15 | 179 | | 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 |
| | 15 | 185 | | 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 | | { |
| | 2304 | 198 | | var info = ((IJsonTypeInfoResolver)AsyncResponseJsonContext.Default).GetTypeInfo(type, options); |
| | 2304 | 199 | | if (info is not null) |
| | 819 | 200 | | return info; |
| | | 201 | | |
| | 3131 | 202 | | foreach (var resolver in AsyncResponseJsonSerialization.Resolvers) |
| | | 203 | | { |
| | 87 | 204 | | info = resolver.GetTypeInfo(type, options); |
| | 87 | 205 | | if (info is not null) |
| | 13 | 206 | | return info; |
| | | 207 | | } |
| | | 208 | | |
| | 1472 | 209 | | return _reflectionResolver?.GetTypeInfo(type, options); |
| | | 210 | | } |
| | | 211 | | } |
| | | 212 | | } |