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

Information
Class: AsyncResponse.AsyncResponseEnvelope<T>
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/AsyncResponseEnvelope.cs
Line coverage
100%
Covered lines: 5
Uncovered lines: 0
Coverable lines: 5
Total lines: 289
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_SchemaVersion()100%11100%
get_Success()100%11100%
get_Payload()100%11100%
get_ExceptionMessage()100%11100%
get_ExceptionStackTrace()100%11100%

File(s)

/_/src/AsyncResponse.Core/AsyncResponseEnvelope.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Serialization;
 3using System.Text.Json.Serialization.Metadata;
 4
 5namespace AsyncResponse;
 6
 7/// <summary>
 8/// The transport envelope wrapping every published response: either a payload
 9/// (<see cref="Success"/> = true) or a technical failure description.
 10/// </summary>
 11/// <typeparam name="T">The payload type.</typeparam>
 12internal sealed class AsyncResponseEnvelope<T>
 13{
 14    /// <summary>
 15    /// Wire schema version, stamped with <see cref="AsyncResponseEnvelopeSchema.Current"/> when the
 16    /// envelope is created. The property is required on the wire; a waiter rejects a missing or
 17    /// unrecognized version rather than risk misreading it.
 18    /// </summary>
 1340119    public int SchemaVersion { get; set; } = AsyncResponseEnvelopeSchema.Current;
 1069520    public bool Success { get; set; }
 1266021    public T? Payload { get; set; }
 549522    public string? ExceptionMessage { get; set; }
 548623    public string? ExceptionStackTrace { get; set; }
 24}
 25
 26/// <summary>
 27/// Wire-schema version stamp for <see cref="AsyncResponseEnvelope{T}"/>. New envelopes are stamped
 28/// with <see cref="Current"/>; a waiter rejects any unrecognized version so a publisher cannot feed
 29/// an incompatible shape to a waiter.
 30/// </summary>
 31internal static class AsyncResponseEnvelopeSchema
 32{
 33    /// <summary>The current wire schema version written by this build.</summary>
 34    public const int Current = 1;
 35
 36    /// <summary>Returns <c>true</c> when an envelope with <paramref name="entryVersion"/> is safe to read on this build
 37    public static bool IsReadable(int entryVersion) => entryVersion == Current;
 38}
 39
 40/// <summary>
 41/// Pre-configured <see cref="JsonSerializerOptions"/> for (de)serializing
 42/// <see cref="AsyncResponseEnvelope{T}"/> instances with null-payload tolerance.
 43/// <para>
 44/// The envelope's metadata is provided converter-backed via
 45/// <see cref="JsonMetadataServices.CreateValueInfo{T}"/> (statically instantiated per
 46/// <typeparamref name="T"/>, no reflection), and everything else — including the payload type —
 47/// resolves through <see cref="AsyncResponseJson.Resolver"/>. Callers needing trim/AOT-clean
 48/// (de)serialization go through <see cref="AsyncResponseEnvelopeJson"/> rather than the
 49/// reflection-based <see cref="JsonSerializer"/> overloads.
 50/// </para>
 51/// <para>
 52/// Property matching is case-insensitive for the PAYLOAD, matching every other broker-ingress
 53/// read (<see cref="AsyncResponseJson.CaseInsensitive"/>): external producers publish payload
 54/// JSON in their own casing, and binding it case-sensitively silently completed waiters with
 55/// all-default payloads. The envelope's own fields are unaffected — its converter matches them
 56/// byte-exact regardless of this flag. Writes are unaffected too (the flag is read-only).
 57/// </para>
 58/// </summary>
 59internal static class AsyncResponseEnvelopeOptions<T>
 60{
 61    public static readonly JsonSerializerOptions Instance = new()
 62    {
 63        TypeInfoResolver = new EnvelopeResolver(),
 64        PropertyNameCaseInsensitive = true
 65    };
 66
 67    private sealed class EnvelopeResolver : IJsonTypeInfoResolver
 68    {
 69        public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
 70            => type == typeof(AsyncResponseEnvelope<T>)
 71                ? JsonMetadataServices.CreateValueInfo<AsyncResponseEnvelope<T>>(options, new AsyncResponseEnvelopeConve
 72                : AsyncResponseJson.Resolver.GetTypeInfo(type, options);
 73    }
 74}
 75
 76/// <summary>
 77/// Trim/AOT-clean (de)serialization entry points for <see cref="AsyncResponseEnvelope{T}"/> —
 78/// the typed-metadata counterparts of <c>JsonSerializer.Serialize(envelope,
 79/// AsyncResponseEnvelopeOptions&lt;T&gt;.Instance)</c> and
 80/// <c>JsonSafety.SafeDeserialize&lt;AsyncResponseEnvelope&lt;T&gt;&gt;(json, …)</c>.
 81/// </summary>
 82internal static class AsyncResponseEnvelopeJson
 83{
 84    /// <summary>Typed metadata for <see cref="AsyncResponseEnvelope{T}"/> bound to its pre-configured options.</summary
 85    public static JsonTypeInfo<AsyncResponseEnvelope<T>> TypeInfo<T>()
 86        => Cache<T>.Value ??= AsyncResponseJson.GetTypeInfo<AsyncResponseEnvelope<T>>(AsyncResponseEnvelopeOptions<T>.In
 87
 88    /// <summary>
 89    /// Per-<typeparamref name="T"/> memo of the resolved metadata, saving the options' dictionary
 90    /// lookup + cast + catch frame on every publish and dispatch. A plain field with a
 91    /// success-only latch, deliberately NOT a <c>static readonly</c> initializer: resolution can
 92    /// fail with the actionable register-a-context error (trimmed/AOT app, payload context
 93    /// registered later at startup), and an initializer would poison the type — every later call
 94    /// would surface a <see cref="TypeInitializationException"/> instead of retrying. The benign
 95    /// latch race resolves to the same instance either way (the serializer caches per options).
 96    /// </summary>
 97    private static class Cache<T>
 98    {
 99        public static JsonTypeInfo<AsyncResponseEnvelope<T>>? Value;
 100    }
 101
 102    /// <summary>Serializes an envelope for publishing.</summary>
 103    public static string Serialize<T>(AsyncResponseEnvelope<T> envelope)
 104        => JsonSerializer.Serialize(envelope, TypeInfo<T>());
 105
 106    /// <summary>Deserializes an envelope with the standard broker-ingress guards (see <see cref="JsonSafety"/>).</summa
 107    public static AsyncResponseEnvelope<T>? SafeDeserialize<T>(string json)
 108        => JsonSafety.SafeDeserialize(json, TypeInfo<T>());
 109}
 110
 111/// <summary>
 112/// Custom converter that tolerates a JSON <c>null</c> payload on failure envelopes — their
 113/// routine shape — even when <typeparamref name="T"/> is a non-nullable value type, assigning
 114/// <c>default(T)</c> instead of throwing. On a success envelope a <c>null</c> payload is
 115/// rejected as malformed: no publisher ever writes one, and accepting it would complete the
 116/// waiter with a payload that surfaces as a <see cref="NullReferenceException"/> at the
 117/// consumer, far from the message that caused it.
 118/// </summary>
 119internal sealed class AsyncResponseEnvelopeConverter<T> : JsonConverter<AsyncResponseEnvelope<T>>
 120{
 121    private static readonly JsonEncodedText SchemaVersionName = JsonEncodedText.Encode("SchemaVersion");
 122    private static readonly JsonEncodedText SuccessName = JsonEncodedText.Encode("Success");
 123    private static readonly JsonEncodedText PayloadName = JsonEncodedText.Encode("Payload");
 124    private static readonly JsonEncodedText ExceptionMessageName = JsonEncodedText.Encode("ExceptionMessage");
 125    private static readonly JsonEncodedText ExceptionStackTraceName = JsonEncodedText.Encode("ExceptionStackTrace");
 126
 127    /// <summary>Reads the JSON value.</summary>
 128    public override AsyncResponseEnvelope<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions 
 129    {
 130        if (reader.TokenType != JsonTokenType.StartObject)
 131        {
 132            throw JsonSafety.WireContractFailure("A response envelope must be a JSON object.");
 133        }
 134
 135        int schemaVersion = default;
 136        bool hasSchemaVersion = false;
 137        bool success = false;
 138        bool hasPayload = false;
 139        bool payloadIsNull = false;
 140        T? payload = default;
 141        string? exceptionMessage = null;
 142        string? exceptionStackTrace = null;
 143
 144        while (reader.Read())
 145        {
 146            if (reader.TokenType == JsonTokenType.EndObject)
 147                break;
 148
 149            if (reader.TokenType == JsonTokenType.PropertyName)
 150            {
 151                var property = GetProperty(ref reader);
 152                reader.Read();
 153
 154                if (property == EnvelopeProperty.SchemaVersion)
 155                {
 156                    if (reader.TokenType != JsonTokenType.Number || !reader.TryGetInt32(out schemaVersion))
 157                        throw JsonSafety.WireContractFailure("SchemaVersion must be an integer.");
 158                    hasSchemaVersion = true;
 159                }
 160                else if (property == EnvelopeProperty.Success)
 161                {
 162                    // Guarded token check: GetBoolean on a non-boolean token throws
 163                    // InvalidOperationException, which the ingress would misread as a TRANSIENT
 164                    // fault and retry — a malformed envelope must fail fast as a JsonException.
 165                    // JsonSafety.WireContractFailure builds one: still a JsonException, so that
 166                    // classification is unchanged, but marked body-free so the scrub preserves
 167                    // this message — it names only the contract's own property, never the body.
 168                    if (reader.TokenType is not (JsonTokenType.True or JsonTokenType.False))
 169                        throw JsonSafety.WireContractFailure("Success must be a boolean.");
 170                    success = reader.GetBoolean();
 171                }
 172                else if (property == EnvelopeProperty.Payload)
 173                {
 174                    hasPayload = true;
 175                    if (reader.TokenType == JsonTokenType.Null)
 176                    {
 177                        // Instead of throwing, assign default(T); whether null was legal here is
 178                        // judged against Success AFTER the loop — property order is not fixed.
 179                        payloadIsNull = true;
 180                        payload = default;
 181                    }
 182                    else
 183                    {
 184                        // Last wins, as every other STJ binding: a null occurrence followed by a
 185                        // value is a value, so the flag must not latch.
 186                        payloadIsNull = false;
 187                        payload = JsonSerializer.Deserialize(ref reader, AsyncResponseJson.GetTypeInfo<T>(options));
 188                    }
 189                }
 190                else if (property == EnvelopeProperty.ExceptionMessage)
 191                {
 192                    if (reader.TokenType is not (JsonTokenType.Null or JsonTokenType.String))
 193                        throw JsonSafety.WireContractFailure("ExceptionMessage must be a string or null.");
 194                    exceptionMessage = reader.TokenType == JsonTokenType.Null ? null : reader.GetString();
 195                }
 196                else if (property == EnvelopeProperty.ExceptionStackTrace)
 197                {
 198                    if (reader.TokenType is not (JsonTokenType.Null or JsonTokenType.String))
 199                        throw JsonSafety.WireContractFailure("ExceptionStackTrace must be a string or null.");
 200                    exceptionStackTrace = reader.TokenType == JsonTokenType.Null ? null : reader.GetString();
 201                }
 202                else
 203                {
 204                    reader.Skip();
 205                }
 206            }
 207        }
 208
 209        if (!hasSchemaVersion)
 210            throw JsonSafety.WireContractFailure("SchemaVersion is required.");
 211
 212        // Every publisher serializes the non-null payload it was handed, so Success=true with a
 213        // null Payload only arises from a producer-side contract violation — typically a raw
 214        // ingress body of literal `null` wrapped verbatim into the envelope. JsonException makes
 215        // it fail fast: the ingress classifies it as permanent (no retry burn) and the waiter
 216        // faults with the reason instead of completing with a null payload. An ABSENT Payload
 217        // is the same violation: the flag above is only ever set inside the Payload branch, so
 218        // {"SchemaVersion":1,"Success":true} slipped past this guard and every channel then
 219        // handed `envelope.Payload!` — null — to the user's Until predicate and TrySetResult.
 220        if (success && (!hasPayload || payloadIsNull))
 221            throw JsonSafety.WireContractFailure("Payload is null or absent on a Success envelope; a successful response
 222
 223        return new AsyncResponseEnvelope<T>
 224        {
 225            SchemaVersion = schemaVersion,
 226            Success = success,
 227            Payload = payload!,
 228            ExceptionMessage = exceptionMessage,
 229            ExceptionStackTrace = exceptionStackTrace
 230        };
 231    }
 232
 233    /// <summary>Writes the JSON value.</summary>
 234    public override void Write(Utf8JsonWriter writer, AsyncResponseEnvelope<T> value, JsonSerializerOptions options)
 235    {
 236        writer.WriteStartObject();
 237        writer.WriteNumber(SchemaVersionName, value.SchemaVersion);
 238        writer.WriteBoolean(SuccessName, value.Success);
 239        writer.WritePropertyName(PayloadName);
 240        JsonSerializer.Serialize(writer, (object?)value.Payload, AsyncResponseJson.GetTypeInfo(typeof(T), options));
 241        writer.WriteString(ExceptionMessageName, value.ExceptionMessage);
 242        writer.WriteString(ExceptionStackTraceName, value.ExceptionStackTrace);
 243        writer.WriteEndObject();
 244    }
 245
 246    private static EnvelopeProperty GetProperty(ref Utf8JsonReader reader)
 247    {
 248        if (!reader.HasValueSequence)
 249        {
 250            var name = reader.ValueSpan;
 251            switch (name.Length)
 252            {
 253                case 13 when name[0] == (byte)'S' && reader.ValueTextEquals("SchemaVersion"u8):
 254                    return EnvelopeProperty.SchemaVersion;
 255                case 7 when name[0] == (byte)'S' && reader.ValueTextEquals("Success"u8):
 256                    return EnvelopeProperty.Success;
 257                case 7 when name[0] == (byte)'P' && reader.ValueTextEquals("Payload"u8):
 258                    return EnvelopeProperty.Payload;
 259                case 16 when name[0] == (byte)'E' && reader.ValueTextEquals("ExceptionMessage"u8):
 260                    return EnvelopeProperty.ExceptionMessage;
 261                case 19 when name[0] == (byte)'E' && reader.ValueTextEquals("ExceptionStackTrace"u8):
 262                    return EnvelopeProperty.ExceptionStackTrace;
 263            }
 264        }
 265
 266        if (reader.ValueTextEquals("SchemaVersion"u8))
 267            return EnvelopeProperty.SchemaVersion;
 268        if (reader.ValueTextEquals("Success"u8))
 269            return EnvelopeProperty.Success;
 270        if (reader.ValueTextEquals("Payload"u8))
 271            return EnvelopeProperty.Payload;
 272        if (reader.ValueTextEquals("ExceptionMessage"u8))
 273            return EnvelopeProperty.ExceptionMessage;
 274        if (reader.ValueTextEquals("ExceptionStackTrace"u8))
 275            return EnvelopeProperty.ExceptionStackTrace;
 276
 277        return EnvelopeProperty.Unknown;
 278    }
 279
 280    private enum EnvelopeProperty
 281    {
 282        Unknown,
 283        SchemaVersion,
 284        Success,
 285        Payload,
 286        ExceptionMessage,
 287        ExceptionStackTrace
 288    }
 289}