diff --git a/.github/workflows/test-version.yml b/.github/workflows/test-version.yml
index 3469679..79fb1b4 100644
--- a/.github/workflows/test-version.yml
+++ b/.github/workflows/test-version.yml
@@ -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
diff --git a/CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj b/CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj
new file mode 100644
index 0000000..b64890b
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj
@@ -0,0 +1,20 @@
+
+
+ netstandard2.0
+ enable
+ enable
+ latest
+ true
+ false
+ false
+ RS2007
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs b/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs
new file mode 100644
index 0000000..ed3eea1
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs
@@ -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;
+
+///
+/// Migrates a result error mapper to request-service resolution by replacing its directly implemented
+/// IResultErrorMapper interface with IServiceResultErrorMapper and adding the required imports.
+///
+[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 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();
+
+ 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 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)));
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs
new file mode 100644
index 0000000..b7c4d78
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs
@@ -0,0 +1,3 @@
+namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;
+
+public sealed record DocumentNotFoundError(string DocumentId);
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs
new file mode 100644
index 0000000..d50b390
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;
+
+///
+/// Service variant of an error mapper: resolved from request services, so constructor injection works.
+///
+public sealed class DocumentNotFoundErrorMapper(DocumentationLinkProvider linkProvider)
+ : IServiceResultErrorMapper
+{
+ 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);
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs
new file mode 100644
index 0000000..12ebefd
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs
@@ -0,0 +1,7 @@
+namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;
+
+/// Builds documentation links and is injected into the service mapper.
+public sealed class DocumentationLinkProvider
+{
+ public string For(string topic) => $"https://docs.example.com/errors/{topic}";
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs
new file mode 100644
index 0000000..7cc5f8e
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs
@@ -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, ProblemHttpResult> Handle(string id, HttpContext httpContext)
+ {
+ return Result.Failure(new DocumentNotFoundError(id)).ToOkHttpResult(httpContext);
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsFactoryProvider.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsFactoryProvider.cs
new file mode 100644
index 0000000..8bb95f7
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsFactoryProvider.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+
+namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider;
+
+///
+/// Example integration with ASP.NET Core's . The generator discovers
+/// this provider and registers it as a scoped automatically.
+///
+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;
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsProviderEndpoint.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsProviderEndpoint.cs
new file mode 100644
index 0000000..1a62c90
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsProviderEndpoint.cs
@@ -0,0 +1,31 @@
+using CSharpFunctionalExtensions;
+using CSharpFunctionalExtensions.HttpResults.ResultExtensions;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider;
+
+///
+/// The generator discovers and registers the factory-backed IResultProblemDetailsProvider. Its presence
+/// enables the generated context overloads for built-in string-error results.
+///
+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, ProblemHttpResult> Handle(HttpContext httpContext)
+ {
+ return Result
+ .Failure("Example failure")
+ .ToOkHttpResult(
+ httpContext,
+ failureStatusCode: 400,
+ customizeProblemDetails: problemDetails =>
+ problemDetails.Extensions["source"] = "custom-problem-details-example"
+ );
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
index f993264..b5f5358 100644
--- a/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
@@ -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);
@@ -35,6 +38,11 @@
builder.Services.AddSingleton();
+// Registers ASP.NET Core's ProblemDetailsFactory used by the example provider.
+builder.Services.AddControllers();
+builder.Services.AddCSharpFunctionalExtensionsHttpResults();
+builder.Services.AddSingleton();
+
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
@@ -55,5 +63,7 @@
app.MapBooksGroup();
app.MapCheckAge();
app.MapStream();
+app.MapProblemDetailsProvider();
+app.MapDocuments();
app.Run();
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/README.md b/CSharpFunctionalExtensions.HttpResults.Examples/README.md
index b723307..c680f7a 100644
--- a/CSharpFunctionalExtensions.HttpResults.Examples/README.md
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/README.md
@@ -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`.
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj
index 52d3877..b6b1392 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj
@@ -37,5 +37,6 @@
ReferenceOutputAssembly="false"
/>
+
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs
index 8486c56..e143a95 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs
@@ -1,22 +1,31 @@
-using Microsoft.CodeAnalysis;
+using System.Collections.Immutable;
+using AwesomeAssertions;
+using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
public static class GeneratorTestHelper
{
+ private static readonly CSharpParseOptions ParseOptions = CSharpParseOptions
+ .Default.WithLanguageVersion(LanguageVersion.Preview)
+ .WithPreprocessorSymbols("NET10_0_OR_GREATER");
+
public static (IEnumerable Diagnostics, string GeneratedSource) RunGenerator(
string sourceCode,
IEnumerable? additionalReferences = null
)
{
- var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
+ var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, ParseOptions);
+
+ var references = new List();
- var references = new List
- {
- MetadataReference.CreateFromFile(typeof(ResultExtensionsGenerator).Assembly.Location),
- MetadataReference.CreateFromFile(typeof(IResultErrorMapper<,>).Assembly.Location),
- };
+ // The trusted platform assemblies include the ASP.NET Core shared framework parts
+ // required to resolve types like ProblemHttpResult during generation.
+ var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(
+ Path.PathSeparator
+ );
+ references.AddRange(trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path)));
if (additionalReferences != null)
references.AddRange(additionalReferences);
@@ -24,14 +33,17 @@ public static (IEnumerable Diagnostics, string GeneratedSource) RunG
var compilation = CSharpCompilation.Create(
"TestAssembly",
[syntaxTree],
- references.OfType(),
+ DistinctReferences(references),
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);
var generator = new ResultExtensionsGenerator();
- var driver = CSharpGeneratorDriver.Create(generator);
+ var driver = CSharpGeneratorDriver.Create(
+ [generator.AsSourceGenerator()],
+ parseOptions: (CSharpParseOptions)syntaxTree.Options
+ );
- driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generatorDiagnostics);
var sourceFiles = outputCompilation
.SyntaxTrees.Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase))
@@ -39,6 +51,88 @@ public static (IEnumerable Diagnostics, string GeneratedSource) RunG
var generatedSource = string.Join("\n\n", sourceFiles);
- return (diagnostics, generatedSource);
+ return (CollectDiagnostics(generatorDiagnostics, outputCompilation), generatedSource);
+ }
+
+ ///
+ /// Runs the generator and returns the generated sources keyed by their hint name,
+ /// e.g. ResultExtensions.g.cs. Assertions can target individual generated files.
+ ///
+ public static (IEnumerable Diagnostics, IReadOnlyDictionary Files) RunGeneratorPerFile(
+ string sourceCode,
+ IEnumerable? additionalReferences = null
+ )
+ {
+ var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, ParseOptions);
+
+ var references = new List();
+
+ var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(
+ Path.PathSeparator
+ );
+ references.AddRange(trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path)));
+
+ if (additionalReferences != null)
+ references.AddRange(additionalReferences);
+
+ var compilation = CSharpCompilation.Create(
+ "TestAssembly",
+ [syntaxTree],
+ DistinctReferences(references),
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+
+ var driver = CSharpGeneratorDriver.Create(
+ [new ResultExtensionsGenerator().AsSourceGenerator()],
+ parseOptions: (CSharpParseOptions)syntaxTree.Options
+ );
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generatorDiagnostics);
+
+ var files = outputCompilation
+ .SyntaxTrees.Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase))
+ .ToDictionary(tree => Path.GetFileName(tree.FilePath), tree => tree.GetText().ToString());
+
+ return (CollectDiagnostics(generatorDiagnostics, outputCompilation), files);
+ }
+
+ /// Compiles the given source into an assembly reference, e.g. to simulate referenced mapper libraries.
+ public static MetadataReference CreateReference(string assemblyName, string source)
+ {
+ var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
+ .Split(Path.PathSeparator)
+ .Select(path => MetadataReference.CreateFromFile(path));
+ var compilation = CSharpCompilation.Create(
+ assemblyName,
+ [CSharpSyntaxTree.ParseText(source)],
+ trustedPlatformAssemblies.Append(
+ MetadataReference.CreateFromFile(typeof(IResultErrorMapper<,>).Assembly.Location)
+ ),
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+ using var stream = new MemoryStream();
+ compilation.Emit(stream).Success.Should().BeTrue();
+ return MetadataReference.CreateFromImage(stream.ToArray());
+ }
+
+ private static IReadOnlyList CollectDiagnostics(
+ ImmutableArray generatorDiagnostics,
+ Compilation outputCompilation
+ ) =>
+ generatorDiagnostics
+ .Concat(outputCompilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error))
+ .GroupBy(diagnostic => (diagnostic.Id, diagnostic.Location.SourceSpan, diagnostic.GetMessage()))
+ .Select(group => group.First())
+ .ToArray();
+
+ private static IEnumerable DistinctReferences(IEnumerable references)
+ {
+ var portableReferences = references.OfType().ToArray();
+ var fileReferences = portableReferences
+ .Where(reference => reference.FilePath is not null)
+ .GroupBy(reference => reference.FilePath!, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First());
+
+ // References created from in-memory images have no path and must not be collapsed into one group.
+ return fileReferences.Concat(portableReferences.Where(reference => reference.FilePath is null));
}
}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs
new file mode 100644
index 0000000..a831bba
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs
@@ -0,0 +1,33 @@
+using AwesomeAssertions;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class HttpResultMethodCatalogTests
+{
+ [Fact]
+ public void Catalog_contains_every_supported_family_once()
+ {
+ HttpResultMethodCatalog.StringErrorMethods.Should().HaveCount(14).And.OnlyHaveUniqueItems();
+ HttpResultMethodCatalog.ResultWithCustomErrorMethods.Should().HaveCount(12).And.OnlyHaveUniqueItems();
+ HttpResultMethodCatalog.UnitResultWithCustomErrorMethods.Should().HaveCount(2).And.OnlyHaveUniqueItems();
+ }
+
+ [Fact]
+ public void Catalog_type_and_expression_fragments_are_valid_CSharp()
+ {
+ var methods = HttpResultMethodCatalog
+ .StringErrorMethods.Concat(HttpResultMethodCatalog.UnitResultWithCustomErrorMethods)
+ .Distinct();
+
+ foreach (var method in methods)
+ {
+ SyntaxFactory.ParseTypeName(method.SuccessArm).ContainsDiagnostics.Should().BeFalse();
+ SyntaxFactory.ParseExpression(method.SuccessExpression).ContainsDiagnostics.Should().BeFalse();
+
+ foreach (var parameter in method.Parameters)
+ SyntaxFactory.ParseTypeName(parameter.Type).ContainsDiagnostics.Should().BeFalse();
+ }
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs
new file mode 100644
index 0000000..d839e2f
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs
@@ -0,0 +1,205 @@
+using AwesomeAssertions;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class MapperShapeGenerationTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Explicit_interface_Map_compiles_and_is_invoked_through_the_contract(bool serviceMapper)
+ {
+ var marker = serviceMapper ? "IServiceResultErrorMapper" : "IResultErrorMapper";
+ var source = $$"""
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class Error;
+ public sealed class Mapper : {{marker}}
+ {
+ ProblemHttpResult IResultErrorMapper.Map(Error error) =>
+ TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ (files["ResultExtensions.g.cs"] + files["ResultErrorMapperCache.g.cs"])
+ .Should()
+ .Contain(
+ "global::CSharpFunctionalExtensions.HttpResults.IResultErrorMapper"
+ );
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Inherited_Map_compiles(bool serviceMapper)
+ {
+ var marker = serviceMapper ? "IServiceResultErrorMapper" : "IResultErrorMapper";
+ var source = $$"""
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class Error;
+ public abstract class MapperBase
+ {
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ public sealed class Mapper : MapperBase, {{marker}};
+ """;
+
+ var (diagnostics, _) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Partial_mapper_is_analysed_once()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class Error;
+ public sealed partial class Mapper : IResultErrorMapper;
+ public sealed partial class Mapper
+ {
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ files["ResultErrorMapperCache.g.cs"].Split("new global::Mapper()").Should().HaveCount(2);
+ }
+
+ [Theory]
+ [MemberData(nameof(UnsupportedMapperSources))]
+ public void Unsupported_mapper_shapes_are_reported(string declaration)
+ {
+ var source = $$"""
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class Error;
+ {{declaration}}
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR006");
+ files["StringErrorResultExtensions.g.cs"].Should().Contain("ToOkHttpResult");
+ files["ResultExtensions.g.cs"].Should().NotContain("global::Error");
+ }
+
+ public static TheoryData UnsupportedMapperSources =>
+ new()
+ {
+ "public abstract class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); }",
+ "public sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(T error) => TypedResults.Problem(); }",
+ "public static class Holder { private sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); } }",
+ "public sealed class Mapper : IServiceResultErrorMapper { private Mapper() { } public ProblemHttpResult Map(Error error) => TypedResults.Problem(); }",
+ };
+
+ [Fact]
+ public void Result_error_mapper_with_required_members_is_rejected()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class Error;
+ public sealed class Mapper : IResultErrorMapper
+ {
+ public required string Name { get; init; }
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR004");
+ files["ResultErrorMapperCache.g.cs"].Should().NotContain("new global::Mapper");
+ }
+
+ [Fact]
+ public void SetsRequiredMembers_constructor_is_supported()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using System.Diagnostics.CodeAnalysis;
+
+ public sealed class Error;
+ public sealed class Mapper : IResultErrorMapper
+ {
+ public required string Name { get; init; }
+ [SetsRequiredMembers]
+ public Mapper() => Name = "mapper";
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ files["ResultErrorMapperCache.g.cs"].Should().Contain("new global::Mapper()");
+ }
+
+ [Fact]
+ public void Same_short_mapper_names_get_unique_cache_members()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ namespace First { public sealed class Error; public sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); } }
+ namespace Second { public sealed class Error; public sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); } }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ var cache = files["ResultErrorMapperCache.g.cs"];
+ cache.Should().Contain("Mapper0").And.Contain("Mapper1");
+ cache.Should().Contain("new global::First.Mapper()").And.Contain("new global::Second.Mapper()");
+ }
+
+ [Fact]
+ public void Invalid_mapper_does_not_suppress_independent_generation()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class InvalidError;
+ public sealed class InvalidMapper(string dependency) : IResultErrorMapper
+ {
+ public ProblemHttpResult Map(InvalidError error) => TypedResults.Problem();
+ }
+ public sealed class ValidError;
+ public sealed class ValidMapper : IServiceResultErrorMapper
+ {
+ public ProblemHttpResult Map(ValidError error) => TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR004");
+ files["StringErrorResultExtensions.g.cs"].Should().Contain("ToOkHttpResult");
+ files["ResultExtensions.g.cs"].Should().Contain("global::ValidError").And.NotContain("global::InvalidError");
+ files["ServiceCollectionExtensions.g.cs"].Should().Contain("TryAddScoped()");
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs
new file mode 100644
index 0000000..de95566
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs
@@ -0,0 +1,138 @@
+using AwesomeAssertions;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class ProviderGenerationTests
+{
+ private const string ProviderUsings = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Mvc;
+ """;
+
+ [Fact]
+ public void Multiple_providers_report_an_error_and_disable_provider_generation()
+ {
+ var source =
+ ProviderUsings
+ + """
+
+ public sealed class FirstProvider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ public sealed class SecondProvider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR005");
+ files["StringErrorResultExtensions.g.cs"].Should().NotContain("HttpContext httpContext");
+ files["ServiceCollectionExtensions.g.cs"].Should().NotContain("FirstProvider").And.NotContain("SecondProvider");
+ }
+
+ [Fact]
+ public void Abstract_provider_is_reported_without_suppressing_static_mappings()
+ {
+ var source =
+ ProviderUsings
+ + """
+
+ public abstract class AbstractProvider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR007");
+ files["StringErrorResultExtensions.g.cs"].Should().Contain("ProblemDetailsMappingProvider.FindMapping");
+ }
+
+ [Fact]
+ public void Partial_provider_is_discovered_once()
+ {
+ var source =
+ ProviderUsings
+ + """
+
+ public sealed partial class Provider : IResultProblemDetailsProvider;
+ public sealed partial class Provider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ files["ServiceCollectionExtensions.g.cs"].Split("global::Provider").Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Referenced_provider_is_fully_qualified_and_auto_registered()
+ {
+ var external =
+ ProviderUsings
+ + """
+
+ namespace External;
+ public sealed class Provider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ """;
+ var reference = GeneratorTestHelper.CreateReference("ExternalProvider", external);
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile("public static class Empty;", [reference]);
+
+ diagnostics.Should().BeEmpty();
+ files["StringErrorResultExtensions.g.cs"].Should().Contain("HttpContext httpContext");
+ files["ServiceCollectionExtensions.g.cs"]
+ .Should()
+ .Contain(
+ "TryAddScoped()"
+ );
+ }
+
+ [Fact]
+ public void Providers_from_multiple_referenced_assemblies_report_an_error()
+ {
+ var firstReference = GeneratorTestHelper.CreateReference(
+ "FirstProviderAssembly",
+ ProviderUsings
+ + """
+
+ namespace First;
+ public sealed class Provider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ """
+ );
+ var secondReference = GeneratorTestHelper.CreateReference(
+ "SecondProviderAssembly",
+ ProviderUsings
+ + """
+
+ namespace Second;
+ public sealed class Provider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new();
+ }
+ """
+ );
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(
+ "public static class Empty;",
+ [firstReference, secondReference]
+ );
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR005");
+ files["StringErrorResultExtensions.g.cs"].Should().NotContain("HttpContext httpContext");
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs
new file mode 100644
index 0000000..96e6a02
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs
@@ -0,0 +1,119 @@
+using AwesomeAssertions;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class RegistrationGenerationTests
+{
+ [Fact]
+ public void Registration_is_emitted_even_without_any_mappers()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile("public static class Empty { }");
+ var registration = files["ServiceCollectionExtensions.g.cs"];
+
+ registration
+ .Should()
+ .Contain(
+ "public static IServiceCollection AddCSharpFunctionalExtensionsHttpResults(this IServiceCollection services)"
+ );
+ registration.Should().NotContain("TryAddScoped<");
+ }
+
+ [Fact]
+ public void Source_service_mapper_is_registered_but_standard_mapper_is_not()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace Sample;
+
+ public sealed class ServiceError;
+ public sealed class StandardDomainError;
+
+ public sealed class ServiceMapper : IServiceResultErrorMapper
+ {
+ public ProblemHttpResult Map(ServiceError error) => TypedResults.Problem();
+ }
+
+ public sealed class StandardMapper : IResultErrorMapper
+ {
+ public ProblemHttpResult Map(StandardDomainError error) => TypedResults.Problem();
+ }
+ """;
+
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+ var registration = files["ServiceCollectionExtensions.g.cs"];
+
+ registration.Should().Contain("services.TryAddScoped();");
+ registration.Should().NotContain("StandardMapper");
+ }
+
+ [Fact]
+ public void Public_referenced_service_mapper_is_registered_internal_is_not()
+ {
+ const string external = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace External;
+
+ public sealed class ExternalError;
+
+ public sealed class PublicServiceMapper : IServiceResultErrorMapper
+ {
+ public ProblemHttpResult Map(ExternalError error) => TypedResults.Problem();
+ }
+
+ internal sealed class InternalServiceMapper : IServiceResultErrorMapper>
+ {
+ public NotFound Map(ExternalError error) => TypedResults.NotFound(string.Empty);
+ }
+ """;
+
+ var reference = GeneratorTestHelper.CreateReference("ExternalMappers", external);
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile("public static class Empty { }", [reference]);
+ var registration = files["ServiceCollectionExtensions.g.cs"];
+
+ registration.Should().Contain("services.TryAddScoped();");
+ registration.Should().NotContain("InternalServiceMapper");
+ }
+
+ [Fact]
+ public void Discovered_provider_is_auto_registered_as_scoped()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(StringErrorMethodGenerationTests.WithProviderSource);
+ var registration = files["ServiceCollectionExtensions.g.cs"];
+
+ registration
+ .Should()
+ .Contain(
+ "services.TryAddScoped();"
+ );
+ }
+
+ [Fact]
+ public void Registrations_are_deterministically_ordered()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace Zeta { public sealed class ZMapper : IServiceResultErrorMapper { public ProblemHttpResult Map(Zeta.Error error) => TypedResults.Problem(); }
+ public sealed class Error; }
+ namespace Alpha { public sealed class AMapper : IServiceResultErrorMapper { public ProblemHttpResult Map(Alpha.Error error) => TypedResults.Problem(); }
+ public sealed class Error; }
+ """;
+
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+ var registration = files["ServiceCollectionExtensions.g.cs"];
+
+ var alphaIndex = registration.IndexOf("global::Alpha.AMapper", StringComparison.Ordinal);
+ var zetaIndex = registration.IndexOf("global::Zeta.ZMapper", StringComparison.Ordinal);
+
+ alphaIndex.Should().BePositive().And.BeLessThan(zetaIndex);
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs
index fa119d5..ea30211 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs
@@ -39,6 +39,11 @@ public class DocumentCreationErrorMapper2 : IResultErrorMapper Map(string error) => TypedResults.Conflict(error.DocumentId);
+ public Conflict Map(string error) => TypedResults.Conflict(error);
}
// Explicit parameterless & with parameters --> No error
@@ -40,7 +40,7 @@ public class DocumentCreationErrorMapper3 : IResultErrorMapper Map(int error) => TypedResults.Conflict(error.DocumentId);
+ public Conflict Map(int error) => TypedResults.Conflict(error.ToString());
}
""";
@@ -57,6 +57,8 @@ public DocumentCreationErrorMapper3(string foo) { }
diagnostic
.GetMessage()
.Should()
- .Be("Class 'DocumentCreationErrorMapper' does not have a parameterless constructor");
+ .Be(
+ "Class 'DocumentCreationErrorMapper' does not have an accessible parameterless constructor without unsatisfied required members. Implement IServiceResultErrorMapper<,> to use dependency injection instead."
+ );
}
}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs
new file mode 100644
index 0000000..eb4e7ba
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs
@@ -0,0 +1,228 @@
+using AwesomeAssertions;
+using CSharpFunctionalExtensions.HttpResults;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class ServiceMapperCodeFixProviderTests
+{
+ [Fact]
+ public void Metadata_exposes_the_supported_diagnostic_and_batch_fixer()
+ {
+ var provider = new ServiceMapperCodeFixProvider();
+
+ provider.FixableDiagnosticIds.Should().Equal("CFEHTTPR004");
+ provider.GetFixAllProvider().Should().BeSameAs(WellKnownFixAllProviders.BatchFixer);
+ }
+
+ [Fact]
+ public async Task Fix_replaces_only_the_result_mapper_interface_and_preserves_mapper_source()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class MyError;
+
+ // This mapper needs constructor injection.
+ public sealed class MyMapper /* keep this comment */ : IResultErrorMapper
+ {
+ public MyMapper(string dependency) { }
+
+ // Map implementation must be untouched.
+ public ProblemHttpResult Map(MyError error) => TypedResults.Problem();
+ }
+ """;
+
+ var fixedSource = await ApplyFixAsync(source);
+
+ fixedSource.Should().Contain("IServiceResultErrorMapper");
+ fixedSource.Should().NotContain("global::");
+ fixedSource.Should().Contain("/* keep this comment */");
+ fixedSource.Should().Contain("public ProblemHttpResult Map(MyError error) => TypedResults.Problem();");
+ }
+
+ [Fact]
+ public async Task No_fix_is_offered_when_the_rule_reports_no_diagnostic()
+ {
+ const string source = """
+ public sealed class MyMapper : SomeOtherInterface
+ {
+ public SomeOtherInterface Map(MyError error) => throw null!;
+ }
+ """;
+
+ var actions = await GetActionsAsync(source);
+
+ actions.Should().BeEmpty();
+ }
+
+ [Theory]
+ [InlineData("public sealed class Mapper { }")]
+ [InlineData("public sealed class Mapper : System.IDisposable { public void Dispose() { } }")]
+ public async Task No_fix_is_offered_when_a_diagnostic_does_not_point_to_a_mapper_interface(string source)
+ {
+ var actions = await GetActionsAsync(source, createSyntheticDiagnostic: true);
+
+ actions.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task Fixed_mapper_is_accepted_as_a_service_mapper_by_the_generator()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ public sealed class Error;
+ public sealed class Mapper : IResultErrorMapper
+ {
+ public Mapper(string dependency) { }
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var fixedSource = await ApplyFixAsync(source);
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(fixedSource);
+
+ diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CFEHTTPR004");
+ files.Values.Should().Contain(source => source.Contains("GetRequiredService()"));
+ files["ResultErrorMapperCache.g.cs"].Should().NotContain("new global::Mapper");
+ }
+
+ [Fact]
+ public async Task Fix_supports_a_qualified_interface_name()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http.HttpResults;
+
+ public sealed class Error;
+ public sealed class Mapper : CSharpFunctionalExtensions.HttpResults.IResultErrorMapper
+ {
+ public Mapper(string dependency) { }
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var fixedSource = await ApplyFixAsync(source);
+
+ fixedSource
+ .Should()
+ .Contain("using CSharpFunctionalExtensions.HttpResults;")
+ .And.Contain("IServiceResultErrorMapper");
+ fixedSource.Should().NotContain("global::");
+ }
+
+ [Fact]
+ public async Task Fix_supports_an_alias_qualified_closed_interface()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using ResultMapper = CSharpFunctionalExtensions.HttpResults.IResultErrorMapper;
+
+ public sealed class Error;
+ public sealed class Mapper : ResultMapper
+ {
+ public Mapper(string dependency) { }
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var fixedSource = await ApplyFixAsync(source);
+
+ fixedSource.Should().NotContain(": ResultMapper");
+ fixedSource.Should().Contain("IServiceResultErrorMapper");
+ fixedSource.Should().NotContain("global::");
+ }
+
+ private static async Task ApplyFixAsync(string source)
+ {
+ var actions = await GetActionsAsync(source);
+ actions.Should().ContainSingle();
+ actions[0].Title.Should().Be("Use IServiceResultErrorMapper (generated mappings require httpContext on failure)");
+
+ var operations = await actions[0].GetOperationsAsync(TestContext.Current.CancellationToken);
+ var changedSolution = operations.OfType().Should().ContainSingle().Which.ChangedSolution;
+ var changedDocument = changedSolution.Projects.Single().Documents.Single();
+ return (await changedDocument.GetTextAsync(TestContext.Current.CancellationToken)).ToString();
+ }
+
+ ///
+ /// Produces the code-fix actions exactly as the IDE would: diagnostics come from the real generator,
+ /// so their spans match production.
+ ///
+ private static async Task> GetActionsAsync(string source, bool createSyntheticDiagnostic = false)
+ {
+ using var workspace = new AdhocWorkspace();
+ var project = workspace
+ .AddProject("TestProject", LanguageNames.CSharp)
+ .WithParseOptions(new CSharpParseOptions(LanguageVersion.Preview))
+ .WithMetadataReferences(CreateReferences());
+ workspace.TryApplyChanges(project.Solution).Should().BeTrue();
+ project = workspace.CurrentSolution.GetProject(project.Id)!;
+ var document = workspace.AddDocument(project.Id, "Mapper.cs", SourceText.From(source));
+ var compilation = (await document.Project.GetCompilationAsync(TestContext.Current.CancellationToken))!;
+ var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options;
+ var driver = CSharpGeneratorDriver.Create(
+ [new ResultExtensionsGenerator().AsSourceGenerator()],
+ parseOptions: parseOptions
+ );
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var generatorDiagnostics);
+ var diagnostics = createSyntheticDiagnostic
+ ? [CreateSyntheticDiagnostic(compilation.SyntaxTrees.Single())]
+ : generatorDiagnostics.Where(diagnostic => diagnostic.Id == "CFEHTTPR004").ToArray();
+
+ var actions = new List();
+ foreach (var diagnostic in diagnostics)
+ {
+ var context = new CodeFixContext(
+ document,
+ diagnostic,
+ (action, _) => actions.Add(action),
+ TestContext.Current.CancellationToken
+ );
+
+ await new ServiceMapperCodeFixProvider().RegisterCodeFixesAsync(context);
+ }
+
+ return actions;
+ }
+
+ private static Diagnostic CreateSyntheticDiagnostic(SyntaxTree syntaxTree)
+ {
+ var classDeclaration = syntaxTree.GetRoot().DescendantNodes().OfType().Single();
+ var descriptor = new DiagnosticDescriptor(
+ "CFEHTTPR004",
+ "Test diagnostic",
+ "Test diagnostic",
+ "Test",
+ DiagnosticSeverity.Error,
+ isEnabledByDefault: true
+ );
+
+ return Diagnostic.Create(descriptor, classDeclaration.Identifier.GetLocation());
+ }
+
+ private static IReadOnlyList CreateReferences()
+ {
+ var references = new List
+ {
+ MetadataReference.CreateFromFile(typeof(IResultErrorMapper<,>).Assembly.Location),
+ };
+
+ var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(
+ Path.PathSeparator
+ );
+ references.AddRange(trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path)));
+
+ return references;
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs
new file mode 100644
index 0000000..3d4b05c
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs
@@ -0,0 +1,252 @@
+using AwesomeAssertions;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class ServiceMapperGeneratorTests
+{
+ private const string ServiceMapperSource = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace Sample;
+
+ public sealed class SampleError;
+
+ public sealed class Dependency;
+
+ public sealed class ServiceMapper(Dependency dependency) : IServiceResultErrorMapper
+ {
+ public ProblemHttpResult Map(SampleError error) => TypedResults.Problem(detail: dependency.ToString());
+ }
+ """;
+
+ [Fact]
+ public void ServiceMapper_generates_required_context_overloads_for_every_mapping_family()
+ {
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(ServiceMapperSource);
+
+ diagnostics.Should().BeEmpty();
+ var mapperMethods = files["ResultExtensions.g.cs"] + files["UnitResultExtensions.g.cs"];
+
+ mapperMethods.Should().NotContain("ResultErrorMapperCache.Mapper");
+ mapperMethods.Should().Contain("GetRequiredService()");
+ mapperMethods
+ .Should()
+ .Contain(
+ "global::CSharpFunctionalExtensions.HttpResults.IResultErrorMapper"
+ );
+ mapperMethods.Should().Contain("A HttpContext is required for IServiceResultErrorMapper mappings.");
+
+ // 12 Result families and 2 UnitResult families, each sync and async:
+ // 28 overloads carrying a REQUIRED HttpContext parameter (no default value).
+ mapperMethods.Split("HttpContext httpContext").Should().HaveCount(29);
+ mapperMethods.Should().NotContain("HttpContext? httpContext");
+ mapperMethods.Should().NotContain("httpContext = null");
+
+ // Every async overload forwards to its sync counterpart.
+ mapperMethods.Split("await result").Should().HaveCount(15);
+
+ // The mapper returns ProblemHttpResult: every sync failure branch applies the
+ // callback to the mapped result (14 families).
+ (files["ResultExtensions.g.cs"].Split("customizeProblemDetails?.Invoke(mapped.ProblemDetails);").Length - 1)
+ .Should()
+ .Be(12);
+ (files["UnitResultExtensions.g.cs"].Split("customizeProblemDetails?.Invoke(mapped.ProblemDetails);").Length - 1)
+ .Should()
+ .Be(2);
+ }
+
+ [Fact]
+ public void ServiceMapper_overloads_place_httpContext_between_required_and_optional_parameters()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(ServiceMapperSource);
+ var mapperMethods = files["ResultExtensions.g.cs"] + files["UnitResultExtensions.g.cs"];
+
+ // Accepted requires uri -> context comes after it.
+ mapperMethods
+ .Should()
+ .Contain(
+ "ToAcceptedHttpResult(this Result result, Func uri, HttpContext httpContext, Action? customizeProblemDetails = null)"
+ );
+
+ // StatusCode only has optional parameters -> context comes first.
+ mapperMethods
+ .Should()
+ .Contain(
+ "ToStatusCodeHttpResult(this Result result, HttpContext httpContext, int successStatusCode = 204, Action? customizeProblemDetails = null)"
+ );
+
+ mapperMethods
+ .Should()
+ .Contain(
+ "ToNoContentHttpResult(this UnitResult result, HttpContext httpContext, Action? customizeProblemDetails = null)"
+ );
+
+ // File/FileStream forward context between the family parameters and the callback,
+ // mirroring the sync parameter order.
+ mapperMethods
+ .Should()
+ .Contain(
+ "ToFileHttpResult(await result, httpContext, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing, customizeProblemDetails)"
+ );
+ mapperMethods
+ .Should()
+ .Contain(
+ "ToFileStreamHttpResult(await result, httpContext, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing, customizeProblemDetails)"
+ );
+ }
+
+ [Fact]
+ public void ServiceMapper_with_constructor_dependency_does_not_report_standard_constructor_diagnostic()
+ {
+ var (diagnostics, _) = GeneratorTestHelper.RunGeneratorPerFile(ServiceMapperSource);
+
+ diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CFEHTTPR004");
+ }
+
+ [Fact]
+ public void ServiceMapper_without_ProblemHttpResult_return_type_gets_no_customize_parameter()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace Sample;
+
+ public sealed class OtherError;
+
+ public sealed class Service;
+
+ public sealed class OtherMapper(Service service) : IServiceResultErrorMapper>
+ {
+ public NotFound Map(OtherError error) => TypedResults.NotFound(error.ToString());
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+ var mapperMethods = files["ResultExtensions.g.cs"];
+
+ diagnostics.Should().BeEmpty();
+ mapperMethods.Should().NotContain("customizeProblemDetails");
+ mapperMethods.Should().Contain("(this Result result, HttpContext httpContext)");
+ mapperMethods
+ .Should()
+ .Contain("httpContext.RequestServices.GetRequiredService()).Map(result.Error);");
+ }
+
+ [Fact]
+ public void StandardMapper_retains_static_instance_and_context_free_overloads()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ public sealed class StandardDomainError;
+ public sealed class StandardMapper : IResultErrorMapper
+ {
+ public ProblemHttpResult Map(StandardDomainError error) => TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ files["ResultErrorMapperCache.g.cs"]
+ .Should()
+ .Contain(
+ "internal static global::CSharpFunctionalExtensions.HttpResults.IResultErrorMapper Mapper0 { get; } = new global::StandardMapper();"
+ );
+
+ var mapperMethods = files["ResultExtensions.g.cs"];
+ mapperMethods.Should().NotContain("GetRequiredService<");
+ mapperMethods.Should().NotContain("HttpContext");
+ mapperMethods
+ .Should()
+ .Contain("var mapped = CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache.Mapper0.Map(result.Error);");
+
+ // Standard mappers returning ProblemHttpResult get the optional customize parameter.
+ mapperMethods.Should().Contain("Action? customizeProblemDetails = null");
+ mapperMethods.Should().Contain("customizeProblemDetails?.Invoke(mapped.ProblemDetails);");
+ }
+
+ [Fact]
+ public void StandardMapper_without_ProblemHttpResult_return_type_stays_parameterless()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ public sealed class StandardDomainError;
+ public sealed class StandardNotFoundMapper : IResultErrorMapper>
+ {
+ public NotFound Map(StandardDomainError error) => TypedResults.NotFound(string.Empty);
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().BeEmpty();
+ files["UnitResultExtensions.g.cs"].Should().Contain("(this UnitResult result)");
+ files["UnitResultExtensions.g.cs"]
+ .Should()
+ .Contain("return CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache.Mapper0.Map(result.Error);");
+ }
+
+ [Fact]
+ public void Duplicate_error_type_is_reported_for_mixed_standard_and_service_mappers()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ public sealed class SharedError;
+ public sealed class StandardMapper : IResultErrorMapper
+ {
+ public ProblemHttpResult Map(SharedError error) => TypedResults.Problem();
+ }
+ public sealed class ServiceMapper : IServiceResultErrorMapper
+ {
+ public ProblemHttpResult Map(SharedError error) => TypedResults.Problem();
+ }
+ """;
+
+ var (diagnostics, _) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR002");
+ }
+
+ [Fact]
+ public void Error_types_with_the_same_short_name_in_different_namespaces_are_not_duplicates()
+ {
+ const string source = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace First { public sealed class Error; }
+ namespace Second { public sealed class Error; }
+ namespace Mappers
+ {
+ public sealed class FirstMapper : IResultErrorMapper
+ {
+ public ProblemHttpResult Map(First.Error error) => TypedResults.Problem();
+ }
+ public sealed class SecondMapper : IServiceResultErrorMapper
+ {
+ public ProblemHttpResult Map(Second.Error error) => TypedResults.Problem();
+ }
+ }
+ """;
+
+ var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source);
+
+ diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CFEHTTPR002");
+ files.Should().ContainKey("ResultExtensions.g.cs");
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs
new file mode 100644
index 0000000..b83e6a4
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs
@@ -0,0 +1,135 @@
+using AwesomeAssertions;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests;
+
+public class StringErrorMethodGenerationTests
+{
+ private const string NoProviderSource = "public static class Empty { }";
+
+ internal const string WithProviderSource = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Mvc;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ public sealed class MyProvider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) =>
+ new ProblemDetails { Status = statusCode, Detail = error };
+ }
+ """;
+
+ [Fact]
+ public void Static_variants_are_always_emitted_into_the_well_known_namespace()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource);
+ var builtin = files["StringErrorResultExtensions.g.cs"];
+
+ builtin.Should().Contain("namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions;");
+ builtin.Should().Contain("public static partial class ResultExtensions {");
+
+ builtin
+ .Should()
+ .Contain(
+ "ToStatusCodeHttpResult(this Result result, int successStatusCode = 204, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+ builtin
+ .Should()
+ .Contain(
+ "ToOkHttpResult(this Result result, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+ builtin
+ .Should()
+ .Contain(
+ "ToAcceptedHttpResult(this Result result, Func uri, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+ builtin
+ .Should()
+ .Contain(
+ "ToContentHttpResult(this Result result, string? contentType = null, Encoding? contentEncoding = null, int? statusCode = null, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+
+ builtin.Should().Contain("ProblemDetailsMappingProvider.FindMapping(failureStatusCode)");
+ }
+
+ [Fact]
+ public void Context_overloads_are_emitted_when_a_provider_exists_in_the_compilation()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(WithProviderSource);
+ var builtin = files["StringErrorResultExtensions.g.cs"];
+
+ builtin.Should().Contain("using Microsoft.Extensions.DependencyInjection;");
+ builtin
+ .Should()
+ .Contain(
+ "ToOkHttpResult(this Result result, HttpContext httpContext, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+ builtin
+ .Should()
+ .Contain(
+ "ToAcceptedHttpResult(this Result result, Func uri, HttpContext httpContext, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+ builtin
+ .Should()
+ .Contain(
+ "ToStatusCodeHttpResult(this Result result, HttpContext httpContext, int successStatusCode = 204, int failureStatusCode = 400, Action? customizeProblemDetails = null)"
+ );
+
+ builtin.Should().Contain("httpContext.RequestServices.GetRequiredService()");
+ builtin.Should().Contain("CreateProblemDetails(httpContext, result.Error, failureStatusCode)");
+
+ builtin.Split("customizeProblemDetails?.Invoke(problemDetails);").Should().HaveCount(29);
+
+ builtin
+ .Should()
+ .Contain("ToOkHttpResult(await result, httpContext, failureStatusCode, customizeProblemDetails)");
+ }
+
+ [Fact]
+ public void Context_overloads_are_not_emitted_without_a_provider()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource);
+ var builtin = files["StringErrorResultExtensions.g.cs"];
+
+ builtin.Should().NotContain("HttpContext httpContext");
+ builtin.Should().NotContain("GetRequiredService");
+ builtin.Should().NotContain("Microsoft.Extensions.DependencyInjection");
+ }
+
+ [Fact]
+ public void Public_provider_from_referenced_assembly_enables_emission_internal_does_not()
+ {
+ const string externalPublic = """
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Mvc;
+ using CSharpFunctionalExtensions.HttpResults;
+
+ namespace External;
+
+ public sealed class PublicProvider : IResultProblemDetailsProvider
+ {
+ public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) => new();
+ }
+ """;
+
+ string externalInternal = externalPublic.Replace("public sealed class", "internal sealed class");
+
+ var publicReference = GeneratorTestHelper.CreateReference("ExternalProvidersPublic", externalPublic);
+ var (_, filesWithPublic) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource, [publicReference]);
+ filesWithPublic["StringErrorResultExtensions.g.cs"].Should().Contain("HttpContext httpContext");
+
+ var internalReference = GeneratorTestHelper.CreateReference("ExternalProvidersInternal", externalInternal);
+ var (_, filesWithInternal) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource, [internalReference]);
+ filesWithInternal["StringErrorResultExtensions.g.cs"].Should().NotContain("HttpContext httpContext");
+ }
+
+ [Fact]
+ public void ServerSentEvents_family_is_wrapped_in_net10_conditional()
+ {
+ var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource);
+ var builtin = files["StringErrorResultExtensions.g.cs"];
+
+ builtin.Should().Contain("#if NET10_0_OR_GREATER");
+ builtin.Should().Contain("ServerSentEventsResult");
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs
new file mode 100644
index 0000000..95bd8a1
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs
@@ -0,0 +1,52 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+/// All diagnostics emitted while analysing mapping implementations.
+internal static class GeneratorDiagnostics
+{
+ public static readonly DiagnosticDescriptor DuplicateMapper = new(
+ "CFEHTTPR002",
+ "Duplicate ResultErrorMapper",
+ "Error type '{0}' has multiple IResultErrorMapper or IServiceResultErrorMapper implementations",
+ "Mapping",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor MissingParameterlessConstructor = new(
+ "CFEHTTPR004",
+ "Missing parameterless constructor in IResultErrorMapper",
+ "Class '{0}' does not have an accessible parameterless constructor without unsatisfied required members. Implement IServiceResultErrorMapper<,> to use dependency injection instead.",
+ "Mapping",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor DuplicateProvider = new(
+ "CFEHTTPR005",
+ "Multiple result problem-details providers",
+ "Multiple IResultProblemDetailsProvider implementations were found: {0}",
+ "Mapping",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor UnsupportedMapper = new(
+ "CFEHTTPR006",
+ "Unsupported result error mapper",
+ "Mapper class '{0}' is unsupported: {1}",
+ "Mapping",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static readonly DiagnosticDescriptor UnsupportedProvider = new(
+ "CFEHTTPR007",
+ "Unsupported result problem-details provider",
+ "Provider class '{0}' is unsupported: {1}",
+ "Mapping",
+ DiagnosticSeverity.Error,
+ true
+ );
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs
new file mode 100644
index 0000000..2d7217d
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs
@@ -0,0 +1,7 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+/// Valid source mappers and diagnostics produced while analysing mapper symbols.
+internal sealed record MapperAnalysis(IReadOnlyList Mappers, IReadOnlyList Diagnostics);
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs
new file mode 100644
index 0000000..fe9fb78
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs
@@ -0,0 +1,141 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+/// Validates source mapper symbols and creates generation metadata for valid entries only.
+internal static class MapperAnalyzer
+{
+ private const string ProblemHttpResultMetadataName = "Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult";
+
+ public static MapperAnalysis Analyze(Compilation compilation, IReadOnlyList candidates)
+ {
+ var diagnostics = new List();
+ var validCandidates = new List<(INamedTypeSymbol Symbol, INamedTypeSymbol MapperInterface)>();
+
+ foreach (
+ var candidate in candidates.OrderBy(SymbolTypeNameFormatter.GetFullyQualifiedTypeName, StringComparer.Ordinal)
+ )
+ {
+ var mapperInterfaces = MapperSymbolResolver.GetClosedErrorMapperInterfaces(candidate).ToArray();
+ if (mapperInterfaces.Length != 1)
+ {
+ diagnostics.Add(CreateUnsupported(candidate, "exactly one closed IResultErrorMapper<,> is required"));
+ continue;
+ }
+
+ if (candidate.TypeKind != TypeKind.Class || candidate.IsAbstract || candidate.IsStatic)
+ {
+ diagnostics.Add(CreateUnsupported(candidate, "the type must be a concrete, non-static class"));
+ continue;
+ }
+
+ if (!SymbolAccessibility.IsReferenceable(candidate, compilation.Assembly))
+ {
+ diagnostics.Add(CreateUnsupported(candidate, "the type must be closed and accessible to generated code"));
+ continue;
+ }
+
+ if (MapperSymbolResolver.IsServiceMapper(candidate))
+ {
+ if (!SymbolAccessibility.HasPublicInstanceConstructor(candidate))
+ {
+ diagnostics.Add(CreateUnsupported(candidate, "a service mapper must have a public constructor for DI"));
+ continue;
+ }
+ }
+ else
+ {
+ var constructor = candidate.InstanceConstructors.FirstOrDefault(constructor =>
+ constructor.Parameters.Length == 0
+ && constructor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal
+ );
+ if (
+ constructor is null
+ || !SymbolAccessibility.HasAccessibleParameterlessConstructor(candidate, compilation.Assembly)
+ || SymbolAccessibility.HasUnsatisfiedRequiredMembers(candidate, constructor)
+ )
+ {
+ diagnostics.Add(
+ Diagnostic.Create(
+ GeneratorDiagnostics.MissingParameterlessConstructor,
+ GetLocation(candidate),
+ candidate.Name
+ )
+ );
+ continue;
+ }
+ }
+
+ validCandidates.Add((candidate, mapperInterfaces[0]));
+ }
+
+ var duplicateSymbols = new HashSet(SymbolEqualityComparer.Default);
+ foreach (
+ var group in validCandidates.GroupBy(
+ entry => entry.MapperInterface.TypeArguments[0],
+ SymbolEqualityComparer.Default
+ )
+ )
+ {
+ if (group.Count() <= 1)
+ continue;
+
+ var entries = group.ToArray();
+ foreach (var entry in entries)
+ duplicateSymbols.Add(entry.Symbol);
+
+ diagnostics.Add(
+ Diagnostic.Create(
+ GeneratorDiagnostics.DuplicateMapper,
+ GetLocation(entries[1].Symbol),
+ entries[0].MapperInterface.TypeArguments[0].ToDisplayString()
+ )
+ );
+ }
+
+ var problemHttpResult = compilation.GetTypeByMetadataName(ProblemHttpResultMetadataName);
+ var mapperDescriptors = validCandidates
+ .Where(entry => !duplicateSymbols.Contains(entry.Symbol))
+ .Select(entry => new MapperDescriptor
+ {
+ Symbol = entry.Symbol,
+ CacheMemberName = "",
+ FullyQualifiedName = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.Symbol),
+ ErrorType = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.MapperInterface.TypeArguments[0]),
+ HttpResultType = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.MapperInterface.TypeArguments[1]),
+ MapperInterfaceType = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.MapperInterface),
+ IsServiceMapper = MapperSymbolResolver.IsServiceMapper(entry.Symbol),
+ IsProblemHttpResult =
+ problemHttpResult is not null
+ && SymbolEqualityComparer.Default.Equals(
+ entry.MapperInterface.TypeArguments[1].OriginalDefinition,
+ problemHttpResult
+ ),
+ })
+ .OrderBy(info => info.FullyQualifiedName, StringComparer.Ordinal)
+ .Select(
+ (info, index) =>
+ new MapperDescriptor
+ {
+ Symbol = info.Symbol,
+ CacheMemberName = $"Mapper{index}",
+ FullyQualifiedName = info.FullyQualifiedName,
+ ErrorType = info.ErrorType,
+ HttpResultType = info.HttpResultType,
+ MapperInterfaceType = info.MapperInterfaceType,
+ IsServiceMapper = info.IsServiceMapper,
+ IsProblemHttpResult = info.IsProblemHttpResult,
+ }
+ )
+ .ToArray();
+
+ return new MapperAnalysis(mapperDescriptors, diagnostics);
+ }
+
+ private static Diagnostic CreateUnsupported(INamedTypeSymbol symbol, string reason) =>
+ Diagnostic.Create(GeneratorDiagnostics.UnsupportedMapper, GetLocation(symbol), symbol.Name, reason);
+
+ private static Location GetLocation(INamedTypeSymbol symbol) =>
+ symbol.Locations.FirstOrDefault(location => location.IsInSource) ?? Location.None;
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs
new file mode 100644
index 0000000..05532a0
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs
@@ -0,0 +1,33 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+internal static class MapperSymbolResolver
+{
+ public const string ResultErrorMapperInterfaceMetadataName = "IResultErrorMapper`2";
+ public const string ServiceResultErrorMapperInterfaceMetadataName = "IServiceResultErrorMapper`2";
+ public const string LibraryNamespace = "CSharpFunctionalExtensions.HttpResults";
+
+ ///
+ /// Checks whether the mapper implements the library's
+ /// .
+ /// Must be checked before the standard interface because it inherits it.
+ ///
+ public static bool IsServiceMapper(INamedTypeSymbol? mapperSymbol) =>
+ mapperSymbol?.AllInterfaces.Any(interfaceSymbol =>
+ interfaceSymbol.OriginalDefinition.MetadataName == ServiceResultErrorMapperInterfaceMetadataName
+ && interfaceSymbol.ContainingNamespace.ToDisplayString() == LibraryNamespace
+ ) == true;
+
+ ///
+ /// Returns the closed IResultErrorMapper<,> interfaces implemented by the mapper, resolved
+ /// from its type symbol. Service mappers qualify through inheritance, so explicit or inherited
+ /// Map implementations are covered without any syntactic lookup.
+ ///
+ public static IEnumerable GetClosedErrorMapperInterfaces(INamedTypeSymbol? mapperSymbol) =>
+ mapperSymbol?.AllInterfaces.Where(interfaceSymbol =>
+ interfaceSymbol.OriginalDefinition.MetadataName == ResultErrorMapperInterfaceMetadataName
+ && interfaceSymbol.ContainingNamespace.ToDisplayString() == LibraryNamespace
+ )
+ ?? [];
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs
new file mode 100644
index 0000000..4ef7782
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs
@@ -0,0 +1,6 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+/// The selected provider, if unambiguous, and provider diagnostics.
+internal sealed record ProviderAnalysis(INamedTypeSymbol? Provider, IReadOnlyList Diagnostics);
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs
new file mode 100644
index 0000000..d307758
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs
@@ -0,0 +1,53 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+/// Validates discovered provider implementations and selects the sole valid provider.
+internal static class ProviderAnalyzer
+{
+ public static ProviderAnalysis Analyze(Compilation compilation, IReadOnlyList candidates)
+ {
+ var diagnostics = new List();
+ var valid = new List();
+
+ foreach (
+ var candidate in candidates.OrderBy(SymbolTypeNameFormatter.GetFullyQualifiedTypeName, StringComparer.Ordinal)
+ )
+ {
+ string? reason = null;
+ if (candidate.TypeKind != TypeKind.Class || candidate.IsAbstract || candidate.IsStatic)
+ reason = "the type must be a concrete, non-static class";
+ else if (!SymbolAccessibility.IsReferenceable(candidate, compilation.Assembly))
+ reason = "the type must be closed and accessible to generated code";
+ else if (!SymbolAccessibility.HasPublicInstanceConstructor(candidate))
+ reason = "a public constructor is required for DI";
+
+ if (reason is not null)
+ {
+ diagnostics.Add(
+ Diagnostic.Create(GeneratorDiagnostics.UnsupportedProvider, GetLocation(candidate), candidate.Name, reason)
+ );
+ continue;
+ }
+
+ valid.Add(candidate);
+ }
+
+ if (valid.Count > 1)
+ {
+ diagnostics.Add(
+ Diagnostic.Create(
+ GeneratorDiagnostics.DuplicateProvider,
+ GetLocation(valid[1]),
+ string.Join(", ", valid.Select(SymbolTypeNameFormatter.GetFullyQualifiedTypeName))
+ )
+ );
+ return new ProviderAnalysis(null, diagnostics);
+ }
+
+ return new ProviderAnalysis(valid.SingleOrDefault(), diagnostics);
+ }
+
+ private static Location GetLocation(INamedTypeSymbol symbol) =>
+ symbol.Locations.FirstOrDefault(location => location.IsInSource) ?? Location.None;
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs
new file mode 100644
index 0000000..d47c8c3
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs
@@ -0,0 +1,75 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+
+/// Accessibility and constructibility checks shared by mapper and provider analysis.
+internal static class SymbolAccessibility
+{
+ public static bool IsReferenceable(INamedTypeSymbol type, IAssemblySymbol generatedAssembly)
+ {
+ for (var current = type; current is not null; current = current.ContainingType)
+ {
+ if (current.Arity != 0)
+ return false;
+
+ var sameAssembly = SymbolEqualityComparer.Default.Equals(current.ContainingAssembly, generatedAssembly);
+ var validAccessibility = sameAssembly
+ ? current.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal
+ : current.DeclaredAccessibility == Accessibility.Public;
+
+ if (!validAccessibility)
+ return false;
+ }
+
+ return true;
+ }
+
+ public static bool HasPublicInstanceConstructor(INamedTypeSymbol type) =>
+ type.InstanceConstructors.Any(constructor => constructor.DeclaredAccessibility == Accessibility.Public);
+
+ public static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol type, IAssemblySymbol generatedAssembly) =>
+ type.InstanceConstructors.Any(constructor =>
+ constructor.Parameters.Length == 0 && IsAccessible(constructor.DeclaredAccessibility, type, generatedAssembly)
+ );
+
+ public static bool HasUnsatisfiedRequiredMembers(INamedTypeSymbol type, IMethodSymbol constructor)
+ {
+ if (
+ constructor
+ .GetAttributes()
+ .Any(attribute =>
+ attribute.AttributeClass?.ToDisplayString() == "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute"
+ )
+ )
+ return false;
+
+ for (var current = type; current is not null; current = current.BaseType)
+ {
+ if (
+ current
+ .GetMembers()
+ .Any(member =>
+ member
+ is IPropertySymbol { IsStatic: false, IsRequired: true }
+ or IFieldSymbol { IsStatic: false, IsRequired: true }
+ )
+ )
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsAccessible(
+ Accessibility accessibility,
+ INamedTypeSymbol type,
+ IAssemblySymbol generatedAssembly
+ )
+ {
+ if (accessibility == Accessibility.Public)
+ return true;
+
+ return accessibility == Accessibility.Internal
+ && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, generatedAssembly);
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Utils/TypeNameResolver.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolTypeNameFormatter.cs
similarity index 80%
rename from CSharpFunctionalExtensions.HttpResults.Generators/Utils/TypeNameResolver.cs
rename to CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolTypeNameFormatter.cs
index 28c6e80..634d091 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Utils/TypeNameResolver.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolTypeNameFormatter.cs
@@ -1,8 +1,8 @@
using Microsoft.CodeAnalysis;
-namespace CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
-internal static class TypeNameResolver
+internal static class SymbolTypeNameFormatter
{
private static readonly SymbolDisplayFormat FullyQualifiedWithNullables =
SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md
index 068e2b2..2fc3350 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md
@@ -52,3 +52,20 @@
| Rule ID | Category | Severity | Notes |
|-------------|----------|----------|---------------------------------------------------------|
| CFEHTTPR004 | Mapping | Error | Missing parameterless constructor in IResultErrorMapper |
+
+## Release v2.0.0
+
+### Changed Rules
+
+| Rule ID | Category | Severity | Notes |
+|-------------|----------|----------|---------------------------------------------------------------------------------------|
+| CFEHTTPR002 | Mapping | Error | Semantic duplicate groups are excluded without suppressing unrelated generated output |
+| CFEHTTPR004 | Mapping | Error | Validates accessible construction and required members for result error mappers |
+
+### New Rules
+
+| Rule ID | Category | Severity | Notes |
+|-------------|----------|----------|--------------------------------------------------------------|
+| CFEHTTPR005 | Mapping | Error | Multiple valid IResultProblemDetailsProvider implementations |
+| CFEHTTPR006 | Mapping | Error | Unsupported mapper type shape |
+| CFEHTTPR007 | Mapping | Error | Unsupported provider type shape |
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md
index e69de29..efa39b6 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md
@@ -0,0 +1 @@
+## Unshipped
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs
deleted file mode 100644
index b1468e6..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs
+++ /dev/null
@@ -1,95 +0,0 @@
-using System.Text;
-using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-
-namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
-
-public abstract class ClassBuilder
-{
- private const string MapMethodName = "Map";
- private readonly Compilation? _compilation;
- private readonly List _mapperClasses;
-
- protected ClassBuilder(List mapperClasses, Compilation? compilation = null)
- {
- _mapperClasses = mapperClasses;
- _compilation = compilation;
- }
-
- private static string DefaultUsings =>
- """
- using CSharpFunctionalExtensions;
- using Microsoft.AspNetCore.Http.HttpResults;
- using Microsoft.AspNetCore.Http;
- using Microsoft.AspNetCore.Mvc;
- using Microsoft.Net.Http.Headers;
- using System.Text;
- using IResult = Microsoft.AspNetCore.Http.IResult;
- """;
-
- public string SourceFileName => $"{ClassName}.g.cs";
-
- protected abstract string ClassName { get; }
- protected abstract string ClassSummary { get; }
- internal abstract List MethodGenerators { get; }
-
- public string Build()
- {
- var sourceBuilder = new StringBuilder();
-
- sourceBuilder.AppendLine("// ");
- sourceBuilder.AppendLine();
- sourceBuilder.AppendLine("#nullable enable");
- sourceBuilder.AppendLine();
- sourceBuilder.AppendLine(DefaultUsings);
-
- sourceBuilder.AppendLine();
- sourceBuilder.AppendLine(ClassSummary);
-
- sourceBuilder.AppendLine($"public static partial class {ClassName} {{");
- sourceBuilder.AppendLine();
-
- foreach (var mapperClass in _mapperClasses)
- {
- var mapperClassName = mapperClass.Identifier.Text;
- var mappingMethod = mapperClass
- .Members.OfType()
- .FirstOrDefault(method => method.Identifier.Text == MapMethodName);
-
- if (mappingMethod == null)
- throw new ArgumentException($"Mapping method in class {mapperClassName} not found.");
-
- if (mappingMethod.ParameterList.Parameters.Count != 1)
- throw new ArgumentException($"Mapping method in class {mapperClassName} must have exactly one parameter.");
-
- var resultErrorType = GetFullyQualifiedTypeName(mapperClass, mappingMethod.ParameterList.Parameters[0].Type!);
- var httpResultType = mappingMethod.ReturnType!.ToString();
-
- foreach (var methodGenerator in MethodGenerators)
- {
- sourceBuilder.AppendLine(methodGenerator.Generate(mapperClassName, resultErrorType, httpResultType));
- sourceBuilder.AppendLine();
- }
- }
-
- sourceBuilder.AppendLine();
- sourceBuilder.AppendLine("}");
-
- return sourceBuilder.ToString();
- }
-
- private string GetFullyQualifiedTypeName(ClassDeclarationSyntax mapperClass, TypeSyntax typeSyntax)
- {
- if (_compilation == null)
- return typeSyntax.ToString();
-
- var semanticModel = _compilation.GetSemanticModel(mapperClass.SyntaxTree);
- var typeInfo = semanticModel.GetTypeInfo(typeSyntax);
-
- if (typeInfo.Type == null)
- return typeSyntax.ToString();
-
- return TypeNameResolver.GetFullyQualifiedTypeName(typeInfo.Type);
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ExtensionClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ExtensionClassBuilder.cs
new file mode 100644
index 0000000..578031c
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ExtensionClassBuilder.cs
@@ -0,0 +1,49 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+using CSharpFunctionalExtensions.HttpResults.Generators.Rendering;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
+
+///
+/// Base builder for the generated extension-method classes that are emitted per discovered error mapper.
+///
+internal abstract class ExtensionClassBuilder(IReadOnlyList mapperDescriptors)
+{
+ public string SourceFileName => $"{ClassName}.g.cs";
+
+ protected abstract string ClassName { get; }
+ protected abstract string ClassSummary { get; }
+ internal abstract IReadOnlyList Methods { get; }
+
+ public string Build()
+ {
+ var sourceBuilder = new StringBuilder();
+
+ sourceBuilder.AppendLine("// ");
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine("#nullable enable");
+ sourceBuilder.AppendLine();
+ GeneratedSourceUsings.AppendTo(sourceBuilder, mapperDescriptors.Any(info => info.IsServiceMapper));
+
+ sourceBuilder.AppendLine(ClassSummary);
+
+ sourceBuilder.AppendLine($"public static partial class {ClassName} {{");
+ sourceBuilder.AppendLine();
+
+ foreach (var mapperDescriptor in mapperDescriptors)
+ {
+ var context = mapperDescriptor.ToContext();
+
+ foreach (var method in Methods)
+ {
+ sourceBuilder.AppendLine(CustomErrorMethodRenderer.Generate(context, method));
+ sourceBuilder.AppendLine();
+ }
+ }
+
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine("}");
+
+ return sourceBuilder.ToString();
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs
index 86ae6a7..12ae956 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs
@@ -1,11 +1,9 @@
-using CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
-public class ResultExtensionsClassBuilder(List mapperClasses, Compilation? compilation = null)
- : ClassBuilder(mapperClasses, compilation)
+internal sealed class ResultExtensionsClassBuilder(IReadOnlyList mapperDescriptors)
+ : ExtensionClassBuilder(mapperDescriptors)
{
protected override string ClassName => "ResultExtensions";
@@ -16,19 +14,6 @@ public class ResultExtensionsClassBuilder(List mapperCla
///
""";
- internal override List MethodGenerators =>
- [
- new ToAcceptedAtRouteHttpResultTE(),
- new ToAcceptedHttpResultTE(),
- new ToCreatedAtRouteHttpResultTE(),
- new ToCreatedHttpResultTE(),
- new ToFileHttpResultByteArrayE(),
- new ToFileStreamHttpResultStreamE(),
- new ToJsonHttpResultTE(),
- new ToNoContentHttpResultTE(),
- new ToStatusCodeHttpResultTE(),
- new ToOkHttpResultTE(),
- new ToContentHttpResultStringE(),
- new ToServerSentEventsHttpResultIAsyncEnumerableTE(),
- ];
+ internal override IReadOnlyList Methods =>
+ HttpResultMethodCatalog.ResultWithCustomErrorMethods;
}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs
new file mode 100644
index 0000000..e7f9b7f
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs
@@ -0,0 +1,55 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+using CSharpFunctionalExtensions.HttpResults.Generators.Rendering;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
+
+///
+/// Emits the built-in extension methods for and
+/// string errors into the same partial class the
+/// handwritten implementations used to live in. The static variants are always emitted; the
+/// request-service-aware variants only when a provider implementation was discovered.
+///
+internal sealed class StringErrorResultExtensionsClassBuilder(bool includeContextOverloads)
+{
+ public string SourceFileName => "StringErrorResultExtensions.g.cs";
+
+ public string Build()
+ {
+ var sourceBuilder = new StringBuilder();
+
+ sourceBuilder.AppendLine("// ");
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine("#nullable enable");
+ sourceBuilder.AppendLine();
+ GeneratedSourceUsings.AppendTo(sourceBuilder, includeContextOverloads);
+ sourceBuilder.AppendLine("namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions;");
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine(
+ """
+ ///
+ /// Extension methods for and
+ ///
+ """
+ );
+ sourceBuilder.AppendLine("public static partial class ResultExtensions {");
+ sourceBuilder.AppendLine();
+
+ foreach (var method in HttpResultMethodCatalog.StringErrorMethods)
+ {
+ sourceBuilder.AppendLine(StringErrorMethodRenderer.GenerateStatic(method));
+ sourceBuilder.AppendLine();
+
+ if (includeContextOverloads)
+ {
+ sourceBuilder.AppendLine(StringErrorMethodRenderer.GenerateWithContext(method));
+ sourceBuilder.AppendLine();
+ }
+ }
+
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine("}");
+
+ return sourceBuilder.ToString();
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs
index 7f03bd1..d09fdea 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs
@@ -1,13 +1,9 @@
-using CSharpFunctionalExtensions.HttpResults.Generators.UnitResultExtensions;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
-public class UnitResultExtensionsClassBuilder(
- List mapperClasses,
- Compilation? compilation = null
-) : ClassBuilder(mapperClasses, compilation)
+internal sealed class UnitResultExtensionsClassBuilder(IReadOnlyList mapperDescriptors)
+ : ExtensionClassBuilder(mapperDescriptors)
{
protected override string ClassName => "UnitResultExtensions";
@@ -18,6 +14,6 @@ public class UnitResultExtensionsClassBuilder(
///
""";
- internal override List MethodGenerators =>
- [new ToStatusCodeHttpResultE(), new ToNoContentHttpResultE()];
+ internal override IReadOnlyList Methods =>
+ HttpResultMethodCatalog.UnitResultWithCustomErrorMethods;
}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Compatibility/CompilerFeaturePolyfills.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Compatibility/CompilerFeaturePolyfills.cs
new file mode 100644
index 0000000..3da7add
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Compatibility/CompilerFeaturePolyfills.cs
@@ -0,0 +1,27 @@
+// Polyfills for language features that are not available on netstandard2.0:
+// init-only setters (IsExternalInit) and required members.
+
+namespace System.Runtime.CompilerServices
+{
+ /// Polyfill for init-only property setters.
+ internal static class IsExternalInit { }
+
+ /// Polyfill for the required members feature.
+ [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = false)]
+ internal sealed class CompilerFeatureRequiredAttribute : Attribute
+ {
+ public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName;
+
+ public string FeatureName { get; }
+ }
+
+ /// Polyfill for the required members feature.
+ internal sealed class RequiredMemberAttribute : Attribute { }
+}
+
+namespace System.Diagnostics.CodeAnalysis
+{
+ /// Polyfill for constructors of types with required members.
+ [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = false)]
+ internal sealed class SetsRequiredMembersAttribute : Attribute { }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs
new file mode 100644
index 0000000..c77274c
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs
@@ -0,0 +1,10 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Discovery;
+
+/// Symbols relevant to mapping generation discovered in source and referenced assemblies.
+internal sealed record ImplementationScan(
+ IReadOnlyList SourceMappers,
+ IReadOnlyList Providers,
+ IReadOnlyList ReferencedServiceMappers
+);
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs
new file mode 100644
index 0000000..8a9a2d3
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs
@@ -0,0 +1,110 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Discovery;
+
+/// Discovers mapper and provider implementations once per compilation.
+internal static class ImplementationScanner
+{
+ public static ImplementationScan Scan(Compilation compilation)
+ {
+ var resultMapperInterface = compilation.GetTypeByMetadataName(
+ $"{MapperSymbolResolver.LibraryNamespace}.{MapperSymbolResolver.ResultErrorMapperInterfaceMetadataName}"
+ );
+ var serviceMapperInterface = compilation.GetTypeByMetadataName(
+ $"{MapperSymbolResolver.LibraryNamespace}.{MapperSymbolResolver.ServiceResultErrorMapperInterfaceMetadataName}"
+ );
+ var providerInterface = compilation.GetTypeByMetadataName(
+ $"{MapperSymbolResolver.LibraryNamespace}.IResultProblemDetailsProvider"
+ );
+
+ var sourceTypes = GetAllTypes(compilation.Assembly.GlobalNamespace).ToArray();
+ var sourceMappers = resultMapperInterface is null
+ ? []
+ : sourceTypes.Where(type => ImplementsInterface(type, resultMapperInterface)).ToArray();
+
+ var providers = providerInterface is null
+ ? new List()
+ : sourceTypes.Where(type => ImplementsInterface(type, providerInterface)).ToList();
+ var referencedServiceMappers = new List();
+
+ var libraryAssembly = providerInterface?.ContainingAssembly ?? resultMapperInterface?.ContainingAssembly;
+ if (libraryAssembly is not null)
+ {
+ foreach (var reference in compilation.References)
+ {
+ if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly)
+ continue;
+
+ if (SymbolEqualityComparer.Default.Equals(assembly, libraryAssembly))
+ continue;
+
+ // Only assemblies that reference this library can contain relevant implementations. This avoids
+ // traversing every type in the framework and unrelated dependencies for each consumer compilation.
+ if (!ReferencesAssembly(assembly, libraryAssembly))
+ continue;
+
+ foreach (var type in GetAllTypes(assembly.GlobalNamespace))
+ {
+ if (
+ providerInterface is not null
+ && ImplementsInterface(type, providerInterface)
+ && SymbolAccessibility.IsReferenceable(type, compilation.Assembly)
+ )
+ providers.Add(type);
+
+ if (
+ serviceMapperInterface is not null
+ && ImplementsInterface(type, serviceMapperInterface)
+ && SymbolAccessibility.IsReferenceable(type, compilation.Assembly)
+ )
+ referencedServiceMappers.Add(type);
+ }
+ }
+ }
+
+ return new ImplementationScan(
+ sourceMappers.Distinct(SymbolEqualityComparer.Default).OfType().ToArray(),
+ providers.Distinct(SymbolEqualityComparer.Default).OfType().ToArray(),
+ referencedServiceMappers.Distinct(SymbolEqualityComparer.Default).OfType().ToArray()
+ );
+ }
+
+ private static bool ImplementsInterface(INamedTypeSymbol type, INamedTypeSymbol interfaceType) =>
+ type.AllInterfaces.Any(implemented =>
+ SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, interfaceType)
+ );
+
+ private static bool ReferencesAssembly(IAssemblySymbol assembly, IAssemblySymbol referencedAssembly) =>
+ assembly.Modules.Any(module =>
+ module.ReferencedAssemblySymbols.Any(reference =>
+ SymbolEqualityComparer.Default.Equals(reference, referencedAssembly)
+ )
+ );
+
+ private static IEnumerable GetAllTypes(INamespaceSymbol namespaceSymbol)
+ {
+ foreach (var type in namespaceSymbol.GetTypeMembers())
+ {
+ yield return type;
+
+ foreach (var nestedType in GetNestedTypes(type))
+ yield return nestedType;
+ }
+
+ foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers())
+ foreach (var type in GetAllTypes(childNamespace))
+ yield return type;
+ }
+
+ private static IEnumerable GetNestedTypes(INamedTypeSymbol type)
+ {
+ foreach (var nestedType in type.GetTypeMembers())
+ {
+ yield return nestedType;
+
+ foreach (var descendant in GetNestedTypes(nestedType))
+ yield return descendant;
+ }
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ResultErrorMapperCacheEmitter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ResultErrorMapperCacheEmitter.cs
new file mode 100644
index 0000000..c56ff62
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ResultErrorMapperCacheEmitter.cs
@@ -0,0 +1,28 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Emitters;
+
+/// Emits internal singleton instances for valid result error mappers.
+internal static class ResultErrorMapperCacheEmitter
+{
+ public const string HintName = "ResultErrorMapperCache.g.cs";
+
+ public static string Emit(IReadOnlyList mapperDescriptors)
+ {
+ var source = new StringBuilder();
+ source.AppendLine("// ");
+ source.AppendLine();
+ source.AppendLine("#nullable enable");
+ source.AppendLine();
+ source.AppendLine("internal static class CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache {");
+
+ foreach (var mapper in mapperDescriptors.Where(info => !info.IsServiceMapper))
+ source.AppendLine(
+ $" internal static {mapper.MapperInterfaceType} {mapper.CacheMemberName} {{ get; }} = new {mapper.FullyQualifiedName}();"
+ );
+
+ source.AppendLine("}");
+ return source.ToString();
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs
new file mode 100644
index 0000000..121c53a
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs
@@ -0,0 +1,72 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Emitters;
+
+/// Emits zero-configuration scoped registrations for discovered DI implementations.
+internal static class ServiceCollectionExtensionsEmitter
+{
+ public const string HintName = "ServiceCollectionExtensions.g.cs";
+
+ public static string Emit(
+ Compilation compilation,
+ INamedTypeSymbol? provider,
+ IReadOnlyList referencedServiceMappers,
+ IReadOnlyList sourceMappers
+ )
+ {
+ var mapperTypes = sourceMappers
+ .Where(info => info.IsServiceMapper)
+ .Select(info => info.Symbol)
+ .Concat(referencedServiceMappers)
+ .Where(symbol => IsValidServiceMapper(symbol, compilation))
+ .Distinct(SymbolEqualityComparer.Default)
+ .OfType()
+ .OrderBy(SymbolTypeNameFormatter.GetFullyQualifiedTypeName, StringComparer.Ordinal)
+ .ToArray();
+
+ var source = new StringBuilder();
+ source.AppendLine("// ");
+ source.AppendLine();
+ source.AppendLine("#nullable enable");
+ source.AppendLine("using Microsoft.Extensions.DependencyInjection;");
+ source.AppendLine("using Microsoft.Extensions.DependencyInjection.Extensions;");
+ source.AppendLine();
+ source.AppendLine("namespace CSharpFunctionalExtensions.HttpResults;");
+ source.AppendLine();
+ // The helper is emitted into every consuming compilation. Keeping its containing type internal avoids
+ // clashes with the same generated helper in referenced projects while the extension remains available
+ // everywhere inside the application being compiled.
+ source.AppendLine("internal static class GeneratedServiceCollectionExtensions {");
+ source.AppendLine(
+ " /// Registers discovered DI-enabled problem-details and error mappers as scoped services."
+ );
+ source.AppendLine(
+ " public static IServiceCollection AddCSharpFunctionalExtensionsHttpResults(this IServiceCollection services) {"
+ );
+
+ if (provider is not null)
+ source.AppendLine(
+ $" services.TryAddScoped();"
+ );
+
+ foreach (var mapperType in mapperTypes)
+ source.AppendLine(
+ $" services.TryAddScoped<{SymbolTypeNameFormatter.GetFullyQualifiedTypeName(mapperType)}>();"
+ );
+
+ source.AppendLine(" return services;");
+ source.AppendLine(" }");
+ source.AppendLine("}");
+ return source.ToString();
+ }
+
+ private static bool IsValidServiceMapper(INamedTypeSymbol type, Compilation compilation) =>
+ type.TypeKind == TypeKind.Class
+ && !type.IsAbstract
+ && !type.IsStatic
+ && SymbolAccessibility.IsReferenceable(type, compilation.Assembly)
+ && SymbolAccessibility.HasPublicInstanceConstructor(type);
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs b/CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs
deleted file mode 100644
index 5239aa3..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators;
-
-internal interface IGenerateMethods
-{
- string Generate(string mapperClassName, string resultErrorType, string httpResultType);
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodCatalog.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodCatalog.cs
new file mode 100644
index 0000000..cdb0e00
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodCatalog.cs
@@ -0,0 +1,36 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+/// The single source of truth for every generated HTTP-result method family.
+internal static class HttpResultMethodCatalog
+{
+ private static readonly IReadOnlyList Definitions =
+ [
+ new(HttpResultMethodKind.StatusCode, ResultReceiverKind.Result),
+ new(HttpResultMethodKind.StatusCode, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.Json, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.Ok, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.NoContent, ResultReceiverKind.Result),
+ new(HttpResultMethodKind.NoContent, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.Created, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.CreatedAtRoute, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.Accepted, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.AcceptedAtRoute, ResultReceiverKind.ResultOfT),
+ new(HttpResultMethodKind.File, ResultReceiverKind.ResultOfByteArray),
+ new(HttpResultMethodKind.FileStream, ResultReceiverKind.ResultOfStream),
+ new(HttpResultMethodKind.Content, ResultReceiverKind.ResultOfString),
+ new(HttpResultMethodKind.ServerSentEvents, ResultReceiverKind.ResultOfAsyncEnumerable),
+ new(HttpResultMethodKind.NoContent, ResultReceiverKind.UnitResult),
+ new(HttpResultMethodKind.StatusCode, ResultReceiverKind.UnitResult),
+ ];
+
+ public static IReadOnlyList StringErrorMethods { get; } =
+ Definitions.Where(definition => definition.SupportsStringErrors).ToArray();
+
+ public static IReadOnlyList ResultWithCustomErrorMethods { get; } =
+ Definitions
+ .Where(definition => definition.SupportsCustomErrors && definition.Receiver != ResultReceiverKind.UnitResult)
+ .ToArray();
+
+ public static IReadOnlyList UnitResultWithCustomErrorMethods { get; } =
+ Definitions.Where(definition => definition.Receiver == ResultReceiverKind.UnitResult).ToArray();
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodDefinition.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodDefinition.cs
new file mode 100644
index 0000000..475a902
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodDefinition.cs
@@ -0,0 +1,155 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+///
+/// Structured metadata for one generated HTTP-result method family. All string-error and custom-error
+/// generation derives from this single model so their signatures cannot drift independently.
+///
+internal sealed record HttpResultMethodDefinition(HttpResultMethodKind Kind, ResultReceiverKind Receiver)
+{
+ public bool SupportsStringErrors => Receiver != ResultReceiverKind.UnitResult;
+
+ public bool SupportsCustomErrors => Receiver != ResultReceiverKind.Result;
+
+ public string MethodName =>
+ Kind switch
+ {
+ HttpResultMethodKind.File => "ToFileHttpResult",
+ _ => $"To{Kind}HttpResult",
+ };
+
+ public string SuccessArm =>
+ Kind switch
+ {
+ HttpResultMethodKind.StatusCode => "StatusCodeHttpResult",
+ HttpResultMethodKind.Json => "JsonHttpResult",
+ HttpResultMethodKind.Ok => "Ok",
+ HttpResultMethodKind.NoContent => "NoContent",
+ HttpResultMethodKind.Created => "Created",
+ HttpResultMethodKind.CreatedAtRoute => "CreatedAtRoute",
+ HttpResultMethodKind.Accepted => "Accepted",
+ HttpResultMethodKind.AcceptedAtRoute => "AcceptedAtRoute",
+ HttpResultMethodKind.File => "FileContentHttpResult",
+ HttpResultMethodKind.FileStream => "FileStreamHttpResult",
+ HttpResultMethodKind.Content => "ContentHttpResult",
+ HttpResultMethodKind.ServerSentEvents => "ServerSentEventsResult",
+ _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null),
+ };
+
+ public string SuccessExpression =>
+ Kind switch
+ {
+ HttpResultMethodKind.StatusCode => "TypedResults.StatusCode(successStatusCode)",
+ HttpResultMethodKind.Json => "TypedResults.Json(result.Value, statusCode: successStatusCode)",
+ HttpResultMethodKind.Ok => "TypedResults.Ok(result.Value)",
+ HttpResultMethodKind.NoContent => "TypedResults.NoContent()",
+ HttpResultMethodKind.Created =>
+ "uri is null ? TypedResults.Created(string.Empty, result.Value) : TypedResults.Created(uri.Invoke(result.Value), result.Value)",
+ HttpResultMethodKind.CreatedAtRoute =>
+ "TypedResults.CreatedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value))",
+ HttpResultMethodKind.Accepted => "TypedResults.Accepted(uri(result.Value), result.Value)",
+ HttpResultMethodKind.AcceptedAtRoute =>
+ "TypedResults.AcceptedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value))",
+ HttpResultMethodKind.File =>
+ "TypedResults.File(result.Value, contentType, fileDownloadName, enableRangeProcessing, lastModified, entityTag)",
+ HttpResultMethodKind.FileStream =>
+ "TypedResults.Stream(result.Value, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing)",
+ HttpResultMethodKind.Content => "TypedResults.Content(result.Value, contentType, contentEncoding, statusCode)",
+ HttpResultMethodKind.ServerSentEvents => "TypedResults.ServerSentEvents(result.Value, eventType)",
+ _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null),
+ };
+
+ public IReadOnlyList Parameters =>
+ Kind switch
+ {
+ HttpResultMethodKind.StatusCode => [new(MethodParameterKind.EmptySuccessStatusCode)],
+ HttpResultMethodKind.Json => [new(MethodParameterKind.JsonSuccessStatusCode)],
+ HttpResultMethodKind.Created => [new(MethodParameterKind.OptionalUri)],
+ HttpResultMethodKind.CreatedAtRoute or HttpResultMethodKind.AcceptedAtRoute =>
+ [
+ new(MethodParameterKind.RouteName),
+ new(MethodParameterKind.RouteValues),
+ ],
+ HttpResultMethodKind.Accepted => [new(MethodParameterKind.RequiredUri)],
+ HttpResultMethodKind.File or HttpResultMethodKind.FileStream =>
+ [
+ new(MethodParameterKind.ContentType),
+ new(MethodParameterKind.FileDownloadName),
+ new(MethodParameterKind.LastModified),
+ new(MethodParameterKind.EntityTag),
+ new(MethodParameterKind.EnableRangeProcessing),
+ ],
+ HttpResultMethodKind.Content =>
+ [
+ new(MethodParameterKind.ContentType),
+ new(MethodParameterKind.ContentEncoding),
+ new(MethodParameterKind.ContentStatusCode),
+ ],
+ HttpResultMethodKind.ServerSentEvents => [new(MethodParameterKind.EventType)],
+ _ => [],
+ };
+
+ public bool HasTypeParameter =>
+ Receiver
+ is ResultReceiverKind.ResultOfT
+ or ResultReceiverKind.ResultOfStream
+ or ResultReceiverKind.ResultOfAsyncEnumerable;
+
+ public string TypeConstraints => Receiver == ResultReceiverKind.ResultOfStream ? " where T : Stream" : "";
+
+ public string PreprocessorDirective => Kind == HttpResultMethodKind.ServerSentEvents ? "NET10_0_OR_GREATER" : "";
+
+ public string GetStringReceiver(bool isAsync)
+ {
+ var receiver = Receiver switch
+ {
+ ResultReceiverKind.Result => "Result",
+ ResultReceiverKind.ResultOfT or ResultReceiverKind.ResultOfStream => "Result",
+ ResultReceiverKind.ResultOfByteArray => "Result",
+ ResultReceiverKind.ResultOfString => "Result",
+ ResultReceiverKind.ResultOfAsyncEnumerable => "Result>",
+ _ => throw new InvalidOperationException($"{Receiver} does not support string-error mapping."),
+ };
+
+ return isAsync ? $"Task<{receiver}>" : receiver;
+ }
+
+ public string GetCustomReceiver(string errorType, bool isAsync)
+ {
+ var receiver = Receiver switch
+ {
+ ResultReceiverKind.ResultOfT or ResultReceiverKind.ResultOfStream => $"Result",
+ ResultReceiverKind.ResultOfByteArray => $"Result",
+ ResultReceiverKind.ResultOfString => $"Result",
+ ResultReceiverKind.ResultOfAsyncEnumerable => $"Result,{errorType}>",
+ ResultReceiverKind.UnitResult => $"UnitResult<{errorType}>",
+ _ => throw new InvalidOperationException($"{Receiver} does not support custom-error mapping."),
+ };
+
+ return isAsync ? $"Task<{receiver}>" : receiver;
+ }
+
+ public string BuildSummary(bool customError)
+ {
+ var valueText = Kind switch
+ {
+ HttpResultMethodKind.StatusCode => "a ",
+ HttpResultMethodKind.Json => "a ",
+ HttpResultMethodKind.Ok => "an ",
+ HttpResultMethodKind.NoContent => "a ",
+ HttpResultMethodKind.Created => "a ",
+ HttpResultMethodKind.CreatedAtRoute => "a ",
+ HttpResultMethodKind.Accepted => "an ",
+ HttpResultMethodKind.AcceptedAtRoute => "an ",
+ HttpResultMethodKind.File => "a ",
+ HttpResultMethodKind.FileStream => "a ",
+ HttpResultMethodKind.Content => "a ",
+ HttpResultMethodKind.ServerSentEvents => "a ",
+ _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null),
+ };
+
+ var failureText = customError
+ ? "Returns the custom error mapping in case of failure."
+ : "Returns a in case of failure.";
+ return $"Returns {valueText} in case of success. {failureText}";
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodKind.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodKind.cs
new file mode 100644
index 0000000..eda24c8
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodKind.cs
@@ -0,0 +1,18 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+/// The ASP.NET Core typed-result family produced on a successful result.
+internal enum HttpResultMethodKind
+{
+ StatusCode,
+ Json,
+ Ok,
+ NoContent,
+ Created,
+ CreatedAtRoute,
+ Accepted,
+ AcceptedAtRoute,
+ File,
+ FileStream,
+ Content,
+ ServerSentEvents,
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodParameter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodParameter.cs
new file mode 100644
index 0000000..0eb5557
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodParameter.cs
@@ -0,0 +1,56 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+/// Strongly typed metadata for one parameter in a generated mapping method.
+internal readonly record struct HttpResultMethodParameter(MethodParameterKind Kind)
+{
+ public string Type =>
+ Kind switch
+ {
+ MethodParameterKind.JsonSuccessStatusCode or MethodParameterKind.EmptySuccessStatusCode => "int",
+ MethodParameterKind.OptionalUri => "Func?",
+ MethodParameterKind.RequiredUri => "Func",
+ MethodParameterKind.RouteName
+ or MethodParameterKind.ContentType
+ or MethodParameterKind.FileDownloadName
+ or MethodParameterKind.EventType => "string?",
+ MethodParameterKind.RouteValues => "Func?",
+ MethodParameterKind.LastModified => "DateTimeOffset?",
+ MethodParameterKind.EntityTag => "EntityTagHeaderValue?",
+ MethodParameterKind.EnableRangeProcessing => "bool",
+ MethodParameterKind.ContentEncoding => "Encoding?",
+ MethodParameterKind.ContentStatusCode => "int?",
+ _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null),
+ };
+
+ public string Name =>
+ Kind switch
+ {
+ MethodParameterKind.JsonSuccessStatusCode or MethodParameterKind.EmptySuccessStatusCode => "successStatusCode",
+ MethodParameterKind.OptionalUri or MethodParameterKind.RequiredUri => "uri",
+ MethodParameterKind.RouteName => "routeName",
+ MethodParameterKind.RouteValues => "routeValues",
+ MethodParameterKind.ContentType => "contentType",
+ MethodParameterKind.FileDownloadName => "fileDownloadName",
+ MethodParameterKind.LastModified => "lastModified",
+ MethodParameterKind.EntityTag => "entityTag",
+ MethodParameterKind.EnableRangeProcessing => "enableRangeProcessing",
+ MethodParameterKind.ContentEncoding => "contentEncoding",
+ MethodParameterKind.ContentStatusCode => "statusCode",
+ MethodParameterKind.EventType => "eventType",
+ _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null),
+ };
+
+ public string? DefaultValue =>
+ Kind switch
+ {
+ MethodParameterKind.RequiredUri => null,
+ MethodParameterKind.JsonSuccessStatusCode => "200",
+ MethodParameterKind.EmptySuccessStatusCode => "204",
+ MethodParameterKind.EnableRangeProcessing => "false",
+ _ => "null",
+ };
+
+ public bool IsRequired => DefaultValue is null;
+
+ public override string ToString() => IsRequired ? $"{Type} {Name}" : $"{Type} {Name} = {DefaultValue}";
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperDescriptor.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperDescriptor.cs
new file mode 100644
index 0000000..fafc381
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperDescriptor.cs
@@ -0,0 +1,45 @@
+using Microsoft.CodeAnalysis;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+///
+/// Precomputed information about one discovered error mapper, shared by analysis,
+/// the builders and the registration emitter.
+///
+internal sealed class MapperDescriptor
+{
+ /// Declared symbol of the mapper class.
+ public required INamedTypeSymbol Symbol { get; init; }
+
+ /// Unique generated cache member for non-service mappers.
+ public required string CacheMemberName { get; init; }
+
+ /// Fully qualified name of the mapper type.
+ public required string FullyQualifiedName { get; init; }
+
+ /// Fully qualified name of the mapped error type (first type argument of IResultErrorMapper<,>).
+ public required string ErrorType { get; init; }
+
+ /// Fully qualified name of the HttpResult type (second type argument of IResultErrorMapper<,>).
+ public required string HttpResultType { get; init; }
+
+ /// Fully qualified closed IResultErrorMapper interface implemented by the mapper.
+ public required string MapperInterfaceType { get; init; }
+
+ public required bool IsServiceMapper { get; init; }
+
+ /// Indicates whether the Map method returns exactly .
+ public required bool IsProblemHttpResult { get; init; }
+
+ public MapperGenerationContext ToContext() =>
+ new()
+ {
+ CacheMemberName = CacheMemberName,
+ ErrorType = ErrorType,
+ HttpResultType = HttpResultType,
+ IsServiceMapper = IsServiceMapper,
+ MapperFullyQualifiedName = FullyQualifiedName,
+ MapperInterfaceType = MapperInterfaceType,
+ IsProblemHttpResult = IsProblemHttpResult,
+ };
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperGenerationContext.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperGenerationContext.cs
new file mode 100644
index 0000000..016acd3
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperGenerationContext.cs
@@ -0,0 +1,29 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+///
+/// Carries all information a method renderer needs to generate the extension methods
+/// for a single discovered error mapper.
+///
+internal readonly record struct MapperGenerationContext
+{
+ /// Unique generated cache member used for a standard mapper.
+ public required string CacheMemberName { get; init; }
+
+ /// Fully qualified name of the mapped error type.
+ public required string ErrorType { get; init; }
+
+ /// Fully qualified name of the HttpResult type returned by the mapper's Map method.
+ public required string HttpResultType { get; init; }
+
+ /// Indicates whether the mapper implements .
+ public required bool IsServiceMapper { get; init; }
+
+ /// Fully qualified name of the mapper type; used for request-service resolution of service mappers.
+ public required string MapperFullyQualifiedName { get; init; }
+
+ /// Fully qualified closed mapper interface used to invoke Map through its contract.
+ public required string MapperInterfaceType { get; init; }
+
+ /// Indicates whether the mapper's Map method returns exactly .
+ public required bool IsProblemHttpResult { get; init; }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/MethodParameterKind.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MethodParameterKind.cs
new file mode 100644
index 0000000..496bb20
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MethodParameterKind.cs
@@ -0,0 +1,20 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+/// A supported parameter in a generated mapping-method signature.
+internal enum MethodParameterKind
+{
+ JsonSuccessStatusCode,
+ EmptySuccessStatusCode,
+ OptionalUri,
+ RequiredUri,
+ RouteName,
+ RouteValues,
+ ContentType,
+ FileDownloadName,
+ LastModified,
+ EntityTag,
+ EnableRangeProcessing,
+ ContentEncoding,
+ ContentStatusCode,
+ EventType,
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/ResultReceiverKind.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/ResultReceiverKind.cs
new file mode 100644
index 0000000..4c5be37
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/ResultReceiverKind.cs
@@ -0,0 +1,13 @@
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+/// The CSharpFunctionalExtensions result shape accepted by a generated method.
+internal enum ResultReceiverKind
+{
+ Result,
+ ResultOfT,
+ ResultOfByteArray,
+ ResultOfStream,
+ ResultOfString,
+ ResultOfAsyncEnumerable,
+ UnitResult,
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/CustomErrorMethodRenderer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/CustomErrorMethodRenderer.cs
new file mode 100644
index 0000000..0ab5f54
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/CustomErrorMethodRenderer.cs
@@ -0,0 +1,145 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering;
+
+///
+/// Renders the sync and async extension-method overloads for one custom-error method family
+/// and one discovered error mapper. Standard mappers resolve through their static instance,
+/// service mappers through HttpContext.RequestServices.
+///
+internal static class CustomErrorMethodRenderer
+{
+ private const string ServiceGuard = "A HttpContext is required for IServiceResultErrorMapper mappings.";
+
+ public static string Generate(in MapperGenerationContext context, HttpResultMethodDefinition method)
+ {
+ var summary = BuildSummary(context, method);
+ return SyncAsyncMethodPairRenderer.Render(
+ method,
+ summary,
+ BuildSignature(context, method, isAsync: false),
+ BuildSignature(context, method, isAsync: true),
+ FailureBlock(context),
+ BuildForwardCall(context, method, awaitReceiver: true)
+ );
+ }
+
+ private static string BuildSummary(in MapperGenerationContext context, HttpResultMethodDefinition method)
+ {
+ var summary = method.BuildSummary(customError: true);
+
+ if (context.IsServiceMapper)
+ summary +=
+ " Requires an argument to resolve the registered from request services on failure.";
+
+ if (context.IsProblemHttpResult)
+ summary += " The callback is applied to the returned ProblemHttpResult.";
+
+ return summary;
+ }
+
+ private static string BuildParameters(
+ in MapperGenerationContext context,
+ HttpResultMethodDefinition method,
+ bool isAsync
+ )
+ {
+ var parameters = new List { $"this {method.GetCustomReceiver(context.ErrorType, isAsync)} result" };
+
+ var httpContextAdded = false;
+
+ foreach (var parameter in method.Parameters)
+ {
+ if (context.IsServiceMapper && !httpContextAdded && !parameter.IsRequired)
+ {
+ parameters.Add("HttpContext httpContext");
+ httpContextAdded = true;
+ }
+
+ parameters.Add(parameter.ToString());
+ }
+
+ if (context.IsServiceMapper && !httpContextAdded)
+ parameters.Add("HttpContext httpContext");
+
+ if (context.IsProblemHttpResult)
+ parameters.Add("Action? customizeProblemDetails = null");
+
+ return string.Join(", ", parameters);
+ }
+
+ private static string BuildSignature(
+ in MapperGenerationContext context,
+ HttpResultMethodDefinition method,
+ bool isAsync
+ )
+ {
+ var generic = method.HasTypeParameter ? "" : "";
+ var asyncKeyword = isAsync ? "async " : "";
+ var returnType = isAsync
+ ? $"Task>"
+ : $"Results<{method.SuccessArm}, {context.HttpResultType}>";
+
+ return $"public static {asyncKeyword}{returnType} {method.MethodName}{generic}({BuildParameters(context, method, isAsync)}){method.TypeConstraints}";
+ }
+
+ private static string BuildForwardArguments(
+ in MapperGenerationContext context,
+ HttpResultMethodDefinition method,
+ bool awaitReceiver
+ )
+ {
+ var arguments = new List { awaitReceiver ? "await result" : "result" };
+
+ foreach (var parameter in method.Parameters.Where(parameter => parameter.IsRequired))
+ arguments.Add(parameter.Name);
+
+ if (context.IsServiceMapper)
+ arguments.Add("httpContext");
+
+ foreach (var parameter in method.Parameters.Where(parameter => !parameter.IsRequired))
+ arguments.Add(parameter.Name);
+
+ if (context.IsProblemHttpResult)
+ arguments.Add("customizeProblemDetails");
+
+ return string.Join(", ", arguments);
+ }
+
+ private static string BuildForwardCall(
+ in MapperGenerationContext context,
+ HttpResultMethodDefinition method,
+ bool awaitReceiver
+ ) =>
+ $"{method.MethodName}{(method.HasTypeParameter ? "" : "")}({BuildForwardArguments(context, method, awaitReceiver)})";
+
+ private static string FailureBlock(in MapperGenerationContext context)
+ {
+ var lines = new List();
+
+ if (context.IsServiceMapper)
+ {
+ lines.Add(" if (httpContext is null)");
+ lines.Add($" throw new InvalidOperationException(\"{ServiceGuard}\");");
+ lines.Add("");
+ }
+
+ var mapped = context.IsServiceMapper
+ ? $"(({context.MapperInterfaceType})httpContext.RequestServices.GetRequiredService<{context.MapperFullyQualifiedName}>()).Map(result.Error)"
+ : $"CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache.{context.CacheMemberName}.Map(result.Error)";
+
+ if (context.IsProblemHttpResult)
+ {
+ lines.Add($" var mapped = {mapped};");
+ lines.Add(" customizeProblemDetails?.Invoke(mapped.ProblemDetails);");
+ lines.Add("");
+ lines.Add(" return mapped;");
+ }
+ else
+ {
+ lines.Add($" return {mapped};");
+ }
+
+ return string.Join("\n", lines);
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/GeneratedSourceUsings.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/GeneratedSourceUsings.cs
new file mode 100644
index 0000000..57f3b98
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/GeneratedSourceUsings.cs
@@ -0,0 +1,30 @@
+using System.Text;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering;
+
+/// Writes the common using directives required by generated result-extension source files.
+internal static class GeneratedSourceUsings
+{
+ public static void AppendTo(StringBuilder sourceBuilder, bool includeDependencyInjection)
+ {
+ sourceBuilder.AppendLine(
+ """
+ using CSharpFunctionalExtensions;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Net.Http.Headers;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Text;
+ using System.Threading.Tasks;
+ """
+ );
+
+ if (includeDependencyInjection)
+ sourceBuilder.AppendLine("using Microsoft.Extensions.DependencyInjection;");
+
+ sourceBuilder.AppendLine();
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/StringErrorMethodRenderer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/StringErrorMethodRenderer.cs
new file mode 100644
index 0000000..2c6c898
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/StringErrorMethodRenderer.cs
@@ -0,0 +1,126 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering;
+
+///
+/// Renders the built-in mapping methods for string-error results. The static variants replicate the
+/// former handwritten behavior; the context variants resolve an
+/// from
+/// HttpContext.RequestServices on failure.
+///
+internal static class StringErrorMethodRenderer
+{
+ private const string StandardTailParameters =
+ "int failureStatusCode = 400, Action? customizeProblemDetails = null";
+
+ private const string ProviderGuard =
+ "A HttpContext is required to resolve the registered IResultProblemDetailsProvider.";
+
+ private const string StaticFailureBlock = """
+ var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode);
+ var problemDetails = new ProblemDetails
+ {
+ Status = failureStatusCode,
+ Title = problemDetailsInfo.Title,
+ Type = problemDetailsInfo.Type,
+ Detail = result.Error,
+ };
+
+ customizeProblemDetails?.Invoke(problemDetails);
+
+ return TypedResults.Problem(problemDetails);
+ """;
+
+ private const string ContextFailureBlock = """
+ if (httpContext is null)
+ throw new InvalidOperationException(
+ "A HttpContext is required to resolve the registered IResultProblemDetailsProvider.");
+
+ var problemDetails = httpContext.RequestServices.GetRequiredService()
+ .CreateProblemDetails(httpContext, result.Error, failureStatusCode);
+
+ customizeProblemDetails?.Invoke(problemDetails);
+
+ return TypedResults.Problem(problemDetails);
+ """;
+
+ public static string GenerateStatic(HttpResultMethodDefinition method)
+ {
+ var summary = method.BuildSummary(customError: false);
+ return SyncAsyncMethodPairRenderer.Render(
+ method,
+ summary,
+ BuildSignature(method, includeContext: false, isAsync: false),
+ BuildSignature(method, includeContext: false, isAsync: true),
+ StaticFailureBlock,
+ BuildForwardCall(method, includeContext: false, awaitReceiver: true)
+ );
+ }
+
+ public static string GenerateWithContext(HttpResultMethodDefinition method)
+ {
+ var summary =
+ $"{method.BuildSummary(customError: false)} Resolves the registered from request services for failures.";
+ return SyncAsyncMethodPairRenderer.Render(
+ method,
+ summary,
+ BuildSignature(method, includeContext: true, isAsync: false),
+ BuildSignature(method, includeContext: true, isAsync: true),
+ ContextFailureBlock,
+ BuildForwardCall(method, includeContext: true, awaitReceiver: true)
+ );
+ }
+
+ private static string BuildParameters(HttpResultMethodDefinition method, bool includeContext, bool isAsync)
+ {
+ var parameters = new List { $"this {method.GetStringReceiver(isAsync)} result" };
+ var httpContextAdded = false;
+
+ foreach (var parameter in method.Parameters)
+ {
+ if (includeContext && !httpContextAdded && !parameter.IsRequired)
+ {
+ parameters.Add("HttpContext httpContext");
+ httpContextAdded = true;
+ }
+
+ parameters.Add(parameter.ToString());
+ }
+
+ if (includeContext && !httpContextAdded)
+ parameters.Add("HttpContext httpContext");
+
+ parameters.Add(StandardTailParameters);
+
+ return string.Join(", ", parameters);
+ }
+
+ private static string BuildSignature(HttpResultMethodDefinition method, bool includeContext, bool isAsync)
+ {
+ var generic = method.HasTypeParameter ? "" : "";
+ var asyncKeyword = isAsync ? "async " : "";
+ var returnType = isAsync
+ ? $"Task>"
+ : $"Results<{method.SuccessArm}, ProblemHttpResult>";
+
+ return $"public static {asyncKeyword}{returnType} {method.MethodName}{generic}({BuildParameters(method, includeContext, isAsync)}){method.TypeConstraints}";
+ }
+
+ private static string BuildForwardCall(HttpResultMethodDefinition method, bool includeContext, bool awaitReceiver)
+ {
+ var arguments = new List { awaitReceiver ? "await result" : "result" };
+
+ foreach (var required in method.Parameters.Where(p => p.IsRequired))
+ arguments.Add(required.Name);
+
+ if (includeContext)
+ arguments.Add("httpContext");
+
+ foreach (var optional in method.Parameters.Where(p => !p.IsRequired))
+ arguments.Add(optional.Name);
+
+ arguments.Add("failureStatusCode, customizeProblemDetails");
+
+ return $"{method.MethodName}{(method.HasTypeParameter ? "" : "")}({string.Join(", ", arguments)})";
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/SyncAsyncMethodPairRenderer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/SyncAsyncMethodPairRenderer.cs
new file mode 100644
index 0000000..5cea6d6
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/SyncAsyncMethodPairRenderer.cs
@@ -0,0 +1,47 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Models;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering;
+
+/// Renders the common sync/async pair and optional target-framework guard for a method family.
+internal static class SyncAsyncMethodPairRenderer
+{
+ public static string Render(
+ HttpResultMethodDefinition method,
+ string summary,
+ string syncSignature,
+ string asyncSignature,
+ string failureBlock,
+ string asyncForwardCall
+ )
+ {
+ var builder = new StringBuilder();
+
+ if (!string.IsNullOrEmpty(method.PreprocessorDirective))
+ builder.AppendLine($"#if {method.PreprocessorDirective}");
+
+ AppendSummary(builder, summary);
+ builder.AppendLine(syncSignature + " {");
+ builder.AppendLine($" if (result.IsSuccess) return {method.SuccessExpression};");
+ builder.AppendLine();
+ builder.AppendLine(failureBlock);
+ builder.AppendLine("}");
+ builder.AppendLine();
+ AppendSummary(builder, summary);
+ builder.AppendLine(asyncSignature + " {");
+ builder.AppendLine($" return {asyncForwardCall};");
+ builder.AppendLine("}");
+
+ if (!string.IsNullOrEmpty(method.PreprocessorDirective))
+ builder.AppendLine("#endif");
+
+ return builder.ToString();
+ }
+
+ private static void AppendSummary(StringBuilder builder, string summary)
+ {
+ builder.AppendLine("/// ");
+ builder.AppendLine($"/// {summary}");
+ builder.AppendLine("/// ");
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs
deleted file mode 100644
index c18526c..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToAcceptedAtRouteHttpResultTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header.
- ///
- public static Results, {{httpResultType}}> ToAcceptedAtRouteHttpResult(this Result result, string? routeName = null, Func? routeValues = null)
- {
- if (result.IsSuccess) return TypedResults.AcceptedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value));
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header.
- ///
- public static async Task, {{httpResultType}}>> ToAcceptedAtRouteHttpResult(this Task> result, string? routeName = null, Func? routeValues = null)
- {
- return (await result).ToAcceptedAtRouteHttpResult(routeName, routeValues);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs
deleted file mode 100644
index fe71561..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToAcceptedHttpResultTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header.
- ///
- public static Results, {{httpResultType}}> ToAcceptedHttpResult(this Result result, Func uri)
- {
- if (result.IsSuccess) return TypedResults.Accepted(uri(result.Value), result.Value);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header.
- ///
- public static async Task, {{httpResultType}}>> ToAcceptedHttpResult(this Task> result, Func uri)
- {
- return (await result).ToAcceptedHttpResult(uri);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs
deleted file mode 100644
index 906738c..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToContentHttpResultStringE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Returns a in case of success result. Returns custom mapping in case of failure.
- ///
- public static Results ToContentHttpResult(this Result result, string? contentType = null, Encoding? contentEncoding = null, int? statusCode = null)
- {
- if (result.IsSuccess) return TypedResults.Content(result.Value, contentType, contentEncoding, statusCode);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a in case of success result. Returns custom mapping in case of failure.
- ///
- public static async Task> ToContentHttpResult(this Task> result, string? contentType = null, Encoding? contentEncoding = null, int? statusCode = null)
- {
- return (await result).ToContentHttpResult(contentType, contentEncoding, statusCode);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs
deleted file mode 100644
index 3b5c8ab..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToCreatedAtRouteHttpResultTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header.
- ///
- public static Results, {{httpResultType}}> ToCreatedAtRouteHttpResult(this Result result, string? routeName = null, Func? routeValues = null)
- {
- if (result.IsSuccess) return TypedResults.CreatedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value));
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header.
- ///
- public static async Task, {{httpResultType}}>> ToCreatedAtRouteHttpResult(this Task> result, string? routeName = null, Func? routeValues = null)
- {
- return (await result).ToCreatedAtRouteHttpResult(routeName, routeValues);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs
deleted file mode 100644
index f2a98b6..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToCreatedHttpResultTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header.
- ///
- public static Results, {{httpResultType}}> ToCreatedHttpResult(this Result result, Func? uri = null)
- {
- if (result.IsSuccess)
- return uri is null
- ? TypedResults.Created(string.Empty, result.Value)
- : TypedResults.Created(uri.Invoke(result.Value), result.Value);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header.
- ///
- public static async Task, {{httpResultType}}>> ToCreatedHttpResult(this Task> result, Func? uri = null)
- {
- return (await result).ToCreatedHttpResult(uri);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs
deleted file mode 100644
index 2f5052f..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToFileHttpResultByteArrayE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///