-
Notifications
You must be signed in to change notification settings - Fork 2
Add HttpClient result helpers with Polly integration #72
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
base: main
Are you sure you want to change the base?
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,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; | ||
|
||
/// <summary> | ||
/// Helpers that execute <see cref="HttpClient"/> requests and transform the responses into | ||
/// <see cref="ManagedCode.Communication.Result"/> instances. | ||
/// </summary> | ||
public static class ResultHttpClientExtensions | ||
{ | ||
/// <summary> | ||
/// Sends a request built by <paramref name="requestFactory"/> and converts the HTTP response into a | ||
/// <see cref="Result{T}"/>. When a <paramref name="pipeline"/> is provided the request is executed through it, | ||
/// enabling Polly resilience strategies such as retries or circuit breakers. | ||
/// </summary> | ||
/// <typeparam name="T">The JSON payload type that the endpoint returns in case of success.</typeparam> | ||
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param> | ||
/// <param name="requestFactory">Factory that creates a fresh <see cref="HttpRequestMessage"/> for each attempt.</param> | ||
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param> | ||
/// <param name="cancellationToken">Token that cancels the request execution.</param> | ||
public static Task<Result<T>> SendForResultAsync<T>( | ||
this HttpClient client, | ||
Func<HttpRequestMessage> requestFactory, | ||
ResiliencePipeline<HttpResponseMessage>? pipeline = null, | ||
CancellationToken cancellationToken = default) | ||
{ | ||
ArgumentNullException.ThrowIfNull(client); | ||
ArgumentNullException.ThrowIfNull(requestFactory); | ||
|
||
return SendCoreAsync( | ||
client, | ||
requestFactory, | ||
static response => response.FromJsonToResult<T>(), | ||
pipeline, | ||
cancellationToken); | ||
} | ||
|
||
/// <summary> | ||
/// Sends a request built by <paramref name="requestFactory"/> and converts the HTTP response into a | ||
/// <see cref="Result"/> without a payload. When a <paramref name="pipeline"/> is provided the request is executed | ||
/// through it, enabling Polly resilience strategies such as retries or circuit breakers. | ||
/// </summary> | ||
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param> | ||
/// <param name="requestFactory">Factory that creates a fresh <see cref="HttpRequestMessage"/> for each attempt.</param> | ||
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param> | ||
/// <param name="cancellationToken">Token that cancels the request execution.</param> | ||
public static Task<Result> SendForResultAsync( | ||
this HttpClient client, | ||
Func<HttpRequestMessage> requestFactory, | ||
ResiliencePipeline<HttpResponseMessage>? pipeline = null, | ||
CancellationToken cancellationToken = default) | ||
{ | ||
ArgumentNullException.ThrowIfNull(client); | ||
ArgumentNullException.ThrowIfNull(requestFactory); | ||
|
||
return SendCoreAsync( | ||
client, | ||
requestFactory, | ||
static response => response.FromRequestToResult(), | ||
pipeline, | ||
cancellationToken); | ||
} | ||
|
||
/// <summary> | ||
/// Performs a GET request for <paramref name="requestUri"/> and converts the response into a | ||
/// <see cref="Result{T}"/>. The optional <paramref name="pipeline"/> allows attaching Polly retry or circuit | ||
/// breaker strategies. | ||
/// </summary> | ||
/// <typeparam name="T">The JSON payload type that the endpoint returns in case of success.</typeparam> | ||
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param> | ||
/// <param name="requestUri">The request URI.</param> | ||
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param> | ||
/// <param name="cancellationToken">Token that cancels the request execution.</param> | ||
public static Task<Result<T>> GetAsResultAsync<T>( | ||
this HttpClient client, | ||
string requestUri, | ||
ResiliencePipeline<HttpResponseMessage>? pipeline = null, | ||
CancellationToken cancellationToken = default) | ||
{ | ||
ArgumentNullException.ThrowIfNull(client); | ||
ArgumentException.ThrowIfNullOrEmpty(requestUri); | ||
|
||
return client.SendForResultAsync<T>( | ||
() => new HttpRequestMessage(HttpMethod.Get, requestUri), | ||
pipeline, | ||
cancellationToken); | ||
} | ||
|
||
/// <summary> | ||
/// Performs a GET request for <paramref name="requestUri"/> and converts the response into a non generic | ||
/// <see cref="Result"/>. | ||
/// </summary> | ||
/// <param name="client">The <see cref="HttpClient"/> used to send the request.</param> | ||
/// <param name="requestUri">The request URI.</param> | ||
/// <param name="pipeline">Optional Polly resilience pipeline that wraps the HTTP invocation.</param> | ||
/// <param name="cancellationToken">Token that cancels the request execution.</param> | ||
public static Task<Result> GetAsResultAsync( | ||
this HttpClient client, | ||
string requestUri, | ||
ResiliencePipeline<HttpResponseMessage>? 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<TResponse> SendCoreAsync<TResponse>( | ||
HttpClient client, | ||
Func<HttpRequestMessage> requestFactory, | ||
Func<HttpResponseMessage, Task<TResponse>> convert, | ||
ResiliencePipeline<HttpResponseMessage>? 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); | ||
Comment on lines
+132
to
+136
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.
The lambda passed to Useful? React with 👍 / 👎. |
||
}, | ||
cancellationToken).ConfigureAwait(false); | ||
|
||
using (httpResponse) | ||
{ | ||
return await convert(httpResponse).ConfigureAwait(false); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,22 @@ | ||||||||||
<Project Sdk="Microsoft.NET.Sdk"> | ||||||||||
|
||||||||||
<PropertyGroup> | ||||||||||
<IsPackable>true</IsPackable> | ||||||||||
<DebugType>embedded</DebugType> | ||||||||||
</PropertyGroup> | ||||||||||
|
||||||||||
<!--NuGet--> | ||||||||||
<PropertyGroup> | ||||||||||
<Title>ManagedCode.Communication.Extensions</Title> | ||||||||||
<PackageId>ManagedCode.Communication.Extensions</PackageId> | ||||||||||
<Description>Optional integrations for ManagedCode.Communication including Minimal API endpoint filters.</Description> | ||||||||||
<PackageTags>managedcode;communication;result-pattern;minimal-api;endpoint-filter</PackageTags> | ||||||||||
Comment on lines
+12
to
+13
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. Update the package description and tags to also mention HttpClient helpers and Polly resilience (e.g., 'HttpClient result helpers; Polly resilience pipelines') so NuGet metadata reflects the new functionality.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||
</PropertyGroup> | ||||||||||
|
||||||||||
<ItemGroup> | ||||||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||||||||
<ProjectReference Include="..\ManagedCode.Communication.AspNetCore\ManagedCode.Communication.AspNetCore.csproj" /> | ||||||||||
<PackageReference Include="Polly" Version="8.4.2" /> | ||||||||||
</ItemGroup> | ||||||||||
|
||||||||||
</Project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
using System; | ||
using Microsoft.AspNetCore.Builder; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Routing; | ||
|
||
namespace ManagedCode.Communication.Extensions.MinimalApi; | ||
|
||
/// <summary> | ||
/// Extension helpers for wiring ManagedCode.Communication support into Minimal API route handlers. | ||
/// </summary> | ||
public static class CommunicationEndpointExtensions | ||
{ | ||
/// <summary> | ||
/// Adds <see cref="ResultEndpointFilter"/> to a specific <see cref="RouteHandlerBuilder"/> so that | ||
/// <c>Result</c>-returning handlers are converted into <see cref="Microsoft.AspNetCore.Http.IResult"/> automatically. | ||
/// </summary> | ||
/// <param name="builder">The endpoint builder.</param> | ||
/// <returns>The same builder instance to enable fluent configuration.</returns> | ||
public static RouteHandlerBuilder WithCommunicationResults(this RouteHandlerBuilder builder) | ||
{ | ||
ArgumentNullException.ThrowIfNull(builder); | ||
|
||
builder.AddEndpointFilterFactory(CreateFilter); | ||
return builder; | ||
} | ||
|
||
/// <summary> | ||
/// Adds <see cref="ResultEndpointFilter"/> to an entire <see cref="RouteGroupBuilder"/> so that every child endpoint | ||
/// inherits automatic <c>Result</c> to <see cref="Microsoft.AspNetCore.Http.IResult"/> conversion. | ||
/// </summary> | ||
/// <param name="builder">The group builder.</param> | ||
/// <returns>The same builder instance for chaining.</returns> | ||
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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<object?>; | ||
using AspNetResultFactory = System.Func<object, Microsoft.AspNetCore.Http.IResult>; | ||
|
||
namespace ManagedCode.Communication.Extensions.MinimalApi; | ||
|
||
/// <summary> | ||
/// Endpoint filter that converts <see cref="ManagedCode.Communication.Result"/> responses into Minimal API results. | ||
/// </summary> | ||
public sealed class ResultEndpointFilter : IEndpointFilter | ||
{ | ||
private static readonly ConcurrentDictionary<Type, AspNetResultFactory> ValueResultConverters = new(); | ||
|
||
/// <inheritdoc /> | ||
public async ValueTask<object?> 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); | ||
}; | ||
} | ||
} |
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.
The extension methods FromJsonToResult() and FromRequestToResult() are defined in the ASP.NET Core extensions, not in ManagedCode.Communication.Extensions. Replace the using with ManagedCode.Communication.AspNetCore.Extensions so this file compiles.
Copilot uses AI. Check for mistakes.