Skip to content

Commit de3a09f

Browse files
committed
Merge remote-tracking branch 'origin/codex/add-innovative-features-to-extensions'
# Conflicts: # ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj
2 parents b7a804d + 450c124 commit de3a09f

File tree

9 files changed

+648
-0
lines changed

9 files changed

+648
-0
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using ManagedCode.Communication;
6+
using ManagedCode.Communication.Extensions;
7+
using Polly;
8+
9+
namespace ManagedCode.Communication.Extensions.Http;
10+
11+
/// <summary>
12+
/// Helpers that execute <see cref="HttpClient"/> requests and transform the responses into
13+
/// <see cref="ManagedCode.Communication.Result"/> instances.
14+
/// </summary>
15+
public static class ResultHttpClientExtensions
16+
{
17+
/// <summary>
18+
/// Sends a request built by <paramref name="requestFactory"/> and converts the HTTP response into a
19+
/// <see cref="Result{T}"/>. When a <paramref name="pipeline"/> is provided the request is executed through it,
20+
/// enabling Polly resilience strategies such as retries or circuit breakers.
21+
/// </summary>
22+
/// <typeparam name="T">The JSON payload type that the endpoint returns in case of success.</typeparam>
23+
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param>
24+
/// <param name="requestFactory">Factory that creates a fresh <see cref="HttpRequestMessage"/> for each attempt.</param>
25+
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param>
26+
/// <param name="cancellationToken">Token that cancels the request execution.</param>
27+
public static Task<Result<T>> SendForResultAsync<T>(
28+
this HttpClient client,
29+
Func<HttpRequestMessage> requestFactory,
30+
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
31+
CancellationToken cancellationToken = default)
32+
{
33+
ArgumentNullException.ThrowIfNull(client);
34+
ArgumentNullException.ThrowIfNull(requestFactory);
35+
36+
return SendCoreAsync(
37+
client,
38+
requestFactory,
39+
static response => response.FromJsonToResult<T>(),
40+
pipeline,
41+
cancellationToken);
42+
}
43+
44+
/// <summary>
45+
/// Sends a request built by <paramref name="requestFactory"/> and converts the HTTP response into a
46+
/// <see cref="Result"/> without a payload. When a <paramref name="pipeline"/> is provided the request is executed
47+
/// through it, enabling Polly resilience strategies such as retries or circuit breakers.
48+
/// </summary>
49+
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param>
50+
/// <param name="requestFactory">Factory that creates a fresh <see cref="HttpRequestMessage"/> for each attempt.</param>
51+
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param>
52+
/// <param name="cancellationToken">Token that cancels the request execution.</param>
53+
public static Task<Result> SendForResultAsync(
54+
this HttpClient client,
55+
Func<HttpRequestMessage> requestFactory,
56+
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
57+
CancellationToken cancellationToken = default)
58+
{
59+
ArgumentNullException.ThrowIfNull(client);
60+
ArgumentNullException.ThrowIfNull(requestFactory);
61+
62+
return SendCoreAsync(
63+
client,
64+
requestFactory,
65+
static response => response.FromRequestToResult(),
66+
pipeline,
67+
cancellationToken);
68+
}
69+
70+
/// <summary>
71+
/// Performs a GET request for <paramref name="requestUri"/> and converts the response into a
72+
/// <see cref="Result{T}"/>. The optional <paramref name="pipeline"/> allows attaching Polly retry or circuit
73+
/// breaker strategies.
74+
/// </summary>
75+
/// <typeparam name="T">The JSON payload type that the endpoint returns in case of success.</typeparam>
76+
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param>
77+
/// <param name="requestUri">The request URI.</param>
78+
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param>
79+
/// <param name="cancellationToken">Token that cancels the request execution.</param>
80+
public static Task<Result<T>> GetAsResultAsync<T>(
81+
this HttpClient client,
82+
string requestUri,
83+
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
84+
CancellationToken cancellationToken = default)
85+
{
86+
ArgumentNullException.ThrowIfNull(client);
87+
ArgumentException.ThrowIfNullOrEmpty(requestUri);
88+
89+
return client.SendForResultAsync<T>(
90+
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
91+
pipeline,
92+
cancellationToken);
93+
}
94+
95+
/// <summary>
96+
/// Performs a GET request for <paramref name="requestUri"/> and converts the response into a non generic
97+
/// <see cref="Result"/>.
98+
/// </summary>
99+
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param>
100+
/// <param name="requestUri">The request URI.</param>
101+
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param>
102+
/// <param name="cancellationToken">Token that cancels the request execution.</param>
103+
public static Task<Result> GetAsResultAsync(
104+
this HttpClient client,
105+
string requestUri,
106+
ResiliencePipeline<HttpResponseMessage>? pipeline = null,
107+
CancellationToken cancellationToken = default)
108+
{
109+
ArgumentNullException.ThrowIfNull(client);
110+
ArgumentException.ThrowIfNullOrEmpty(requestUri);
111+
112+
return client.SendForResultAsync(
113+
() => new HttpRequestMessage(HttpMethod.Get, requestUri),
114+
pipeline,
115+
cancellationToken);
116+
}
117+
118+
private static async Task<TResponse> SendCoreAsync<TResponse>(
119+
HttpClient client,
120+
Func<HttpRequestMessage> requestFactory,
121+
Func<HttpResponseMessage, Task<TResponse>> convert,
122+
ResiliencePipeline<HttpResponseMessage>? pipeline,
123+
CancellationToken cancellationToken)
124+
{
125+
if (pipeline is null)
126+
{
127+
using var request = requestFactory();
128+
using var directResponse = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
129+
return await convert(directResponse).ConfigureAwait(false);
130+
}
131+
132+
var httpResponse = await pipeline.ExecuteAsync(
133+
async cancellationToken =>
134+
{
135+
using var request = requestFactory();
136+
return await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
137+
},
138+
cancellationToken).ConfigureAwait(false);
139+
140+
using (httpResponse)
141+
{
142+
return await convert(httpResponse).ConfigureAwait(false);
143+
}
144+
}
145+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<IsPackable>true</IsPackable>
5+
<DebugType>embedded</DebugType>
6+
</PropertyGroup>
7+
8+
<!--NuGet-->
9+
<PropertyGroup>
10+
<Title>ManagedCode.Communication.Extensions</Title>
11+
<PackageId>ManagedCode.Communication.Extensions</PackageId>
12+
<Description>Optional integrations for ManagedCode.Communication including Minimal API endpoint filters.</Description>
13+
<PackageTags>managedcode;communication;result-pattern;minimal-api;endpoint-filter</PackageTags>
14+
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
18+
<ProjectReference Include="..\ManagedCode.Communication.AspNetCore\ManagedCode.Communication.AspNetCore.csproj" />
19+
<PackageReference Include="Polly" Version="8.4.2" />
20+
</ItemGroup>
21+
22+
</Project>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Routing;
5+
6+
namespace ManagedCode.Communication.Extensions.MinimalApi;
7+
8+
/// <summary>
9+
/// Extension helpers for wiring ManagedCode.Communication support into Minimal API route handlers.
10+
/// </summary>
11+
public static class CommunicationEndpointExtensions
12+
{
13+
/// <summary>
14+
/// Adds <see cref="ResultEndpointFilter"/> to a specific <see cref="RouteHandlerBuilder"/> so that
15+
/// <c>Result</c>-returning handlers are converted into <see cref="Microsoft.AspNetCore.Http.IResult"/> automatically.
16+
/// </summary>
17+
/// <param name="builder">The endpoint builder.</param>
18+
/// <returns>The same builder instance to enable fluent configuration.</returns>
19+
public static RouteHandlerBuilder WithCommunicationResults(this RouteHandlerBuilder builder)
20+
{
21+
ArgumentNullException.ThrowIfNull(builder);
22+
23+
builder.AddEndpointFilterFactory(CreateFilter);
24+
return builder;
25+
}
26+
27+
/// <summary>
28+
/// Adds <see cref="ResultEndpointFilter"/> to an entire <see cref="RouteGroupBuilder"/> so that every child endpoint
29+
/// inherits automatic <c>Result</c> to <see cref="Microsoft.AspNetCore.Http.IResult"/> conversion.
30+
/// </summary>
31+
/// <param name="builder">The group builder.</param>
32+
/// <returns>The same builder instance for chaining.</returns>
33+
public static RouteGroupBuilder WithCommunicationResults(this RouteGroupBuilder builder)
34+
{
35+
ArgumentNullException.ThrowIfNull(builder);
36+
37+
builder.AddEndpointFilterFactory(CreateFilter);
38+
return builder;
39+
}
40+
41+
private static EndpointFilterDelegate CreateFilter(EndpointFilterFactoryContext context, EndpointFilterDelegate next)
42+
{
43+
var filter = new ResultEndpointFilter();
44+
return invocationContext => filter.InvokeAsync(invocationContext, next);
45+
}
46+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Threading.Tasks;
4+
using ManagedCode.Communication;
5+
using ManagedCode.Communication.AspNetCore.Extensions;
6+
using ManagedCode.Communication.Constants;
7+
using Microsoft.AspNetCore.Http;
8+
using HttpResults = Microsoft.AspNetCore.Http.Results;
9+
using AspNetResult = Microsoft.AspNetCore.Http.IResult;
10+
using CommunicationResult = ManagedCode.Communication.IResult;
11+
using CommunicationResultOfObject = ManagedCode.Communication.IResult<object?>;
12+
using AspNetResultFactory = System.Func<object, Microsoft.AspNetCore.Http.IResult>;
13+
14+
namespace ManagedCode.Communication.Extensions.MinimalApi;
15+
16+
/// <summary>
17+
/// Endpoint filter that converts <see cref="ManagedCode.Communication.Result"/> responses into Minimal API results.
18+
/// </summary>
19+
public sealed class ResultEndpointFilter : IEndpointFilter
20+
{
21+
private static readonly ConcurrentDictionary<Type, AspNetResultFactory> ValueResultConverters = new();
22+
23+
/// <inheritdoc />
24+
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
25+
{
26+
ArgumentNullException.ThrowIfNull(context);
27+
ArgumentNullException.ThrowIfNull(next);
28+
29+
var result = await next(context).ConfigureAwait(false);
30+
31+
if (result is null)
32+
{
33+
return null;
34+
}
35+
36+
return ConvertResult(result);
37+
}
38+
39+
private static object ConvertResult(object result)
40+
{
41+
if (result is AspNetResult aspNetResult)
42+
{
43+
return aspNetResult;
44+
}
45+
46+
if (result is ManagedCode.Communication.Result nonGenericResult)
47+
{
48+
return nonGenericResult.ToHttpResult();
49+
}
50+
51+
if (result is CommunicationResultOfObject valueResult)
52+
{
53+
return valueResult.IsSuccess
54+
? HttpResults.Ok(valueResult.Value)
55+
: CreateProblem(valueResult.Problem);
56+
}
57+
58+
if (TryConvertValueResult(result, out var converted))
59+
{
60+
return converted;
61+
}
62+
63+
if (result is CommunicationResult communicationResult)
64+
{
65+
return communicationResult.IsSuccess
66+
? HttpResults.NoContent()
67+
: CreateProblem(communicationResult.Problem);
68+
}
69+
70+
return result;
71+
}
72+
73+
private static AspNetResult CreateProblem(Problem? problem)
74+
{
75+
var normalized = NormalizeProblem(problem);
76+
77+
return HttpResults.Problem(
78+
title: normalized.Title,
79+
detail: normalized.Detail,
80+
statusCode: normalized.StatusCode,
81+
type: normalized.Type,
82+
instance: normalized.Instance,
83+
extensions: normalized.Extensions
84+
);
85+
}
86+
87+
private static Problem NormalizeProblem(Problem? problem)
88+
{
89+
if (problem is null || IsGeneric(problem))
90+
{
91+
return Problem.Create("Operation failed", "Unknown error occurred", 500);
92+
}
93+
94+
return problem;
95+
}
96+
97+
private static bool IsGeneric(Problem problem)
98+
{
99+
return string.Equals(problem.Title, ProblemConstants.Titles.Error, StringComparison.OrdinalIgnoreCase)
100+
&& string.Equals(problem.Detail, ProblemConstants.Messages.GenericError, StringComparison.OrdinalIgnoreCase);
101+
}
102+
103+
private static bool TryConvertValueResult(object result, out AspNetResult converted)
104+
{
105+
converted = null!;
106+
107+
var type = result.GetType();
108+
if (!typeof(CommunicationResult).IsAssignableFrom(type) || type == typeof(Result))
109+
{
110+
return false;
111+
}
112+
113+
var converter = ValueResultConverters.GetOrAdd(type, CreateConverter);
114+
converted = converter(result);
115+
return true;
116+
}
117+
118+
private static AspNetResultFactory CreateConverter(Type type)
119+
{
120+
var valueProperty = type.GetProperty("Value");
121+
122+
return valueProperty is null
123+
? result =>
124+
{
125+
var communicationResult = (CommunicationResult)result;
126+
return communicationResult.IsSuccess
127+
? HttpResults.NoContent()
128+
: CreateProblem(communicationResult.Problem);
129+
}
130+
: result =>
131+
{
132+
var communicationResult = (CommunicationResult)result;
133+
if (communicationResult.IsSuccess)
134+
{
135+
var value = valueProperty.GetValue(result);
136+
return HttpResults.Ok(value);
137+
}
138+
139+
return CreateProblem(communicationResult.Problem);
140+
};
141+
}
142+
}

0 commit comments

Comments
 (0)