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

Information
Class: AsyncResponse.Transports.CorrelationIdJsonPaths
Assembly: AsyncResponse.Transports.PostgreSQL
File(s): /_/src/Transports/Shared/CorrelationIdJsonPaths.cs
Line coverage
98%
Covered lines: 113
Uncovered lines: 2
Coverable lines: 115
Total lines: 268
Line coverage: 98.2%
Branch coverage
91%
Covered branches: 57
Total branches: 62
Branch coverage: 91.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Extract(...)90%1010100%
GetSplitPaths(...)100%88100%
TryReadPath(...)100%1212100%
TryGetProperty(...)100%11100%
RentSeenNames()100%22100%
ReturnSeenNames(...)50%2280%
TryGetProperty(...)100%1616100%
UnwrapJsonString(...)75%121293.75%

File(s)

/_/src/Transports/Shared/CorrelationIdJsonPaths.cs

#LineLine coverage
 1using System.Runtime.CompilerServices;
 2using System.Text.Json;
 3
 4namespace AsyncResponse.Transports;
 5
 6// Shared source for every transport's correlation-id extractor (the 7 broker transports plus
 7// DbCorrelationIdExtractor, which the 3 database transports funnel through): each csproj pulls
 8// this file in via <Compile Include="..\Shared\CorrelationIdJsonPaths.cs" />, so it compiles INTO
 9// each provider assembly. The provider-specific fast path (header/property/attribute lookup) stays
 10// in the per-transport extractor; only the JSON-body path walk lives here.
 11
 12/// <summary>
 13/// Locates the AsyncResponse correlation id inside a message body via configured dotted JSON paths.
 14/// Configured paths are pre-split into segments once per distinct <c>CorrelationIdJsonPaths</c>
 15/// array instead of on every delivered message — the array is startup configuration read by every
 16/// provider's options, not per-message data. The body is walked over a <see cref="JsonDocument"/>
 17/// rather than a mutable <see cref="System.Text.Json.Nodes.JsonNode"/> DOM: a <see
 18/// cref="JsonDocument"/> rents its buffer from the array pool and returns it on <c>Dispose</c>,
 19/// and <see cref="JsonElement.EnumerateObject"/> never allocates a wrapper node per property, where
 20/// the DOM allocates one node per visited property and is never disposed by its caller.
 21/// </summary>
 22internal static class CorrelationIdJsonPaths
 23{
 24    // Keyed by the options-held array's identity (never its content): the array is validated once
 25    // at subscriber startup and re-read on every delivered message afterward, so this amortizes
 26    // path.Split across the process lifetime of a subscriber instead of paying it per message. A
 27    // ConditionalWeakTable lets a replaced array (or one built by a short-lived options instance in
 28    // a test) fall out of the cache with the array itself instead of being pinned forever.
 329    private static readonly ConditionalWeakTable<string[], string[][]> SplitPathCache = new();
 30
 31    /// <summary>
 32    /// Returns the first non-blank value found by walking <paramref name="jsonPaths"/> against
 33    /// <paramref name="messageJson"/> in order, or <see langword="null"/> when none resolve.
 34    /// </summary>
 35    public static string? Extract(string messageJson, string[]? jsonPaths)
 36    {
 3137        if (jsonPaths is null || jsonPaths.Length == 0 || string.IsNullOrWhiteSpace(messageJson))
 438            return null;
 39
 2740        var splitPaths = GetSplitPaths(jsonPaths);
 41
 42        JsonDocument document;
 43        try
 44        {
 2745            document = JsonDocument.Parse(messageJson);
 2546        }
 247        catch (JsonException)
 48        {
 249            return null;
 50        }
 51
 2552        using (document)
 53        {
 18754            foreach (var segments in splitPaths)
 55            {
 7356                var value = TryReadPath(document.RootElement, segments);
 7357                if (!string.IsNullOrWhiteSpace(value))
 958                    return value;
 59            }
 1660        }
 61
 1662        return null;
 1163    }
 64
 65    private static string[][] GetSplitPaths(string[] jsonPaths)
 2766        => SplitPathCache.GetValue(jsonPaths, static paths =>
 2767        {
 2768            // A blank configured path is dropped here rather than cached as a no-op entry,
 2769            // mirroring the pre-shared walker's per-call IsNullOrWhiteSpace(path) short-circuit.
 2770            // A non-blank path of only dots/whitespace (e.g. ".") is NOT blank by that check, so
 2771            // it is kept even though it splits to zero segments — TryReadPath below walks zero
 2772            // steps and reads the message root itself, same as the pre-shared walker did.
 2173            var nonBlankCount = 0;
 19074            foreach (var path in paths)
 2775            {
 7476                if (!string.IsNullOrWhiteSpace(path))
 7277                    nonBlankCount++;
 2778            }
 2779
 2180            var split = new string[nonBlankCount][];
 2181            var index = 0;
 19082            foreach (var path in paths)
 2783            {
 7484                if (string.IsNullOrWhiteSpace(path))
 2785                    continue;
 7286                split[index++] = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 2787            }
 2788
 2189            return split;
 2790        });
 91
 92    private static string? TryReadPath(JsonElement root, string[] segments)
 93    {
 7394        var current = root;
 95
 96        // An embedded JSON string can appear at any segment, so the unwrap below can reparse
 97        // more than once per path; only the most recent reparse needs to stay alive; a value it
 98        // produced is copied out (GetString/GetRawText) before it is disposed.
 7399        JsonDocument? scratch = null;
 100        try
 101        {
 264102            foreach (var segment in segments)
 103            {
 87104                current = UnwrapJsonString(current, ref scratch);
 85105                if (current.ValueKind != JsonValueKind.Object)
 16106                    return null;
 107
 69108                if (!TryGetProperty(current, segment, out var next))
 36109                    return null;
 110
 31111                current = next;
 112            }
 113
 17114            current = UnwrapJsonString(current, ref scratch);
 11115            return current.ValueKind switch
 11116            {
 7117                JsonValueKind.String => current.GetString(),
 2118                JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => current.GetRawText(),
 2119                _ => null
 11120            };
 121        }
 10122        catch (InvalidOperationException)
 123        {
 124            // An ESCAPED lone surrogate ("\ud800") is well-formed JSON — Parse accepts it — but it
 125            // has no UTF-16 string form, so transcoding it (JsonProperty.Name on any property of a
 126            // walked object, GetString on the value or on an embedded-JSON candidate) throws
 127            // InvalidOperationException, not JsonException. Same rule as the duplicate key below:
 128            // unresolvable, NOT a failure — an escape here made an unroutable inbound message a
 129            // handler failure, which on RabbitMQ's default MaxDeliveryAttempts = 0 requeues forever.
 10130            return null;
 131        }
 132        finally
 133        {
 73134            scratch?.Dispose();
 73135        }
 73136    }
 137
 138    // Mirrors JsonObject.TryGetPropertyValue then a case-insensitive scan: an exact (ordinal) match
 139    // wins when present, else the first case-insensitive match in document order. JsonObject
 140    // materializes its whole backing dictionary the instant any property is touched, so on this
 141    // runtime looking up ANY property of an object that contains an exact-duplicate key anywhere —
 142    // not only when the duplicate is the key being requested — throws ArgumentException. A
 143    // JsonElement walk materializes nothing, so the object is scanned once here to detect and
 144    // reproduce that rather than silently resolving to one of the duplicates.
 145    private static bool TryGetProperty(JsonElement obj, string name, out JsonElement value)
 146    {
 69147        var seen = RentSeenNames();
 148        try
 149        {
 69150            return TryGetProperty(obj, name, seen, out value);
 151        }
 152        finally
 153        {
 69154            ReturnSeenNames(seen);
 69155        }
 67156    }
 157
 158    // The duplicate-key scan needs a set of the object's property names, and used to allocate a
 159    // fresh one — set, buckets, and every growth step on the way to the object's size — for every
 160    // segment of every configured path of every delivered message. The walk is synchronous and
 161    // never re-enters itself, so one set per thread serves them all; a set grown by an unusually
 162    // wide object is dropped rather than pinned on the thread for good.
 163    [ThreadStatic]
 164    private static HashSet<string>? t_seenNames;
 165
 166    private const int MaxRetainedSeenNames = 256;
 167
 168    private static HashSet<string> RentSeenNames()
 169    {
 69170        var seen = t_seenNames ?? new HashSet<string>(StringComparer.Ordinal);
 69171        t_seenNames = null;
 69172        return seen;
 173    }
 174
 175    private static void ReturnSeenNames(HashSet<string> seen)
 176    {
 69177        if (seen.Count > MaxRetainedSeenNames)
 0178            return;
 179
 69180        seen.Clear();
 69181        t_seenNames = seen;
 69182    }
 183
 184    private static bool TryGetProperty(JsonElement obj, string name, HashSet<string> seen, out JsonElement value)
 185    {
 69186        var exactFound = false;
 69187        JsonElement exactValue = default;
 69188        var caseInsensitiveFound = false;
 69189        JsonElement caseInsensitiveValue = default;
 190
 308191        foreach (var property in obj.EnumerateObject())
 192        {
 193            // Read ONCE: JsonProperty.Name transcodes a new string on every call, and the three
 194            // reads below used to cost three strings per property of every object walked.
 92195            var propertyName = property.Name;
 90196            if (!seen.Add(propertyName))
 197            {
 198                // Unresolvable, NOT a failure: extraction cannot choose between the duplicates, so
 199                // the id is simply not in this body. Throwing here made an unroutable inbound
 200                // message a handler failure, which the ingress explicitly refuses to do (see
 201                // AsyncResponseIngress: an id that cannot route is acknowledged, never redelivered,
 202                // because RabbitMQ's default MaxDeliveryAttempts = 0 has no cap). Returning false
 203                // lets the caller log-and-ack it instead of hot-looping at broker rate.
 204                //
 205                // The duplicate's NAME is deliberately not surfaced anywhere: it comes straight off
 206                // an untrusted body, and the "never logs a message body" guarantee covers it.
 12207                value = default;
 12208                return false;
 209            }
 210
 78211            if (!exactFound && string.Equals(propertyName, name, StringComparison.Ordinal))
 212            {
 29213                exactFound = true;
 29214                exactValue = property.Value;
 215            }
 49216            else if (!caseInsensitiveFound && string.Equals(propertyName, name, StringComparison.OrdinalIgnoreCase))
 217            {
 4218                caseInsensitiveFound = true;
 4219                caseInsensitiveValue = property.Value;
 220            }
 221        }
 222
 55223        if (exactFound)
 224        {
 27225            value = exactValue;
 27226            return true;
 227        }
 228
 28229        if (caseInsensitiveFound)
 230        {
 4231            value = caseInsensitiveValue;
 4232            return true;
 233        }
 234
 24235        value = default;
 24236        return false;
 12237    }
 238
 239    // A JSON-string-encoded object/array (e.g. a broker envelope carrying its payload as an escaped
 240    // string) is parsed and descended into transparently, same as the pre-shared walker.
 241    private static JsonElement UnwrapJsonString(JsonElement element, ref JsonDocument? scratch)
 242    {
 104243        if (element.ValueKind != JsonValueKind.String)
 85244            return element;
 245
 19246        var text = element.GetString();
 11247        if (text is null)
 0248            return element;
 249
 11250        var trimmed = text.AsSpan().TrimStart();
 11251        if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
 7252            return element;
 253
 254        JsonDocument parsed;
 255        try
 256        {
 4257            parsed = JsonDocument.Parse(text);
 2258        }
 2259        catch (JsonException)
 260        {
 2261            return element;
 262        }
 263
 2264        scratch?.Dispose();
 2265        scratch = parsed;
 2266        return parsed.RootElement;
 2267    }
 268}