diff --git a/ManagedCode.Communication.Extensions/Http/ResultHttpClientExtensions.cs b/ManagedCode.Communication.Extensions/Http/ResultHttpClientExtensions.cs new file mode 100644 index 0000000..b5a2a57 --- /dev/null +++ b/ManagedCode.Communication.Extensions/Http/ResultHttpClientExtensions.cs @@ -0,0 +1,145 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Communication.Extensions; +using Polly; + +namespace ManagedCode.Communication.Extensions.Http; + +/// +/// Helpers that execute requests and transform the responses into +/// instances. +/// +public static class ResultHttpClientExtensions +{ + /// + /// Sends a request built by and converts the HTTP response into a + /// . When a is provided the request is executed through it, + /// enabling Polly resilience strategies such as retries or circuit breakers. + /// + /// The JSON payload type that the endpoint returns in case of success. + /// The used to send the request. + /// Factory that creates a fresh for each attempt. + /// Optional Polly resilience pipeline that wraps the HTTP invocation. + /// Token that cancels the request execution. + public static Task> SendForResultAsync( + this HttpClient client, + Func requestFactory, + ResiliencePipeline? pipeline = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestFactory); + + return SendCoreAsync( + client, + requestFactory, + static response => response.FromJsonToResult(), + pipeline, + cancellationToken); + } + + /// + /// Sends a request built by and converts the HTTP response into a + /// without a payload. When a is provided the request is executed + /// through it, enabling Polly resilience strategies such as retries or circuit breakers. + /// + /// The used to send the request. + /// Factory that creates a fresh for each attempt. + /// Optional Polly resilience pipeline that wraps the HTTP invocation. + /// Token that cancels the request execution. + public static Task SendForResultAsync( + this HttpClient client, + Func requestFactory, + ResiliencePipeline? pipeline = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestFactory); + + return SendCoreAsync( + client, + requestFactory, + static response => response.FromRequestToResult(), + pipeline, + cancellationToken); + } + + /// + /// Performs a GET request for and converts the response into a + /// . The optional allows attaching Polly retry or circuit + /// breaker strategies. + /// + /// The JSON payload type that the endpoint returns in case of success. + /// The used to send the request. + /// The request URI. + /// Optional Polly resilience pipeline that wraps the HTTP invocation. + /// Token that cancels the request execution. + public static Task> GetAsResultAsync( + this HttpClient client, + string requestUri, + ResiliencePipeline? pipeline = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentException.ThrowIfNullOrEmpty(requestUri); + + return client.SendForResultAsync( + () => new HttpRequestMessage(HttpMethod.Get, requestUri), + pipeline, + cancellationToken); + } + + /// + /// Performs a GET request for and converts the response into a non generic + /// . + /// + /// The used to send the request. + /// The request URI. + /// Optional Polly resilience pipeline that wraps the HTTP invocation. + /// Token that cancels the request execution. + public static Task GetAsResultAsync( + this HttpClient client, + string requestUri, + ResiliencePipeline? pipeline = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentException.ThrowIfNullOrEmpty(requestUri); + + return client.SendForResultAsync( + () => new HttpRequestMessage(HttpMethod.Get, requestUri), + pipeline, + cancellationToken); + } + + private static async Task SendCoreAsync( + HttpClient client, + Func requestFactory, + Func> convert, + ResiliencePipeline? pipeline, + CancellationToken cancellationToken) + { + if (pipeline is null) + { + using var request = requestFactory(); + using var directResponse = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await convert(directResponse).ConfigureAwait(false); + } + + var httpResponse = await pipeline.ExecuteAsync( + async cancellationToken => + { + using var request = requestFactory(); + return await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); + + using (httpResponse) + { + return await convert(httpResponse).ConfigureAwait(false); + } + } +} diff --git a/ManagedCode.Communication.Extensions/ManagedCode.Communication.Extensions.csproj b/ManagedCode.Communication.Extensions/ManagedCode.Communication.Extensions.csproj new file mode 100644 index 0000000..1693656 --- /dev/null +++ b/ManagedCode.Communication.Extensions/ManagedCode.Communication.Extensions.csproj @@ -0,0 +1,22 @@ + + + + true + embedded + + + + + ManagedCode.Communication.Extensions + ManagedCode.Communication.Extensions + Optional integrations for ManagedCode.Communication including Minimal API endpoint filters. + managedcode;communication;result-pattern;minimal-api;endpoint-filter + + + + + + + + + diff --git a/ManagedCode.Communication.Extensions/MinimalApi/CommunicationEndpointExtensions.cs b/ManagedCode.Communication.Extensions/MinimalApi/CommunicationEndpointExtensions.cs new file mode 100644 index 0000000..8e7a6a7 --- /dev/null +++ b/ManagedCode.Communication.Extensions/MinimalApi/CommunicationEndpointExtensions.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace ManagedCode.Communication.Extensions.MinimalApi; + +/// +/// Extension helpers for wiring ManagedCode.Communication support into Minimal API route handlers. +/// +public static class CommunicationEndpointExtensions +{ + /// + /// Adds to a specific so that + /// Result-returning handlers are converted into automatically. + /// + /// The endpoint builder. + /// The same builder instance to enable fluent configuration. + public static RouteHandlerBuilder WithCommunicationResults(this RouteHandlerBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddEndpointFilterFactory(CreateFilter); + return builder; + } + + /// + /// Adds to an entire so that every child endpoint + /// inherits automatic Result to conversion. + /// + /// The group builder. + /// The same builder instance for chaining. + public static RouteGroupBuilder WithCommunicationResults(this RouteGroupBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddEndpointFilterFactory(CreateFilter); + return builder; + } + + private static EndpointFilterDelegate CreateFilter(EndpointFilterFactoryContext context, EndpointFilterDelegate next) + { + var filter = new ResultEndpointFilter(); + return invocationContext => filter.InvokeAsync(invocationContext, next); + } +} diff --git a/ManagedCode.Communication.Extensions/MinimalApi/ResultEndpointFilter.cs b/ManagedCode.Communication.Extensions/MinimalApi/ResultEndpointFilter.cs new file mode 100644 index 0000000..7b86cc7 --- /dev/null +++ b/ManagedCode.Communication.Extensions/MinimalApi/ResultEndpointFilter.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Communication.AspNetCore.Extensions; +using ManagedCode.Communication.Constants; +using Microsoft.AspNetCore.Http; +using HttpResults = Microsoft.AspNetCore.Http.Results; +using AspNetResult = Microsoft.AspNetCore.Http.IResult; +using CommunicationResult = ManagedCode.Communication.IResult; +using CommunicationResultOfObject = ManagedCode.Communication.IResult; +using AspNetResultFactory = System.Func; + +namespace ManagedCode.Communication.Extensions.MinimalApi; + +/// +/// Endpoint filter that converts responses into Minimal API results. +/// +public sealed class ResultEndpointFilter : IEndpointFilter +{ + private static readonly ConcurrentDictionary ValueResultConverters = new(); + + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + var result = await next(context).ConfigureAwait(false); + + if (result is null) + { + return null; + } + + return ConvertResult(result); + } + + private static object ConvertResult(object result) + { + if (result is AspNetResult aspNetResult) + { + return aspNetResult; + } + + if (result is ManagedCode.Communication.Result nonGenericResult) + { + return nonGenericResult.ToHttpResult(); + } + + if (result is CommunicationResultOfObject valueResult) + { + return valueResult.IsSuccess + ? HttpResults.Ok(valueResult.Value) + : CreateProblem(valueResult.Problem); + } + + if (TryConvertValueResult(result, out var converted)) + { + return converted; + } + + if (result is CommunicationResult communicationResult) + { + return communicationResult.IsSuccess + ? HttpResults.NoContent() + : CreateProblem(communicationResult.Problem); + } + + return result; + } + + private static AspNetResult CreateProblem(Problem? problem) + { + var normalized = NormalizeProblem(problem); + + return HttpResults.Problem( + title: normalized.Title, + detail: normalized.Detail, + statusCode: normalized.StatusCode, + type: normalized.Type, + instance: normalized.Instance, + extensions: normalized.Extensions + ); + } + + private static Problem NormalizeProblem(Problem? problem) + { + if (problem is null || IsGeneric(problem)) + { + return Problem.Create("Operation failed", "Unknown error occurred", 500); + } + + return problem; + } + + private static bool IsGeneric(Problem problem) + { + return string.Equals(problem.Title, ProblemConstants.Titles.Error, StringComparison.OrdinalIgnoreCase) + && string.Equals(problem.Detail, ProblemConstants.Messages.GenericError, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryConvertValueResult(object result, out AspNetResult converted) + { + converted = null!; + + var type = result.GetType(); + if (!typeof(CommunicationResult).IsAssignableFrom(type) || type == typeof(Result)) + { + return false; + } + + var converter = ValueResultConverters.GetOrAdd(type, CreateConverter); + converted = converter(result); + return true; + } + + private static AspNetResultFactory CreateConverter(Type type) + { + var valueProperty = type.GetProperty("Value"); + + return valueProperty is null + ? result => + { + var communicationResult = (CommunicationResult)result; + return communicationResult.IsSuccess + ? HttpResults.NoContent() + : CreateProblem(communicationResult.Problem); + } + : result => + { + var communicationResult = (CommunicationResult)result; + if (communicationResult.IsSuccess) + { + var value = valueProperty.GetValue(result); + return HttpResults.Ok(value); + } + + return CreateProblem(communicationResult.Problem); + }; + } +} diff --git a/ManagedCode.Communication.Tests/AspNetCore/Extensions/ResultEndpointFilterTests.cs b/ManagedCode.Communication.Tests/AspNetCore/Extensions/ResultEndpointFilterTests.cs new file mode 100644 index 0000000..21f7596 --- /dev/null +++ b/ManagedCode.Communication.Tests/AspNetCore/Extensions/ResultEndpointFilterTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Net; +using System.Net.Http.Json; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Communication.Extensions.MinimalApi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using HttpResults = Microsoft.AspNetCore.Http.Results; +using Shouldly; +using Xunit; + +namespace ManagedCode.Communication.Tests.AspNetCore.Extensions; + +public class ResultEndpointFilterTests +{ + [Fact] + public async Task WithCommunicationResults_SuccessResult_ReturnsOkResponse() + { + await using var app = await CreateAppAsync(static app => + { + app.MapGet("/success", () => Result.Succeed("pong")).WithCommunicationResults(); + }); + + var response = await app.GetTestClient().GetAsync("/success"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var payload = await response.Content.ReadFromJsonAsync(); + payload.ShouldBe("pong"); + } + + [Fact] + public async Task WithCommunicationResults_FailedResult_ReturnsProblem() + { + await using var app = await CreateAppAsync(static app => + { + app.MapGet("/failed", () => Result.Fail()).WithCommunicationResults(); + }); + + var response = await app.GetTestClient().GetAsync("/failed"); + response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.ShouldNotBeNull(); + problem!.StatusCode.ShouldBe(500); + problem.Title.ShouldBe("Operation failed"); + } + + [Fact] + public async Task WithCommunicationResults_GroupBuilder_AppliesFilterToAllEndpoints() + { + await using var app = await CreateAppAsync(static app => + { + var group = app.MapGroup("/api").WithCommunicationResults(); + group.MapGet("/value", () => Result.Succeed(42)); + group.MapGet("/error", () => Result.Fail(Problem.Create("Not Found", "missing", 404))); + }); + + var client = app.GetTestClient(); + + var success = await client.GetAsync("/api/value"); + success.StatusCode.ShouldBe(HttpStatusCode.OK); + (await success.Content.ReadFromJsonAsync()).ShouldBe(42); + + var failure = await client.GetAsync("/api/error"); + failure.StatusCode.ShouldBe(HttpStatusCode.NotFound); + var error = await failure.Content.ReadFromJsonAsync(); + error.ShouldNotBeNull(); + error!.StatusCode.ShouldBe(404); + error.Title.ShouldBe("Not Found"); + } + + [Fact] + public async Task WithCommunicationResults_PassesThroughExistingIResult() + { + await using var app = await CreateAppAsync(static app => + { + app.MapGet("/native", () => HttpResults.Created("/resource", new { Value = 1 })) + .WithCommunicationResults(); + }); + + var response = await app.GetTestClient().GetAsync("/native"); + response.StatusCode.ShouldBe(HttpStatusCode.Created); + } + + private static async Task CreateAppAsync(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + configure(app); + await app.StartAsync(); + return app; + } +} diff --git a/ManagedCode.Communication.Tests/Extensions/ResultHttpClientExtensionsTests.cs b/ManagedCode.Communication.Tests/Extensions/ResultHttpClientExtensionsTests.cs new file mode 100644 index 0000000..598973f --- /dev/null +++ b/ManagedCode.Communication.Tests/Extensions/ResultHttpClientExtensionsTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ManagedCode.Communication; +using ManagedCode.Communication.Extensions.Http; +using Polly; +using Polly.Retry; +using Shouldly; +using Xunit; + +namespace ManagedCode.Communication.Tests.Extensions; + +public class ResultHttpClientExtensionsTests +{ + [Fact] + public async Task SendForResultAsync_WithSuccessResponse_ReturnsSuccessResult() + { + using var client = new HttpClient(new StubHandler(static (_, _) => + { + var payload = JsonSerializer.Serialize(Result.Succeed("pong")); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + return Task.FromResult(response); + })); + + var result = await client.SendForResultAsync( + static () => new HttpRequestMessage(HttpMethod.Get, "https://example.com")); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe("pong"); + } + + [Fact] + public async Task SendForResultAsync_WithFailureResponse_ReturnsFailedResult() + { + using var client = new HttpClient(new StubHandler(static (_, _) => + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("invalid request", Encoding.UTF8, "text/plain") + }; + return Task.FromResult(response); + })); + + var result = await client.SendForResultAsync( + static () => new HttpRequestMessage(HttpMethod.Post, "https://example.com")); + + result.IsFailed.ShouldBeTrue(); + result.Problem.ShouldNotBeNull(); + result.Problem!.StatusCode.ShouldBe((int)HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SendForResultAsync_WithRetryPipeline_RetriesUntilSuccess() + { + var attempt = 0; + using var client = new HttpClient(new StubHandler((_, _) => + { + attempt++; + + if (attempt == 1) + { + var failure = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("down", Encoding.UTF8, "text/plain") + }; + return Task.FromResult(failure); + } + + var payload = JsonSerializer.Serialize(Result.Succeed(42)); + var success = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + return Task.FromResult(success); + })); + + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = TimeSpan.Zero, + ShouldHandle = new PredicateBuilder() + .HandleResult(static response => !response.IsSuccessStatusCode) + }) + .Build(); + + var result = await client.SendForResultAsync( + static () => new HttpRequestMessage(HttpMethod.Get, "https://example.com"), + pipeline); + + attempt.ShouldBe(2); + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBe(42); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public StubHandler(Func> handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } + } +} diff --git a/ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj b/ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj index 258871b..10c221a 100644 --- a/ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj +++ b/ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj @@ -32,6 +32,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -49,6 +50,7 @@ + diff --git a/ManagedCode.Communication.slnx b/ManagedCode.Communication.slnx index d6d80ae..565ee87 100644 --- a/ManagedCode.Communication.slnx +++ b/ManagedCode.Communication.slnx @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md index 128daea..1bc440d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,9 @@ Install-Package ManagedCode.Communication # ASP.NET Core integration Install-Package ManagedCode.Communication.AspNetCore +# Minimal API extensions +Install-Package ManagedCode.Communication.Extensions + # Orleans integration Install-Package ManagedCode.Communication.Orleans ``` @@ -129,6 +132,9 @@ dotnet add package ManagedCode.Communication # ASP.NET Core integration dotnet add package ManagedCode.Communication.AspNetCore +# Minimal API extensions +dotnet add package ManagedCode.Communication.Extensions + # Orleans integration dotnet add package ManagedCode.Communication.Orleans ``` @@ -138,6 +144,7 @@ dotnet add package ManagedCode.Communication.Orleans ```xml + ``` @@ -163,6 +170,71 @@ builder.Services.ConfigureCommunication(); var app = builder.Build(); ``` +### Minimal API Result Mapping + +Add the optional `ManagedCode.Communication.Extensions` package to bridge Minimal API endpoints with the Result pattern. The +package provides the `ResultEndpointFilter` and a fluent helper `WithCommunicationResults` that wraps the endpoint builder and +returns `IResult` instances automatically: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCommunication(); + +var app = builder.Build(); + +// Apply the filter to a single endpoint +app.MapGet("/orders/{id}", async (Guid id, IOrderService orders) => + await orders.GetAsync(id)) + .WithCommunicationResults(); + +// Or apply it to a group so every route inherits the conversion +app.MapGroup("/orders") + .WithCommunicationResults() + .MapPost(string.Empty, async (CreateOrder command, IOrderService orders) => + await orders.CreateAsync(command)); + +app.Run(); +``` + +Handlers can return any `Result` or `Result` instance and the filter will reuse the existing ASP.NET Core converters so +you do not need to write manual `IResult` translations. + +### Resilient HTTP Clients + +The extensions package also ships helpers that turn `HttpClient` calls directly into `Result` instances and optionally run +them through Polly resilience pipelines: + +```csharp +using ManagedCode.Communication.Extensions.Http; +using Polly; +using Polly.Retry; + +var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromMilliseconds(200), + ShouldHandle = new PredicateBuilder() + .HandleResult(response => !response.IsSuccessStatusCode) + }) + .Build(); + +var result = await httpClient.SendForResultAsync( + () => new HttpRequestMessage(HttpMethod.Get, $"/orders/{orderId}"), + pipeline); + +if (result.IsSuccess) +{ + // access result.Value without manually reading the HTTP payload +} +``` + +The helpers use the existing `HttpResponseMessage` converters, so non-success status codes automatically map to a +`Problem` with the response body and status code. +success responses map to `200 OK`/`204 No Content` while failures become RFC 7807 problem details. Native `Microsoft.AspNetCore.Http.IResult` +responses pass through unchanged, so you can mix and match traditional Minimal API patterns with ManagedCode.Communication results. + ### Console Application Setup ```csharp