-
Notifications
You must be signed in to change notification settings - Fork 2
Make a failed call's body readable, and stop sharing one builder per client #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
| using SW.PrimitiveTypes; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using System.Net; | ||
| using System.Net.Http; | ||
| using System.Security.Claims; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace SW.HttpExtensions.UnitTests | ||
| { | ||
| [TestClass] | ||
| public class ApiClientBaseTests | ||
| { | ||
| private class TestOptions : ApiClientOptionsBase | ||
| { | ||
| public override string ConfigurationSection => "Test"; | ||
| } | ||
|
|
||
| /// <summary>Records every path it is asked for, and answers 200 with an empty object.</summary> | ||
| private class RecordingHandler : HttpMessageHandler | ||
| { | ||
| private readonly List<string> paths = new List<string>(); | ||
| private readonly int delayMs; | ||
|
|
||
| public RecordingHandler(int delayMs = 0) => this.delayMs = delayMs; | ||
|
|
||
| public IReadOnlyList<string> Paths | ||
| { | ||
| get { lock (paths) return paths.ToList(); } | ||
| } | ||
|
|
||
| protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| lock (paths) paths.Add(request.RequestUri.PathAndQuery); | ||
| if (delayMs > 0) await Task.Delay(delayMs, cancellationToken); | ||
| return new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /// <summary>Two operations on one client, exactly as a generated SDK writes them.</summary> | ||
| private class TestClient : ApiClientBase<TestOptions> | ||
| { | ||
| public TestClient(HttpClient httpClient, RequestContext requestContext, TestOptions options) | ||
| : base(httpClient, requestContext, options) { } | ||
|
|
||
| public Task<ApiResult<object>> Alpha() => | ||
| Builder.Path("alpha").AsApiResult<object>().GetAsync(); | ||
|
|
||
| public Task<ApiResult<object>> Beta() => | ||
| Builder.Path("beta").AsApiResult<object>().GetAsync(); | ||
|
|
||
| public ApiOperationBuilder<TestOptions> ExposedBuilder => Builder; | ||
|
|
||
| /// <summary> | ||
| /// Prepares both operations before dispatching either — the shape that breaks | ||
| /// deterministically when every method shares one builder instance. | ||
| /// </summary> | ||
| public async Task TwoPreparedOperations() | ||
| { | ||
| var first = Builder.Path("alpha"); | ||
| var second = Builder.Path("beta"); | ||
| await first.AsApiResult<object>().GetAsync(); | ||
| await second.AsApiResult<object>().GetAsync(); | ||
| } | ||
| } | ||
|
|
||
| private static TestClient CreateClient(HttpMessageHandler handler, RequestContext requestContext = null) | ||
| { | ||
| var httpClient = new HttpClient(handler) { BaseAddress = new System.Uri("http://localhost/") }; | ||
| return new TestClient(httpClient, requestContext ?? new RequestContext(), new TestOptions()); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void BuilderIsNotSharedBetweenAccesses() | ||
| { | ||
| var client = CreateClient(new RecordingHandler()); | ||
| Assert.AreNotSame(client.ExposedBuilder, client.ExposedBuilder); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// The bug a per-access builder exists to prevent, in its deterministic form: with one shared | ||
| /// builder the second Path(...) overwrites the first, so BOTH requests go to "beta" and the | ||
| /// first operation silently runs as the second. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task OperationsPreparedBeforeDispatchDoNotCrossPaths() | ||
| { | ||
| var handler = new RecordingHandler(); | ||
| var client = CreateClient(handler); | ||
|
|
||
| await client.TwoPreparedOperations(); | ||
|
|
||
| CollectionAssert.AreEqual( | ||
| new[] { "/alpha", "/beta" }, | ||
| handler.Paths.ToList(), | ||
| $"expected one request per operation, got: {string.Join(", ", handler.Paths)}"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Guards the case that was ALREADY safe, so it stays that way: awaiting two async operations | ||
| /// together is fine even with a shared builder, because AsApiResult copies the path into a | ||
| /// fresh runner synchronously — before the first await — so Alpha's path is captured before | ||
| /// Beta starts. Worth pinning down; it's the reason the old shared builder survived in | ||
| /// production for as long as it did. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task AwaitingTwoOperationsTogetherKeepsTheirOwnPaths() | ||
| { | ||
| var handler = new RecordingHandler(delayMs: 30); | ||
| var client = CreateClient(handler); | ||
|
|
||
| await Task.WhenAll(client.Alpha(), client.Beta()); | ||
|
|
||
| CollectionAssert.AreEquivalent( | ||
| new[] { "/alpha", "/beta" }, | ||
| handler.Paths.ToList(), | ||
| $"expected one request per operation, got: {string.Join(", ", handler.Paths)}"); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public async Task SequentialCallsOnOneClientEachKeepTheirOwnPath() | ||
| { | ||
| var handler = new RecordingHandler(); | ||
| var client = CreateClient(handler); | ||
|
|
||
| await client.Alpha(); | ||
| await client.Beta(); | ||
|
|
||
| CollectionAssert.AreEqual(new[] { "/alpha", "/beta" }, handler.Paths.ToList()); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A builder per call means the constructor's correlation-id header runs per call too, and | ||
| /// HttpHeaders.Add appends rather than replaces — so without the remove-then-add this sends a | ||
| /// header that grows a duplicate value on every request. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task CorrelationIdIsSentOnceNoMatterHowManyCalls() | ||
| { | ||
| var requestContext = new RequestContext(); | ||
| requestContext.Set(new ClaimsPrincipal(new ClaimsIdentity("testauth"))); | ||
|
|
||
| var handler = new RecordingHandler(); | ||
| var httpClient = new HttpClient(handler) { BaseAddress = new System.Uri("http://localhost/") }; | ||
| var client = new TestClient(httpClient, requestContext, new TestOptions()); | ||
|
|
||
| await client.Alpha(); | ||
| await client.Beta(); | ||
| await client.Alpha(); | ||
|
|
||
| Assert.IsTrue( | ||
| httpClient.DefaultRequestHeaders.TryGetValues(RequestContext.CorrelationIdHeaderName, out var values), | ||
| "correlation id header was not set at all"); | ||
| Assert.AreEqual(1, values.Count(), $"correlation id was sent {values.Count()} times"); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
| using SW.PrimitiveTypes; | ||
|
|
||
| namespace SW.HttpExtensions.UnitTests | ||
| { | ||
| [TestClass] | ||
| public class ApiResultExtensionsTests | ||
| { | ||
| private class Refusal | ||
| { | ||
| public string ErrorCode { get; set; } | ||
| public string ErrorMessage { get; set; } | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void ReadsAStructuredBodyOffAFailedResult() | ||
| { | ||
| var result = new ApiResult<object> | ||
| { | ||
| Success = false, | ||
| StatusCode = 400, | ||
| Body = "{\"errorCode\":\"InsufficientQuantity\",\"errorMessage\":\"only 3 left\"}" | ||
| }; | ||
|
|
||
| Assert.IsTrue(result.TryReadBody<Refusal>(out var refusal)); | ||
| Assert.AreEqual("InsufficientQuantity", refusal.ErrorCode); | ||
| Assert.AreEqual("only 3 left", refusal.ErrorMessage); | ||
| } | ||
|
|
||
| /// <summary>Property casing differs between the wire (camel) and C# (pascal).</summary> | ||
| [TestMethod] | ||
| public void MatchesPropertiesRegardlessOfCasing() | ||
| { | ||
| var result = new ApiResult { Body = "{\"ErrorCode\":\"Pascal\"}" }; | ||
| Assert.IsTrue(result.TryReadBody<Refusal>(out var refusal)); | ||
| Assert.AreEqual("Pascal", refusal.ErrorCode); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A failure body is often not the shape the caller expects — a proxy's HTML error page, a | ||
| /// gateway timeout, a bare exception string. None of those may throw at the call site. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void ReturnsFalseRatherThanThrowingOnABodyThatIsNotJson() | ||
| { | ||
| var result = new ApiResult { StatusCode = 502, Body = "<html><body>Bad Gateway</body></html>" }; | ||
| Assert.IsFalse(result.TryReadBody<Refusal>(out var refusal)); | ||
| Assert.IsNull(refusal); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void ReturnsFalseOnJsonOfTheWrongShape() | ||
| { | ||
| var result = new ApiResult { Body = "[1,2,3]" }; | ||
| Assert.IsFalse(result.TryReadBody<Refusal>(out _)); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void ReturnsFalseOnAnEmptyOrAbsentBody() | ||
| { | ||
| Assert.IsFalse(new ApiResult { Body = null }.TryReadBody<Refusal>(out _)); | ||
| Assert.IsFalse(new ApiResult { Body = "" }.TryReadBody<Refusal>(out _)); | ||
| Assert.IsFalse(new ApiResult { Body = " " }.TryReadBody<Refusal>(out _)); | ||
| } | ||
|
|
||
| /// <summary>Literal JSON null deserializes to null — absent, not a value.</summary> | ||
| [TestMethod] | ||
| public void ReturnsFalseOnLiteralJsonNull() | ||
| { | ||
| Assert.IsFalse(new ApiResult { Body = "null" }.TryReadBody<Refusal>(out _)); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void ToleratesANullResult() | ||
| { | ||
| ApiResult result = null; | ||
| Assert.IsFalse(result.TryReadBody<Refusal>(out _)); | ||
| Assert.IsNull(result.ReadBodyOrDefault<Refusal>()); | ||
| } | ||
|
|
||
| [TestMethod] | ||
| public void ReadBodyOrDefaultReturnsTheBodyOrTheDefault() | ||
| { | ||
| Assert.AreEqual("X", | ||
| new ApiResult { Body = "{\"errorCode\":\"X\"}" }.ReadBodyOrDefault<Refusal>().ErrorCode); | ||
| Assert.IsNull(new ApiResult { Body = "nonsense" }.ReadBodyOrDefault<Refusal>()); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,8 +20,17 @@ public ApiOperationBuilder(HttpClient httpClient, RequestContext requestContext, | |
| this.requestContext = requestContext; | ||
| this.options = options; | ||
|
|
||
| // Remove-then-add rather than a bare Add: HttpHeaders.Add APPENDS a second value for an | ||
| // existing name, so building more than one builder over the same HttpClient (which is now | ||
| // once per call — see ApiClientBase.Builder) would otherwise send | ||
| // "sw-correlation-id: abc, abc", growing by one on every call. Remove-then-add is also | ||
| // what keeps the header CURRENT: a plain "add only if missing" guard would pin the first | ||
| // request's correlation id onto every later request that reuses the client. | ||
| if (requestContext.IsValid) | ||
| { | ||
| httpClient.DefaultRequestHeaders.Remove(RequestContext.CorrelationIdHeaderName); | ||
| httpClient.DefaultRequestHeaders.Add(RequestContext.CorrelationIdHeaderName, requestContext.CorrelationId); | ||
|
Comment on lines
29
to
32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect target frameworks and all request/header construction paths.
fd -t f -e csproj -e props -e targets . -x sed -n '1,180p' {} \;
rg -n -C 3 'DefaultRequestHeaders|HttpRequestMessage|SendAsync\(|GetAsync\(|PostAsync\(' \
SW.HttpExtensions SW.HttpExtensions.UnitTestsRepository: simplify9/SW-HttpExtensions Length of output: 22006 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Operation and runner files =="
for f in SW.HttpExtensions/ApiOperationBuilder.cs SW.HttpExtensions/ApiOperationRunnerWrapped.cs SW.HttpExtensions/ApiOperationRunnerWrappedOfT.cs SW.HttpExtensions/ApiOperationRunnerTyped.cs SW.HttpExtensions/ApiClientBase.cs SW.HttpExtensions.UnitTests/ApiClientBaseTests.cs; do
echo "-- $f --"
wc -l "$f"
sed -n '1,180p' "$f" | cat -n
done
echo "== RequestContext definitions/usages =="
rg -n -C 3 'class RequestContext|Record|CorrelationIdHeaderName|CorrelationId|DefaultRequestHeaders\.' SW.HttpExtensions SW.HttpExtensions.UnitTestsRepository: simplify9/SW-HttpExtensions Length of output: 41328 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Locate primitive types =="
git ls-files | rg 'RequestContext|PrimitiveTypes|SW\.PrimitiveTypes|json' | sed -n '1,200p'
echo "== Search correlation/request context in repo =="
rg -n -C 4 'class RequestContext|record RequestContext|record struct RequestContext|struct RequestContext|CorrelationIdHeaderName|CorrelationId|IsValid|Set\(|Set' \
. --glob '!bin/**' --glob '!obj/**' --glob '!node_modules/**' | sed -n '1,220p'Repository: simplify9/SW-HttpExtensions Length of output: 12380 🌐 Web query:
💡 Result: In the SimplyWorks.PrimitiveTypes library (version 8.1.2 and related versions), the RequestContext class is used to maintain request-scoped information, including the CorrelationId for distributed tracing [1]. While specific internal implementation details for property names like CorrelationIdHeaderName or method names like IsValid may vary across versions of the Simplify9/SW-PrimitiveTypes repository, the library is designed for.NET 8.0 and provides foundational patterns for API and messaging development [2][1][3]. Based on typical implementations of RequestContext within the SimplyWorks ecosystem: - CorrelationId: This property is used to retrieve or set the unique identifier for tracking a request across service boundaries [1]. - RequestContext: This class is injected into services or message consumers to provide access to context-specific data, such as the current user or correlation ID [1][4]. - CorrelationIdHeaderName: This is typically a configuration constant or property used to identify the HTTP header key (e.g., "X-Correlation-ID") where the correlation ID is stored [5][6]. - IsValid: This is often implemented as a validation method or property within value objects or domain patterns provided by the library to ensure data integrity [2][3]. For the most accurate and up-to-date documentation regarding your specific version, please refer to the official repository at https://github.com/simplify9/SW-PrimitiveTypes [2][7]. If you are looking for configuration specific to your implementation, ensure you are referencing the namespace SW.PrimitiveTypes.Patterns.RequestContext [8]. Citations:
🌐 Web query:
💡 Result: The term sw-correlation-id is a custom identifier commonly used for distributed tracing and request correlation in.NET applications, particularly those utilizing libraries from the SimplyWorks ecosystem [1][2][3]. SimplyWorks.PrimitiveTypes is a foundational.NET library that provides essential types, domain patterns, and messaging contracts for building modern applications [4][3]. While specific, granular implementation details for every property in version 8.1.2 may not be documented in a single public reference manual, the library is part of the simplify9 open-source project, which facilitates building event-driven architectures and API handlers [2][3]. In such architectures, a Correlation ID (often passed via an HTTP header or messaging property like sw-correlation-id) is used to track a single request or transaction as it traverses various services, logs, and message buses [5][6]. Developers typically implement this by: 1. Generating or extracting the Correlation ID at the service entry point (e.g., an API middleware) [5][6]. 2. Storing this ID in a RequestContext or similar scoped container to make it accessible throughout the request lifecycle [5]. 3. Injecting the ID into outgoing HTTP requests or message headers (e.g., sw-correlation-id) to maintain the trace across downstream services [5]. If you are working with SimplyWorks.PrimitiveTypes, you can find the source code and documentation on the simplify9 GitHub repository to see how these primitives integrate with your messaging or API layer [2][7]. Citations:
🌐 Web query:
💡 Result: There is no standard or built-in.NET type, class, or property named CorrelationIdHeaderName within the.NET SDK or standard library. Furthermore, RequestContext.cs is not a standard file in.NET or ASP.NET Core framework projects. The term likely refers to a custom implementation within a specific third-party library or a private application-specific codebase [1]. If you are working with a codebase containing these names, they are custom identifiers defined by your project or the specific library you are using, rather than platform-provided defaults. Citations: 🌐 Web query:
💡 Result: The terms "PrimitiveTypes," "RequestContext," and "CorrelationIdHeaderName" are associated with the "SimplyWorks" ecosystem developed by Simplify9 [1][2]. SimplyWorks.PrimitiveTypes is a foundational.NET 8.0 library [1][2] providing essential types, interfaces, and patterns for domain-driven design, value objects, messaging, and API development [1][3]. Within this ecosystem: 1. RequestContext: The library includes infrastructure for managing request-scoped data [4]. Files such as RequestContext.cs and IRequestContext.cs are part of the patterns provided to handle contextual information during request processing [4]. 2. CorrelationIdHeaderName: While "CorrelationIdHeaderName" is a common constant name in various.NET libraries (such as the ABP Framework [5][6] or community-developed correlation ID libraries [7][8]), within the context of the Simplify9 libraries, such constants are used to facilitate traceability across services in their messaging and API handlers [1][3][9]. The Simplify9 repository, specifically SW-PrimitiveTypes, serves as a core dependency for other tools in their ecosystem, including SimplyWorks.Bus (for messaging) and SimplyWorks.CqApi (for API generation) [1][2]. If you are looking for specific implementation details, you can find the library on GitHub under simplify9/SW-PrimitiveTypes [1][3]. Citations:
Keep correlation IDs off shared default headers.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| public ApiOperationBuilder<TApiClientOptions> Path(string path) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| using Newtonsoft.Json; | ||
| using SW.PrimitiveTypes; | ||
|
|
||
| namespace SW.HttpExtensions | ||
| { | ||
| /// <summary> | ||
| /// Reads a structured body off an <see cref="ApiResult"/> — in practice, off a FAILED one. | ||
| /// | ||
| /// <para> | ||
| /// <see cref="ApiResult{TResponse}.Response"/> is only ever populated for a 2xx (see | ||
| /// <see cref="ApiOperationRunnerWrapped{TResponse}"/>); a non-2xx leaves the payload sitting in | ||
| /// <see cref="ApiResult.Body"/> as an unparsed string. So an API that answers an expected, | ||
| /// non-exceptional refusal with a 4xx and a typed body — "out of stock", "already cancelled", a | ||
| /// validation list — gives the caller a status code and nothing it can act on, unless every call | ||
| /// site hand-rolls its own <see cref="JsonConvert"/> call. | ||
| /// </para> | ||
| /// <para> | ||
| /// The observable consequence is services answering <c>200 OK</c> with an error field instead, | ||
| /// purely because that's the only shape a caller can actually read. That works, but it hides a | ||
| /// failure from every generic status-code check (logging, retries, dashboards) and silently | ||
| /// degrades if anyone later corrects the status to a 4xx. | ||
| /// </para> | ||
| /// </summary> | ||
| public static class ApiResultExtensions | ||
| { | ||
| /// <summary> | ||
| /// Deserializes <see cref="ApiResult.Body"/> into <typeparamref name="TBody"/>. | ||
| /// | ||
| /// Never throws: a null result, an empty body, or a body that isn't JSON of that shape all | ||
| /// return <c>false</c> with <paramref name="body"/> left at its default. That matters because | ||
| /// a failure body is frequently NOT the shape you expect — an unhandled server exception, an | ||
| /// HTML error page from a proxy, or a gateway timeout — and none of those should turn a failed | ||
| /// call into a thrown exception at the call site. | ||
| /// | ||
| /// Note this reads <see cref="ApiResult.Body"/>, not <see cref="ApiResult{TResponse}.Response"/>: | ||
| /// on a successful typed call the body is not retained, so this is for the failure path. | ||
| /// </summary> | ||
| public static bool TryReadBody<TBody>(this ApiResult apiResult, out TBody body) | ||
| { | ||
| body = default; | ||
|
|
||
| if (string.IsNullOrWhiteSpace(apiResult?.Body)) | ||
| return false; | ||
|
|
||
| try | ||
| { | ||
| var deserialized = JsonConvert.DeserializeObject<TBody>(apiResult.Body); | ||
| if (deserialized == null) | ||
| return false; | ||
|
|
||
| body = deserialized; | ||
| return true; | ||
| } | ||
| catch (JsonException) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// <see cref="TryReadBody{TBody}"/> for when the absent case needs no distinct handling — | ||
| /// returns the default when the body is missing or isn't <typeparamref name="TBody"/>. | ||
| /// </summary> | ||
| public static TBody ReadBodyOrDefault<TBody>(this ApiResult apiResult) | ||
| { | ||
| apiResult.TryReadBody<TBody>(out var body); | ||
| return body; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the casing test use a genuinely different casing.
ErrorCodematches the property declaration exactly, so this test would pass even if case-insensitive matching broke. Use a value such aserrorcodeorERRORCODE; the first test already covers camelCase.🤖 Prompt for AI Agents