Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link

Copilot AI Oct 14, 2025

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.

Suggested change
using ManagedCode.Communication.Extensions;
using ManagedCode.Communication.AspNetCore.Extensions;

Copilot uses AI. Check for mistakes.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Dispose responses between Polly retries

The lambda passed to ResiliencePipeline.ExecuteAsync returns each HttpResponseMessage to Polly without disposing the ones that are retried. When a retry strategy handles non‑success status codes, every discarded response leaks its content stream and the associated socket until final GC, which can exhaust the HttpClient connection pool during transient outages. Dispose or buffer unsuccessful responses inside the delegate before returning so that retries do not leave dangling connections.

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
Copy link

Copilot AI Oct 14, 2025

Choose a reason for hiding this comment

The 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
<Description>Optional integrations for ManagedCode.Communication including Minimal API endpoint filters.</Description>
<PackageTags>managedcode;communication;result-pattern;minimal-api;endpoint-filter</PackageTags>
<Description>Optional integrations for ManagedCode.Communication including Minimal API endpoint filters, HttpClient result helpers, and Polly resilience pipelines.</Description>
<PackageTags>managedcode;communication;result-pattern;minimal-api;endpoint-filter;httpclient;polly;resilience;pipeline</PackageTags>

Copilot uses AI. Check for mistakes.

</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);
};
}
}
Loading