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
2 changes: 2 additions & 0 deletions .github/workflows/test-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ jobs:
with:
filename: dist/tests/${{ env.SANITIZED_LIBVERSION }}/coverage.xml
badge: true
fail_below_min: true
format: markdown
output: file
thresholds: "95 100"
if: ${{ always() }}

- name: Attach test coverage report to action summary
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsPackable>false</IsPackable>
<IncludeBuildOutput>false</IncludeBuildOutput>
<NoWarn>RS2007</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Simplification;

namespace CSharpFunctionalExtensions.HttpResults.Generators;

/// <summary>
/// Migrates a result error mapper to request-service resolution by replacing its directly implemented
/// IResultErrorMapper interface with IServiceResultErrorMapper and adding the required imports.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ServiceMapperCodeFixProvider)), Shared]
public sealed class ServiceMapperCodeFixProvider : CodeFixProvider
{
private const string ResultMapperMetadataName = "CSharpFunctionalExtensions.HttpResults.IResultErrorMapper`2";
private const string ServiceResultMapperMetadataName =
"CSharpFunctionalExtensions.HttpResults.IServiceResultErrorMapper`2";

public override ImmutableArray<string> FixableDiagnosticIds => ["CFEHTTPR004"];

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
var mapperDefinition = semanticModel?.Compilation.GetTypeByMetadataName(ResultMapperMetadataName);
var serviceMapperDefinition = semanticModel?.Compilation.GetTypeByMetadataName(ServiceResultMapperMetadataName);
var classDeclaration = root?.FindNode(context.Span).FirstAncestorOrSelf<ClassDeclarationSyntax>();

if (
root is null
|| semanticModel is null
|| mapperDefinition is null
|| serviceMapperDefinition is null
|| classDeclaration?.BaseList is null
)
return;

var mapperBase = classDeclaration.BaseList.Types.FirstOrDefault(baseType =>
semanticModel.GetTypeInfo(baseType.Type, context.CancellationToken).Type is INamedTypeSymbol type
&& SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, mapperDefinition)
);

if (mapperBase is null)
return;

context.RegisterCodeFix(
CodeAction.Create(
"Use IServiceResultErrorMapper (generated mappings require httpContext on failure)",
cancellationToken =>
ReplaceInterfaceAsync(
context.Document,
root,
semanticModel,
serviceMapperDefinition,
mapperBase,
cancellationToken
),
nameof(ServiceMapperCodeFixProvider)
),
context.Diagnostics
);
}

private static Task<Document> ReplaceInterfaceAsync(
Document document,
SyntaxNode root,
SemanticModel semanticModel,
INamedTypeSymbol serviceMapperDefinition,
BaseTypeSyntax mapperBase,
CancellationToken cancellationToken
)
{
// RegisterCodeFixesAsync only calls this method for a semantically resolved closed mapper interface.
var mapperType = (INamedTypeSymbol)semanticModel.GetTypeInfo(mapperBase.Type, cancellationToken).Type!;

var serviceMapperType = serviceMapperDefinition.Construct(mapperType.TypeArguments.ToArray());
var replacement = ((TypeSyntax)SyntaxGenerator.GetGenerator(document).TypeExpression(serviceMapperType))
.WithAdditionalAnnotations(Simplifier.Annotation, Simplifier.AddImportsAnnotation)
.WithTriviaFrom(mapperBase.Type);

return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(mapperBase.Type, replacement)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;

public sealed record DocumentNotFoundError(string DocumentId);
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;

/// <summary>
/// Service variant of an error mapper: resolved from request services, so constructor injection works.
/// </summary>
public sealed class DocumentNotFoundErrorMapper(DocumentationLinkProvider linkProvider)
: IServiceResultErrorMapper<DocumentNotFoundError, ProblemHttpResult>
{
public ProblemHttpResult Map(DocumentNotFoundError error)
{
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Document not found",
Type = linkProvider.For("document-not-found"),
Detail = $"Document with Id {error.DocumentId} could not be found.",
};

return TypedResults.Problem(problemDetails);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;

/// <summary>Builds documentation links and is injected into the service mapper.</summary>
public sealed class DocumentationLinkProvider
{
public string For(string topic) => $"https://docs.example.com/errors/{topic}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using CSharpFunctionalExtensions;
using Microsoft.AspNetCore.Http.HttpResults;

namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;

public static class DocumentsEndpoint
{
public static IEndpointRouteBuilder MapDocuments(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/documents/{id}", Handle).WithName(nameof(DocumentsEndpoint));

return endpoints;
}

private static Results<Ok<string>, ProblemHttpResult> Handle(string id, HttpContext httpContext)
{
return Result.Failure<string, DocumentNotFoundError>(new DocumentNotFoundError(id)).ToOkHttpResult(httpContext);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;

namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider;

/// <summary>
/// Example integration with ASP.NET Core's <see cref="ProblemDetailsFactory" />. The generator discovers
/// this provider and registers it as a scoped <see cref="IResultProblemDetailsProvider" /> automatically.
/// </summary>
public sealed class ProblemDetailsFactoryProvider(ProblemDetailsFactory problemDetailsFactory)
: IResultProblemDetailsProvider
{
public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode)
{
var problemDetails = problemDetailsFactory.CreateProblemDetails(httpContext, statusCode, detail: error);
problemDetails.Extensions["source"] = "custom-provider";
return problemDetails;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using CSharpFunctionalExtensions;
using CSharpFunctionalExtensions.HttpResults.ResultExtensions;
using Microsoft.AspNetCore.Http.HttpResults;

namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider;

/// <summary>
/// The generator discovers and registers the factory-backed IResultProblemDetailsProvider. Its presence
/// enables the generated context overloads for built-in string-error results.
/// </summary>
public static class ProblemDetailsProviderEndpoint
{
public static IEndpointRouteBuilder MapProblemDetailsProvider(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/problem-details-provider", Handle).WithName(nameof(ProblemDetailsProviderEndpoint));

return endpoints;
}

private static Results<Ok<int>, ProblemHttpResult> Handle(HttpContext httpContext)
{
return Result
.Failure<int>("Example failure")
.ToOkHttpResult(
httpContext,
failureStatusCode: 400,
customizeProblemDetails: problemDetails =>
problemDetails.Extensions["source"] = "custom-problem-details-example"
);
}
}
10 changes: 10 additions & 0 deletions CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using CSharpFunctionalExtensions.HttpResults;
using CSharpFunctionalExtensions.HttpResults.Examples;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.CRUD;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.CustomError;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.FileStream;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
Expand Down Expand Up @@ -35,6 +38,11 @@

builder.Services.AddSingleton<BookService>();

// Registers ASP.NET Core's ProblemDetailsFactory used by the example provider.
builder.Services.AddControllers();
builder.Services.AddCSharpFunctionalExtensionsHttpResults();
builder.Services.AddSingleton<DocumentationLinkProvider>();

builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();

Expand All @@ -55,5 +63,7 @@
app.MapBooksGroup();
app.MapCheckAge();
app.MapStream();
app.MapProblemDetailsProvider();
app.MapDocuments();

app.Run();
14 changes: 14 additions & 0 deletions CSharpFunctionalExtensions.HttpResults.Examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ An example for a `FileStreamResult` is available under [`Features/FileStream`](.

An example for a custom error `AgeRestrictionError` that is used when the age validation detects an age below 18 is available under [`Features/CustomError`](./Features/CustomError).

### Dependency injection

[`Features/DependencyInjection`](./Features/DependencyInjection) shows an
`IServiceResultErrorMapper<,>` with constructor injection. Its endpoint passes the current `HttpContext` to the
generated mapping overload so the mapper can be resolved from request services.

### Problem-details provider

[`Features/ProblemDetailsProvider`](./Features/ProblemDetailsProvider) shows how an application-owned
`IResultProblemDetailsProvider` can delegate to ASP.NET Core MVC's `ProblemDetailsFactory`. The provider is
discovered and registered as scoped by `AddCSharpFunctionalExtensionsHttpResults()`; `Program.cs` calls
`AddControllers()` to register the MVC factory. The endpoint also demonstrates the per-call callback, which runs
after the provider.

## Run

You can run this project and access the OpenApi documentation under `/scalar/v1`.
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@
ReferenceOutputAssembly="false"
/>
<ProjectReference Include="..\CSharpFunctionalExtensions.HttpResults.Generators\CSharpFunctionalExtensions.HttpResults.Generators.csproj" />
<ProjectReference Include="..\CSharpFunctionalExtensions.HttpResults.CodeFixes\CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj" />
</ItemGroup>
</Project>
Loading
Loading