Skip to content

Commit bba08ad

Browse files
committed
fix: align HTTP results with raw payloads
1 parent ea85943 commit bba08ad

11 files changed

Lines changed: 521 additions & 47 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ always check all test are passed.
1919
- Keep documentation aligned with the current major version (for this repository now: version 10); do not add cross-major migration sections unless explicitly requested.
2020
- When behavior changes in Result/Problem flows, include a clear README update with concrete usage examples.
2121
- When a framework adapter catches an exception and converts it into a failed `Result`, it must log the original exception object before returning the failure so distributed traces keep the real stack trace.
22+
- Keep one forward-only HTTP wire contract: `WithCommunicationResults()` emits a raw success payload or RFC 7807 failure, and `HttpClient.SendForResultAsync<T>()` consumes exactly that shape. Do not retain serialized `Result<T>` envelope compatibility.
23+
- For file, optional, or other non-JSON success bodies, use the `SendForResultAsync` success-projection overload so the library still owns transport and RFC 7807 failures; do not add application-specific failure parsers.
24+
- When one Minimal API route dynamically selects between a CQRS stream and another HTTP result, use `CqrsStreamHttpResults.ServerSentEvents(stream)`; do not return `object` or implement a second SSE writer in the application.
2225

2326
# Repository Guidelines
2427

‎Directory.Build.props‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
<RepositoryUrl>https://github.com/managedcode/Communication</RepositoryUrl>
2828
<PackageProjectUrl>https://github.com/managedcode/Communication</PackageProjectUrl>
2929
<Product>Managed Code - Communication</Product>
30-
<Version>10.1.0</Version>
31-
<PackageVersion>10.1.0</PackageVersion>
30+
<Version>10.1.1</Version>
31+
<PackageVersion>10.1.1</PackageVersion>
3232

3333
</PropertyGroup>
3434
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">

‎ManagedCode.Communication.AspNetCore/Cqrs/CqrsStreamResultFactory.cs‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ public static bool TryCreateServerSentEventsActionResult(
137137
return match;
138138
}
139139

140-
private static AspNetResult ToServerSentEventsResult<TProgress, TResult>(
140+
internal static AspNetResult ToServerSentEventsResult<TProgress, TResult>(
141141
IAsyncEnumerable<CqrsStreamChunk<TProgress, TResult>> updates,
142142
CqrsStreamServerOptions options)
143143
{
@@ -164,3 +164,25 @@ private static async IAsyncEnumerable<SseItem<CqrsStreamChunk<TProgress, TResult
164164
}
165165
}
166166
}
167+
168+
/// <summary>
169+
/// Creates typed Minimal API results for CQRS chunk streams when a handler needs to select between an SSE stream
170+
/// and another HTTP result at runtime.
171+
/// </summary>
172+
public static class CqrsStreamHttpResults
173+
{
174+
/// <summary>
175+
/// Writes <paramref name="updates" /> as the same normalized Server-Sent Events contract used by
176+
/// <c>WithCommunicationCqrsResults()</c>.
177+
/// </summary>
178+
public static AspNetResult ServerSentEvents<TProgress, TResult>(
179+
IAsyncEnumerable<CqrsStreamChunk<TProgress, TResult>> updates,
180+
CqrsStreamServerOptions? options = null)
181+
{
182+
ArgumentNullException.ThrowIfNull(updates);
183+
184+
return CqrsStreamResultFactory.ToServerSentEventsResult(
185+
updates,
186+
options ?? CqrsStreamServerOptions.Default);
187+
}
188+
}

‎ManagedCode.Communication.Extensions/Http/ResultHttpClientExtensions.cs‎

Lines changed: 131 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
using System;
2+
using System.Net;
23
using System.Net.Http;
4+
using System.Text.Json;
35
using System.Threading;
46
using System.Threading.Tasks;
57
using ManagedCode.Communication;
68
using ManagedCode.Communication.Extensions;
9+
using ManagedCode.Communication.Logging;
10+
using ManagedCode.Communication.Telemetry;
711
using Polly;
812

913
namespace ManagedCode.Communication.Extensions.Http;
@@ -24,7 +28,7 @@ public static class ResultHttpClientExtensions
2428
/// <param name="requestFactory">Factory that creates a fresh <see cref="HttpRequestMessage"/> for each attempt.</param>
2529
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param>
2630
/// <param name="cancellationToken">Token that cancels the request execution.</param>
27-
public static Task<Result<T>> SendForResultAsync<T>(
31+
public static async Task<Result<T>> SendForResultAsync<T>(
2832
this HttpClient client,
2933
Func<HttpRequestMessage> requestFactory,
3034
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
@@ -33,12 +37,65 @@ public static Task<Result<T>> SendForResultAsync<T>(
3337
ArgumentNullException.ThrowIfNull(client);
3438
ArgumentNullException.ThrowIfNull(requestFactory);
3539

36-
return SendCoreAsync(
37-
client,
38-
requestFactory,
39-
static response => response.FromJsonToResult<T>(),
40-
pipeline,
41-
cancellationToken);
40+
try
41+
{
42+
return await SendCoreAsync(
43+
client,
44+
requestFactory,
45+
static response => response.FromJsonToResult<T>(),
46+
pipeline,
47+
cancellationToken)
48+
.ConfigureAwait(false);
49+
}
50+
catch (HttpRequestException exception)
51+
{
52+
return Result<T>.Fail(TransportProblem(exception, HttpStatusCode.ServiceUnavailable));
53+
}
54+
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
55+
{
56+
return Result<T>.Fail(TransportProblem(exception, HttpStatusCode.GatewayTimeout));
57+
}
58+
}
59+
60+
/// <summary>
61+
/// Sends a request and lets <paramref name="readSuccessAsync"/> project a successful HTTP response while the
62+
/// library continues to own transport failures and RFC problem responses.
63+
/// </summary>
64+
/// <typeparam name="T">The projected success value.</typeparam>
65+
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param>
66+
/// <param name="requestFactory">Factory that creates a fresh request for each attempt.</param>
67+
/// <param name="readSuccessAsync">Reads a successful response into the projected value.</param>
68+
/// <param name="pipeline">Optional Polly resilience pipeline.</param>
69+
/// <param name="cancellationToken">Token that cancels request execution and response reading.</param>
70+
public static async Task<Result<T>> SendForResultAsync<T>(
71+
this HttpClient client,
72+
Func<HttpRequestMessage> requestFactory,
73+
Func<HttpResponseMessage, CancellationToken, Task<T?>> readSuccessAsync,
74+
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
75+
CancellationToken cancellationToken = default)
76+
{
77+
ArgumentNullException.ThrowIfNull(client);
78+
ArgumentNullException.ThrowIfNull(requestFactory);
79+
ArgumentNullException.ThrowIfNull(readSuccessAsync);
80+
81+
try
82+
{
83+
return await SendCoreAsync(
84+
client,
85+
requestFactory,
86+
response => ReadProjectedResultAsync(response, readSuccessAsync, cancellationToken),
87+
pipeline,
88+
cancellationToken)
89+
.ConfigureAwait(false);
90+
}
91+
catch (HttpRequestException exception)
92+
{
93+
return Result<T>.Fail(TransportProblem(exception, HttpStatusCode.ServiceUnavailable));
94+
}
95+
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
96+
{
97+
return Result<T>.Fail(TransportProblem(exception, HttpStatusCode.GatewayTimeout));
98+
}
4299
}
43100

44101
/// <summary>
@@ -50,7 +107,7 @@ public static Task<Result<T>> SendForResultAsync<T>(
50107
/// <param name="requestFactory">Factory that creates a fresh <see cref="HttpRequestMessage"/> for each attempt.</param>
51108
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param>
52109
/// <param name="cancellationToken">Token that cancels the request execution.</param>
53-
public static Task<Result> SendForResultAsync(
110+
public static async Task<Result> SendForResultAsync(
54111
this HttpClient client,
55112
Func<HttpRequestMessage> requestFactory,
56113
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
@@ -59,12 +116,24 @@ public static Task<Result> SendForResultAsync(
59116
ArgumentNullException.ThrowIfNull(client);
60117
ArgumentNullException.ThrowIfNull(requestFactory);
61118

62-
return SendCoreAsync(
63-
client,
64-
requestFactory,
65-
static response => response.FromRequestToResult(),
66-
pipeline,
67-
cancellationToken);
119+
try
120+
{
121+
return await SendCoreAsync(
122+
client,
123+
requestFactory,
124+
static response => response.FromRequestToResult(),
125+
pipeline,
126+
cancellationToken)
127+
.ConfigureAwait(false);
128+
}
129+
catch (HttpRequestException exception)
130+
{
131+
return Result.Fail(TransportProblem(exception, HttpStatusCode.ServiceUnavailable));
132+
}
133+
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
134+
{
135+
return Result.Fail(TransportProblem(exception, HttpStatusCode.GatewayTimeout));
136+
}
68137
}
69138

70139
/// <summary>
@@ -142,4 +211,52 @@ private static async Task<TResponse> SendCoreAsync<TResponse>(
142211
return await convert(httpResponse).ConfigureAwait(false);
143212
}
144213
}
214+
215+
private static async Task<Result<T>> ReadProjectedResultAsync<T>(
216+
HttpResponseMessage response,
217+
Func<HttpResponseMessage, CancellationToken, Task<T?>> readSuccessAsync,
218+
CancellationToken cancellationToken)
219+
{
220+
if (!response.IsSuccessStatusCode)
221+
{
222+
var failure = await response.FromRequestToResult(cancellationToken).ConfigureAwait(false);
223+
return Result<T>.Fail(failure.Problem ?? Problem.GenericError());
224+
}
225+
226+
try
227+
{
228+
var value = await readSuccessAsync(response, cancellationToken).ConfigureAwait(false);
229+
return value is null
230+
? InvalidProjectedResponse<T>(response, new JsonException("The projected response payload was null."))
231+
: Result<T>.Succeed(value);
232+
}
233+
catch (JsonException exception)
234+
{
235+
return InvalidProjectedResponse<T>(response, exception);
236+
}
237+
catch (NotSupportedException exception)
238+
{
239+
return InvalidProjectedResponse<T>(response, exception);
240+
}
241+
}
242+
243+
private static Result<T> InvalidProjectedResponse<T>(HttpResponseMessage response, Exception exception)
244+
{
245+
var problem = Problem.Create(
246+
"Invalid response body",
247+
$"The successful response could not be projected to {typeof(T).Name}: {exception.Message}",
248+
(int)response.StatusCode);
249+
CommunicationDiagnostics.ReportFailure(CommunicationLogger.GetLogger(), problem, exception);
250+
return Result<T>.Fail(problem);
251+
}
252+
253+
private static Problem TransportProblem(Exception exception, HttpStatusCode statusCode)
254+
{
255+
var problem = Problem.Create(
256+
"HTTP transport failure",
257+
exception.Message,
258+
statusCode);
259+
CommunicationDiagnostics.ReportFailure(CommunicationLogger.GetLogger(), problem, exception);
260+
return problem;
261+
}
145262
}

‎ManagedCode.Communication.Tests/AspNetCore/Extensions/ResultEndpointFilterTests.cs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Threading.Tasks;
77
using ManagedCode.Communication;
88
using ManagedCode.Communication.AspNetCore.MinimalApi;
9+
using ManagedCode.Communication.Extensions.Http;
910
using Microsoft.AspNetCore.Builder;
1011
using Microsoft.AspNetCore.Hosting;
1112
using Microsoft.AspNetCore.Http;
@@ -33,6 +34,20 @@ public async Task WithCommunicationResults_SuccessResult_ReturnsOkResponse()
3334
payload.ShouldBe("pong");
3435
}
3536

37+
[Fact]
38+
public async Task WithCommunicationResults_SuccessResult_IsReadableByResultHttpClient()
39+
{
40+
await using var app = await CreateAppAsync(static app =>
41+
{
42+
app.MapGet("/success", () => Result<string>.Succeed("pong")).WithCommunicationResults();
43+
});
44+
45+
var result = await app.GetTestClient().GetAsResultAsync<string>("/success");
46+
47+
result.IsSuccess.ShouldBeTrue();
48+
result.Value.ShouldBe("pong");
49+
}
50+
3651
[Fact]
3752
public async Task WithCommunicationResults_FailedResult_ReturnsProblem()
3853
{
@@ -50,6 +65,30 @@ public async Task WithCommunicationResults_FailedResult_ReturnsProblem()
5065
problem.Title.ShouldBe("Operation failed");
5166
}
5267

68+
[Fact]
69+
public async Task WithCommunicationResults_FailedResult_IsReadableByResultHttpClient()
70+
{
71+
await using var app = await CreateAppAsync(static app =>
72+
{
73+
var problem = Problem.Create("Invalid import", "The archive is malformed.", 422);
74+
problem.Type = "https://managedcode.dev/problems/invalid-import";
75+
problem.Instance = "/imports/42";
76+
problem.Extensions["operationId"] = "import-42";
77+
78+
app.MapGet("/failed", () => Result<string>.Fail(problem)).WithCommunicationResults();
79+
});
80+
81+
var result = await app.GetTestClient().GetAsResultAsync<string>("/failed");
82+
83+
result.IsFailed.ShouldBeTrue();
84+
result.Problem.Title.ShouldBe("Invalid import");
85+
result.Problem.Detail.ShouldBe("The archive is malformed.");
86+
result.Problem.StatusCode.ShouldBe(422);
87+
result.Problem.Type.ShouldBe("https://managedcode.dev/problems/invalid-import");
88+
result.Problem.Instance.ShouldBe("/imports/42");
89+
result.Problem.Extensions.ShouldContainKey("operationId");
90+
}
91+
5392
[Fact]
5493
public async Task WithCommunicationResults_GroupBuilder_AppliesFilterToAllEndpoints()
5594
{

‎ManagedCode.Communication.Tests/CQRS/CqrsMinimalApiIntegrationTests.cs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,25 @@ public async Task CompletedStream_IsDeliveredAsEventStream()
4848
result.Status.ShouldBe("done");
4949
}
5050

51+
[Fact]
52+
public async Task ExplicitHttpResult_UsesTheSameCqrsWireContractWithoutAFilter()
53+
{
54+
await using var app = await StartAsync(app => app
55+
.MapGet("/cqrs", () =>
56+
CqrsStreamHttpResults.ServerSentEvents(CqrsTestStreams.CompletedAsync())));
57+
58+
using var response = await app.GetTestClient().GetAsync("/cqrs");
59+
60+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
61+
response.Content.Headers.ContentType?.MediaType.ShouldBe("text/event-stream");
62+
var chunks = await SseTestReader.ReadChunksAsync(response);
63+
chunks.Select(chunk => chunk.Kind).ShouldBe([
64+
CqrsStreamChunkKind.Started,
65+
CqrsStreamChunkKind.Progress,
66+
CqrsStreamChunkKind.Completed
67+
]);
68+
}
69+
5170
[Fact]
5271
public async Task RouteGroup_AppliesToEveryEndpointInTheGroup()
5372
{

‎ManagedCode.Communication.Tests/CQRS/CqrsStreamResultFactoryTests.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ public void Convert_ChunkStream_ProducesAResult()
3434
converted.ShouldNotBeNull();
3535
}
3636

37+
[Fact]
38+
public void ExplicitHttpResult_NullStream_ThrowsArgumentNullException()
39+
{
40+
IAsyncEnumerable<Chunk> updates = null!;
41+
42+
Should.Throw<ArgumentNullException>(() => CqrsStreamHttpResults.ServerSentEvents(updates));
43+
}
44+
3745
[Fact]
3846
public void Convert_NonChunkAsyncEnumerable_IsNotAStream()
3947
{

0 commit comments

Comments
 (0)