From f1f4f864525c305ef8f89273198f671fb28cb69c Mon Sep 17 00:00:00 2001
From: Tim Schneider <43130816+DerStimmler@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:13:28 +0200
Subject: [PATCH 1/2] feat: add dependency injection
---
.github/workflows/test-version.yml | 2 +
...nalExtensions.HttpResults.CodeFixes.csproj | 20 ++
.../ServiceMapperCodeFixProvider.cs | 75 ++++++
.../CustomProblemDetailsEndpoint.cs | 31 +++
.../DocumentNotFoundError.cs | 3 +
.../DocumentNotFoundErrorMapper.cs | 24 ++
.../DocumentationLinkProvider.cs | 7 +
.../DependencyInjection/DocumentsEndpoint.cs | 19 ++
.../ProblemDetailsFactoryProvider.cs | 20 ++
.../Program.cs | 10 +
.../README.md | 15 ++
...nsions.HttpResults.Generators.Tests.csproj | 1 +
.../GeneratorTestHelper.cs | 116 +++++++-
.../HttpResultMethodCatalogTests.cs | 33 +++
.../MapperShapeGenerationTests.cs | 205 ++++++++++++++
.../ProviderGenerationTests.cs | 138 ++++++++++
.../RegistrationGenerationTests.cs | 119 +++++++++
.../Rules/DuplicateMapperRuleTests.cs | 7 +-
.../ParameterlessConstructorRuleTests.cs | 8 +-
.../ServiceMapperCodeFixProviderTests.cs | 234 ++++++++++++++++
.../ServiceMapperGeneratorTests.cs | 252 ++++++++++++++++++
.../StringErrorMethodGenerationTests.cs | 135 ++++++++++
.../Analysis/GeneratorDiagnostics.cs | 52 ++++
.../Analysis/MapperAnalysis.cs | 7 +
.../Analysis/MapperAnalyzer.cs | 139 ++++++++++
.../Analysis/MapperSymbolResolver.cs | 33 +++
.../Analysis/ProviderAnalysis.cs | 6 +
.../Analysis/ProviderAnalyzer.cs | 52 ++++
.../Analysis/SymbolAccessibility.cs | 75 ++++++
.../AnalyzerReleases.Unshipped.md | 16 ++
.../Builders/ClassBuilder.cs | 62 ++---
.../Builders/ResultExtensionsClassBuilder.cs | 24 +-
...StringErrorResultExtensionsClassBuilder.cs | 72 +++++
.../UnitResultExtensionsClassBuilder.cs | 14 +-
.../Discovery/ImplementationScan.cs | 10 +
.../Discovery/ImplementationScanner.cs | 110 ++++++++
.../Emitters/LegacyMapperCacheEmitter.cs | 28 ++
.../ServiceCollectionExtensionsEmitter.cs | 70 +++++
.../IGenerateMethods.cs | 6 -
.../ToAcceptedAtRouteHttpResultTE.cs | 27 --
.../ToAcceptedHttpResultTE.cs | 27 --
.../ToContentHttpResultStringE.cs | 27 --
.../ToCreatedAtRouteHttpResultTE.cs | 27 --
.../ResultExtensions/ToCreatedHttpResultTE.cs | 30 ---
.../ToFileHttpResultByteArrayE.cs | 32 ---
.../ToFileStreamHttpResultStreamE.cs | 33 ---
.../ResultExtensions/ToJsonHttpResultTE.cs | 27 --
.../ToNoContentHttpResultTE.cs | 27 --
.../ResultExtensions/ToOkHttpResultTE.cs | 27 --
...rSentEventsHttpResultIAsyncEnumerableTE.cs | 29 --
.../ToStatusCodeHttpResultTE.cs | 27 --
.../ResultExtensionsGenerator.cs | 149 ++++-------
.../ResultExtensionsGeneratorValidator.cs | 32 ---
.../Rules/DuplicateMapperRule.cs | 63 -----
.../Rules/IRule.cs | 22 --
.../Rules/ParameterlessConstructorRule.cs | 41 ---
.../ToNoContentHttpResultE.cs | 27 --
.../ToStatusCodeHttpResultE.cs | 27 --
.../Utils/CustomErrorMethodRenderer.cs | 135 ++++++++++
.../Utils/GeneratedMethodParameter.cs | 56 ++++
.../Utils/GenerationContext.cs | 29 ++
.../Utils/HttpResultMethodCatalog.cs | 36 +++
.../Utils/HttpResultMethodDefinition.cs | 155 +++++++++++
.../Utils/HttpResultMethodKind.cs | 18 ++
.../Utils/IsExternalInit.cs | 27 ++
.../Utils/MapperInfo.cs | 45 ++++
.../Utils/MethodPairRenderer.cs | 46 ++++
.../Utils/MethodParameterKind.cs | 20 ++
.../Utils/ResultReceiverKind.cs | 13 +
.../Utils/StringErrorMethodRenderer.cs | 124 +++++++++
...nsions.HttpResults.IntegrationTests.csproj | 52 ++++
.../DependencyInjectionEndpointsTests.cs | 89 +++++++
.../TestApp/BookEndpoints.cs | 34 +++
.../TestApp/DocumentEndpoints.cs | 21 ++
.../TestApp/DocumentNotFoundError.cs | 3 +
.../TestApp/DocumentNotFoundErrorMapper.cs | 24 ++
.../TestApp/DocumentationLinkProvider.cs | 7 +
.../MvcFactoryProblemDetailsProvider.cs | 21 ++
.../TestApp/Program.cs | 14 +
.../TestApp/TestAppFactory.cs | 11 +
.../TestApp/TestAppStartup.cs | 32 +++
...ProblemDetailsMappingProviderCollection.cs | 10 +
.../ProblemDetailsMappingProviderTests.cs | 2 +
.../ToAcceptedAtRouteHttpResultT.cs | 63 +++++
.../ContextOverloads/ToAcceptedHttpResultT.cs | 66 +++++
.../ToContentHttpResultString.cs | 57 ++++
.../ToCreatedAtRouteHttpResultT.cs | 63 +++++
.../ContextOverloads/ToCreatedHttpResultT.cs | 62 +++++
.../ToFileHttpResultByteArray.cs | 57 ++++
.../ToFileStreamHttpResultStream.cs | 58 ++++
.../ContextOverloads/ToJsonHttpResultT.cs | 60 +++++
.../ContextOverloads/ToNoContentHttpResult.cs | 60 +++++
.../ToNoContentHttpResultT.cs | 62 +++++
.../ContextOverloads/ToOkHttpResultT.cs | 86 ++++++
...erSentEventsHttpResultIAsyncEnumerableT.cs | 68 +++++
.../ToStatusCodeHttpResult.cs | 63 +++++
.../ToStatusCodeHttpResultT.cs | 65 +++++
.../LegacyProblemMapperCallbackTests.cs | 34 +++
.../ServiceCollectionExtensionsTests.cs | 101 +++++++
.../ServiceMapperFamilyTests.cs | 250 +++++++++++++++++
.../Shared/LegacyProblemError.cs | 3 +
.../Shared/LegacyProblemErrorMapper.cs | 10 +
.../RecordingResultProblemDetailsProvider.cs | 35 +++
.../Shared/ServiceDocumentError.cs | 7 +
.../Shared/ServiceDocumentErrorMapper.cs | 22 ++
.../Shared/ServiceMapperDependency.cs | 7 +
.../Shared/TestHttpContext.cs | 20 ++
.../Shared/TestHttpContextHelper.cs | 40 +++
.../Shared/ThrowingCtorError.cs | 7 +
.../Shared/ThrowingCtorMapper.cs | 11 +
CSharpFunctionalExtensions.HttpResults.sln | 12 +
...arpFunctionalExtensions.HttpResults.csproj | 10 +
.../IResultProblemDetailsProvider.cs | 21 ++
.../IServiceResultErrorMapper.cs | 9 +
.../ToAcceptedAtRouteHttpResultT.cs | 62 -----
.../ResultExtensions/ToAcceptedHttpResultT.cs | 55 ----
.../ToContentHttpResultString.cs | 64 -----
.../ToCreatedAtRouteHttpResultT.cs | 62 -----
.../ResultExtensions/ToCreatedHttpResultT.cs | 57 ----
.../ToFileHttpResultByteArray.cs | 77 ------
.../ToFileStreamHttpResultStream.cs | 79 ------
.../ResultExtensions/ToJsonHttpResultT.cs | 53 ----
.../ResultExtensions/ToNoContentHttpResult.cs | 51 ----
.../ToNoContentHttpResultT.cs | 51 ----
.../ResultExtensions/ToOkHttpResultT.cs | 51 ----
...erSentEventsHttpResultIAsyncEnumerableT.cs | 56 ----
.../ToStatusCodeHttpResult.cs | 53 ----
.../ToStatusCodeHttpResultT.cs | 55 ----
README.md | 145 +++++++++-
coverlet.runsettings | 4 +-
130 files changed, 4847 insertions(+), 1592 deletions(-)
create mode 100644 CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj
create mode 100644 CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Examples/Features/CustomProblemDetails/CustomProblemDetailsEndpoint.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Examples/ProblemDetailsFactoryProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Emitters/LegacyMapperCacheEmitter.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileStreamHttpResultStreamE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToJsonHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToNoContentHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToOkHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToStatusCodeHttpResultTE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGeneratorValidator.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Rules/DuplicateMapperRule.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Rules/IRule.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Rules/ParameterlessConstructorRule.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToNoContentHttpResultE.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToStatusCodeHttpResultE.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/CustomErrorMethodRenderer.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/GeneratedMethodParameter.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/GenerationContext.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/HttpResultMethodCatalog.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/HttpResultMethodDefinition.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/HttpResultMethodKind.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/IsExternalInit.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/MapperInfo.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/MethodPairRenderer.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/MethodParameterKind.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/ResultReceiverKind.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Generators/Utils/StringErrorMethodRenderer.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/CSharpFunctionalExtensions.HttpResults.IntegrationTests.csproj
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/DependencyInjectionEndpointsTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/BookEndpoints.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/DocumentEndpoints.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/DocumentNotFoundError.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/DocumentNotFoundErrorMapper.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/DocumentationLinkProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/MvcFactoryProblemDetailsProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/Program.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/TestAppFactory.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestApp/TestAppStartup.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderCollection.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToAcceptedAtRouteHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToAcceptedHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToContentHttpResultString.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToCreatedAtRouteHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToCreatedHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToFileHttpResultByteArray.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToFileStreamHttpResultStream.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToJsonHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToNoContentHttpResult.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToNoContentHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToOkHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToServerSentEventsHttpResultIAsyncEnumerableT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToStatusCodeHttpResult.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ContextOverloads/ToStatusCodeHttpResultT.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/LegacyProblemMapperCallbackTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ServiceCollectionExtensionsTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/ServiceMappers/ServiceMapperFamilyTests.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/LegacyProblemError.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/LegacyProblemErrorMapper.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/RecordingResultProblemDetailsProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentError.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentErrorMapper.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceMapperDependency.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContext.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContextHelper.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorError.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorMapper.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults/IResultProblemDetailsProvider.cs
create mode 100644 CSharpFunctionalExtensions.HttpResults/IServiceResultErrorMapper.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedAtRouteHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToContentHttpResultString.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedAtRouteHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileHttpResultByteArray.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileStreamHttpResultStream.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToJsonHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResult.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToOkHttpResultT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableT.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResult.cs
delete mode 100644 CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResultT.cs
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..fe2ea4b
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs
@@ -0,0 +1,75 @@
+using System.Collections.Immutable;
+using System.Composition;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators;
+
+///
+/// Migrates a legacy mapper to request-service resolution by replacing its directly implemented
+/// IResultErrorMapper interface with the fully qualified IServiceResultErrorMapper interface.
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ServiceMapperCodeFixProvider)), Shared]
+public sealed class ServiceMapperCodeFixProvider : CodeFixProvider
+{
+ private const string ResultMapperMetadataName = "CSharpFunctionalExtensions.HttpResults.IResultErrorMapper`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 classDeclaration = root?.FindNode(context.Span).FirstAncestorOrSelf();
+
+ if (root is null || semanticModel is null || mapperDefinition 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, mapperBase, cancellationToken),
+ nameof(ServiceMapperCodeFixProvider)
+ ),
+ context.Diagnostics
+ );
+ }
+
+ private static Task ReplaceInterfaceAsync(
+ Document document,
+ SyntaxNode root,
+ SemanticModel semanticModel,
+ 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 typeArguments = mapperType.TypeArguments.Select(argument =>
+ argument.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ );
+ var replacement = SyntaxFactory
+ .ParseTypeName(
+ $"global::CSharpFunctionalExtensions.HttpResults.IServiceResultErrorMapper<{string.Join(", ", typeArguments)}>"
+ )
+ .WithTriviaFrom(mapperBase.Type);
+
+ return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(mapperBase.Type, replacement)));
+ }
+}
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/CustomProblemDetails/CustomProblemDetailsEndpoint.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/CustomProblemDetails/CustomProblemDetailsEndpoint.cs
new file mode 100644
index 0000000..852cb78
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/CustomProblemDetails/CustomProblemDetailsEndpoint.cs
@@ -0,0 +1,31 @@
+using CSharpFunctionalExtensions;
+using CSharpFunctionalExtensions.HttpResults.ResultExtensions;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.CustomProblemDetails;
+
+///
+/// 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 CustomProblemDetailsEndpoint
+{
+ public static IEndpointRouteBuilder MapCustomProblemDetails(this IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/custom-problem-details", Handle).WithName(nameof(CustomProblemDetailsEndpoint));
+
+ 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/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/ProblemDetailsFactoryProvider.cs b/CSharpFunctionalExtensions.HttpResults.Examples/ProblemDetailsFactoryProvider.cs
new file mode 100644
index 0000000..62f0233
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/ProblemDetailsFactoryProvider.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+
+namespace CSharpFunctionalExtensions.HttpResults.Examples;
+
+///
+/// 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/Program.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
index f993264..750707f 100644
--- a/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs
@@ -1,6 +1,9 @@
using CSharpFunctionalExtensions.HttpResults;
+using CSharpFunctionalExtensions.HttpResults.Examples;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.CRUD;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.CustomError;
+using CSharpFunctionalExtensions.HttpResults.Examples.Features.CustomProblemDetails;
+using CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection;
using CSharpFunctionalExtensions.HttpResults.Examples.Features.FileStream;
using Scalar.AspNetCore;
@@ -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.MapCustomProblemDetails();
+app.MapDocuments();
app.Run();
diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/README.md b/CSharpFunctionalExtensions.HttpResults.Examples/README.md
index b723307..6c8f29c 100644
--- a/CSharpFunctionalExtensions.HttpResults.Examples/README.md
+++ b/CSharpFunctionalExtensions.HttpResults.Examples/README.md
@@ -20,6 +20,21 @@ 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.
+
+### ProblemDetailsFactory
+
+[`ProblemDetailsFactoryProvider.cs`](./ProblemDetailsFactoryProvider.cs) 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 under
+[`Features/CustomProblemDetails`](./Features/CustomProblemDetails) 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..b618e3c
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs
@@ -0,0 +1,33 @@
+using AwesomeAssertions;
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+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..466c94f
--- /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["LegacyErrorMapperCache.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["LegacyErrorMapperCache.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 Legacy_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["LegacyErrorMapperCache.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["LegacyErrorMapperCache.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["LegacyErrorMapperCache.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..0374145
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs
@@ -0,0 +1,234 @@
+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_legacy_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(
+ "global::CSharpFunctionalExtensions.HttpResults.IServiceResultErrorMapper"
+ );
+ 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["LegacyErrorMapperCache.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(
+ "global::CSharpFunctionalExtensions.HttpResults.IServiceResultErrorMapper"
+ );
+ }
+
+ [Fact]
+ public async Task Fix_supports_an_alias_qualified_closed_interface()
+ {
+ const string source = """
+ using CSharpFunctionalExtensions.HttpResults;
+ using Microsoft.AspNetCore.Http.HttpResults;
+ using LegacyMapper = CSharpFunctionalExtensions.HttpResults.IResultErrorMapper;
+
+ public sealed class Error;
+ public sealed class Mapper : LegacyMapper
+ {
+ public Mapper(string dependency) { }
+ public ProblemHttpResult Map(Error error) => TypedResults.Problem();
+ }
+ """;
+
+ var fixedSource = await ApplyFixAsync(source);
+
+ fixedSource.Should().NotContain(": LegacyMapper");
+ fixedSource
+ .Should()
+ .Contain(
+ "global::CSharpFunctionalExtensions.HttpResults.IServiceResultErrorMapper"
+ );
+ }
+
+ 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..60234c5
--- /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("LegacyMapperCache.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["LegacyErrorMapperCache.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 = CSharpFunctionalExtensionsHttpResultsLegacyMapperCache.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 CSharpFunctionalExtensionsHttpResultsLegacyMapperCache.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..3df80df
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs
@@ -0,0 +1,7 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+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..31f1369
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs
@@ -0,0 +1,139 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+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(TypeNameResolver.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 mapperInfos = validCandidates
+ .Where(entry => !duplicateSymbols.Contains(entry.Symbol))
+ .Select(entry => new MapperInfo
+ {
+ Symbol = entry.Symbol,
+ LegacyCacheMemberName = "",
+ FullyQualifiedName = TypeNameResolver.GetFullyQualifiedTypeName(entry.Symbol),
+ ErrorType = TypeNameResolver.GetFullyQualifiedTypeName(entry.MapperInterface.TypeArguments[0]),
+ HttpResultType = TypeNameResolver.GetFullyQualifiedTypeName(entry.MapperInterface.TypeArguments[1]),
+ MapperInterfaceType = TypeNameResolver.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 MapperInfo
+ {
+ Symbol = info.Symbol,
+ LegacyCacheMemberName = $"Mapper{index}",
+ FullyQualifiedName = info.FullyQualifiedName,
+ ErrorType = info.ErrorType,
+ HttpResultType = info.HttpResultType,
+ MapperInterfaceType = info.MapperInterfaceType,
+ IsServiceMapper = info.IsServiceMapper,
+ IsProblemHttpResult = info.IsProblemHttpResult,
+ }
+ )
+ .ToArray();
+
+ return new MapperAnalysis(mapperInfos, 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..780e616
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs
@@ -0,0 +1,52 @@
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+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(TypeNameResolver.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(TypeNameResolver.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/AnalyzerReleases.Unshipped.md b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md
index e69de29..e22a778 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,16 @@
+## Unshipped
+
+### 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 legacy 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/Builders/ClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs
index b1468e6..23a4acc 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs
@@ -1,22 +1,13 @@
-using System.Text;
+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
+///
+/// Base builder for the generated extension-method classes that are emitted per discovered error mapper.
+///
+internal abstract class ClassBuilder(IReadOnlyList mapperInfos)
{
- 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;
@@ -24,7 +15,11 @@ protected ClassBuilder(List mapperClasses, Compilation?
using Microsoft.AspNetCore.Http;
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;
using IResult = Microsoft.AspNetCore.Http.IResult;
""";
@@ -32,7 +27,7 @@ protected ClassBuilder(List mapperClasses, Compilation?
protected abstract string ClassName { get; }
protected abstract string ClassSummary { get; }
- internal abstract List MethodGenerators { get; }
+ internal abstract IReadOnlyList Methods { get; }
public string Build()
{
@@ -44,31 +39,22 @@ public string Build()
sourceBuilder.AppendLine();
sourceBuilder.AppendLine(DefaultUsings);
+ if (mapperInfos.Any(info => info.IsServiceMapper))
+ sourceBuilder.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sourceBuilder.AppendLine();
+
sourceBuilder.AppendLine(ClassSummary);
sourceBuilder.AppendLine($"public static partial class {ClassName} {{");
sourceBuilder.AppendLine();
- foreach (var mapperClass in _mapperClasses)
+ foreach (var mapperInfo in mapperInfos)
{
- 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 context = mapperInfo.ToContext();
- var resultErrorType = GetFullyQualifiedTypeName(mapperClass, mappingMethod.ParameterList.Parameters[0].Type!);
- var httpResultType = mappingMethod.ReturnType!.ToString();
-
- foreach (var methodGenerator in MethodGenerators)
+ foreach (var method in Methods)
{
- sourceBuilder.AppendLine(methodGenerator.Generate(mapperClassName, resultErrorType, httpResultType));
+ sourceBuilder.AppendLine(CustomErrorMethodRenderer.Generate(context, method));
sourceBuilder.AppendLine();
}
}
@@ -78,18 +64,4 @@ public string Build()
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/ResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs
index 86ae6a7..1f6e0b1 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs
@@ -1,11 +1,8 @@
-using CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
-public class ResultExtensionsClassBuilder(List mapperClasses, Compilation? compilation = null)
- : ClassBuilder(mapperClasses, compilation)
+internal sealed class ResultExtensionsClassBuilder(IReadOnlyList mapperInfos) : ClassBuilder(mapperInfos)
{
protected override string ClassName => "ResultExtensions";
@@ -16,19 +13,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..f869f42
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs
@@ -0,0 +1,72 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+
+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();
+ 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 (includeContextOverloads)
+ sourceBuilder.AppendLine("using Microsoft.Extensions.DependencyInjection;");
+
+ sourceBuilder.AppendLine();
+ 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..d5e6fbc 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.Utils;
namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders;
-public class UnitResultExtensionsClassBuilder(
- List mapperClasses,
- Compilation? compilation = null
-) : ClassBuilder(mapperClasses, compilation)
+internal sealed class UnitResultExtensionsClassBuilder(IReadOnlyList mapperInfos)
+ : ClassBuilder(mapperInfos)
{
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/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/LegacyMapperCacheEmitter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/LegacyMapperCacheEmitter.cs
new file mode 100644
index 0000000..2b72cac
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/LegacyMapperCacheEmitter.cs
@@ -0,0 +1,28 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+
+namespace CSharpFunctionalExtensions.HttpResults.Generators.Emitters;
+
+/// Emits internal singleton instances for valid legacy mappers.
+internal static class LegacyMapperCacheEmitter
+{
+ public const string HintName = "LegacyErrorMapperCache.g.cs";
+
+ public static string Emit(IReadOnlyList mapperInfos)
+ {
+ var source = new StringBuilder();
+ source.AppendLine("// ");
+ source.AppendLine();
+ source.AppendLine("#nullable enable");
+ source.AppendLine();
+ source.AppendLine("internal static class CSharpFunctionalExtensionsHttpResultsLegacyMapperCache {");
+
+ foreach (var mapper in mapperInfos.Where(info => !info.IsServiceMapper))
+ source.AppendLine(
+ $" internal static {mapper.MapperInterfaceType} {mapper.LegacyCacheMemberName} {{ 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..e7dcb8b
--- /dev/null
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs
@@ -0,0 +1,70 @@
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
+using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+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(TypeNameResolver.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<{TypeNameResolver.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/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 $$"""
- ///
- /// Returns a based of a byte array in case of success result. Returns custom mapping in case of failure.
- ///
- public static Results ToFileHttpResult(this Result result, string? contentType = null,
- string? fileDownloadName = null, DateTimeOffset? lastModified = null,
- EntityTagHeaderValue? entityTag = null,
- bool enableRangeProcessing = false)
- {
- if (result.IsSuccess) return TypedResults.File(result.Value, contentType, fileDownloadName, enableRangeProcessing, lastModified, entityTag);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a based of a byte array in case of success result. Returns custom mapping in case of failure.
- ///
- public static async Task> ToFileHttpResult(this Task> result, string? contentType = null,
- string? fileDownloadName = null, DateTimeOffset? lastModified = null,
- EntityTagHeaderValue? entityTag = null, bool enableRangeProcessing = false)
- {
- return (await result).ToFileHttpResult(contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileStreamHttpResultStreamE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileStreamHttpResultStreamE.cs
deleted file mode 100644
index fb41e69..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileStreamHttpResultStreamE.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToFileStreamHttpResultStreamE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Returns a based of a Stream in case of success result. Returns custom mapping in case of failure.
- ///
- public static Results ToFileStreamHttpResult(this Result result, string? contentType = null,
- string? fileDownloadName = null, DateTimeOffset? lastModified = null,
- EntityTagHeaderValue? entityTag = null,
- bool enableRangeProcessing = false) where T : Stream
- {
- if (result.IsSuccess) return TypedResults.Stream(result.Value, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a based of a Stream in case of success result. Returns custom mapping in case of failure.
- ///
- public static async Task> ToFileStreamHttpResult(this Task> result, string? contentType = null,
- string? fileDownloadName = null, DateTimeOffset? lastModified = null,
- EntityTagHeaderValue? entityTag = null,
- bool enableRangeProcessing = false) where T : Stream
- {
- return (await result).ToFileStreamHttpResult(contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToJsonHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToJsonHttpResultTE.cs
deleted file mode 100644
index 857c1f0..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToJsonHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToJsonHttpResultTE : 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. You can override the success status code.
- ///
- public static Results, {{httpResultType}}> ToJsonHttpResult(this Result result, int successStatusCode = 200)
- {
- if (result.IsSuccess) return TypedResults.Json(result.Value, statusCode: successStatusCode);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a in case of success result. Returns custom mapping in case of failure. You can override the success status code.
- ///
- public static async Task, {{httpResultType}}>> ToJsonHttpResult(this Task> result, int successStatusCode = 200)
- {
- return (await result).ToJsonHttpResult(successStatusCode);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToNoContentHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToNoContentHttpResultTE.cs
deleted file mode 100644
index f9944ab..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToNoContentHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToNoContentHttpResultTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Discards the value of and Returns a in case of success result. Returns custom mapping in case of failure.
- ///
- public static Results ToNoContentHttpResult(this Result result)
- {
- if (result.IsSuccess) return TypedResults.NoContent();
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Discards the value of and Returns a in case of success result. Returns custom mapping in case of failure.
- ///
- public static async Task> ToNoContentHttpResult(this Task> result)
- {
- return (await result).ToNoContentHttpResult();
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToOkHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToOkHttpResultTE.cs
deleted file mode 100644
index 8a3e723..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToOkHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToOkHttpResultTE : 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, {{httpResultType}}> ToOkHttpResult(this Result result)
- {
- if (result.IsSuccess) return TypedResults.Ok(result.Value);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a in case of success result. Returns custom mapping in case of failure.
- ///
- public static async Task, {{httpResultType}}>> ToOkHttpResult(this Task> result)
- {
- return (await result).ToOkHttpResult();
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableTE.cs
deleted file mode 100644
index f755a08..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableTE.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToServerSentEventsHttpResultIAsyncEnumerableTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- #if NET10_0_OR_GREATER
- ///
- /// Returns a based of a in case of success. Returns custom mapping in case of failure.
- ///
- public static Results, {{httpResultType}}> ToServerSentEventsHttpResult(this Result,{{resultErrorType}}> result, string? eventType = null)
- {
- if (result.IsSuccess) return TypedResults.ServerSentEvents(result.Value, eventType);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Returns a based of a in case of success. Returns custom mapping in case of failure.
- ///
- public static async Task, {{httpResultType}}>> ToServerSentEventsHttpResult(this Task,{{resultErrorType}}>> result, string? eventType = null)
- {
- return (await result).ToServerSentEventsHttpResult(eventType);
- }
- #endif
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToStatusCodeHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToStatusCodeHttpResultTE.cs
deleted file mode 100644
index ee59d4f..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToStatusCodeHttpResultTE.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions;
-
-internal class ToStatusCodeHttpResultTE : IGenerateMethods
-{
- public string Generate(string mapperClassName, string resultErrorType, string httpResultType)
- {
- return $$"""
- ///
- /// Discards the value of and Returns a in case of success result. Returns custom mapping in case of failure. You can override the success status code.
- ///
- public static Results ToStatusCodeHttpResult(this Result result, int successStatusCode = 204)
- {
- if (result.IsSuccess) return TypedResults.StatusCode(successStatusCode);
-
- return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error);
- }
-
- ///
- /// Discards the value of and Returns a in case of success result. Returns custom mapping in case of failure. You can override the success status code.
- ///
- public static async Task> ToStatusCodeHttpResult(this Task> result, int successStatusCode = 204)
- {
- return (await result).ToStatusCodeHttpResult(successStatusCode);
- }
- """;
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGenerator.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGenerator.cs
index 1e99b2d..bd29cb2 100644
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGenerator.cs
+++ b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGenerator.cs
@@ -1,126 +1,67 @@
-using System.Text;
+using System.Text;
+using CSharpFunctionalExtensions.HttpResults.Generators.Analysis;
using CSharpFunctionalExtensions.HttpResults.Generators.Builders;
-using CSharpFunctionalExtensions.HttpResults.Generators.Utils;
+using CSharpFunctionalExtensions.HttpResults.Generators.Discovery;
+using CSharpFunctionalExtensions.HttpResults.Generators.Emitters;
using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace CSharpFunctionalExtensions.HttpResults.Generators;
///
-/// A source generator that creates extension methods for mapping errors to results using classes implementing
-/// .
+/// Generates HTTP-result mapping extensions and zero-configuration DI registrations from the symbols
+/// available to a consumer compilation.
///
[Generator]
-internal class ResultExtensionsGenerator : IIncrementalGenerator
+internal sealed class ResultExtensionsGenerator : IIncrementalGenerator
{
- private const string ResultErrorMapperInterface = "IResultErrorMapper";
-
- ///
- /// Initializes the source generator.
- ///
- /// The initialization context for the generator.
public void Initialize(IncrementalGeneratorInitializationContext context)
{
- var classDeclarations = context
- .SyntaxProvider.CreateSyntaxProvider(
- static (node, _) => node is ClassDeclarationSyntax,
- static (context, _) =>
- {
- var classDeclaration = (ClassDeclarationSyntax)context.Node;
- var classSymbol = context.SemanticModel.GetDeclaredSymbol(classDeclaration) as ITypeSymbol;
- return (ClassDeclaration: classDeclaration, ClassSymbol: classSymbol);
- }
- )
- .Where(static x => x.ClassSymbol != null && ImplementsResultErrorMapper(x.ClassSymbol))
- .Select(static (x, _) => x.ClassDeclaration);
-
- var compilationAndClasses = context.CompilationProvider.Combine(classDeclarations.Collect());
-
context.RegisterSourceOutput(
- compilationAndClasses,
- static (context, source) =>
+ context.CompilationProvider,
+ static (productionContext, compilation) =>
{
- var (compilation, classDeclarations) = source;
+ var implementations = ImplementationScanner.Scan(compilation);
+ var mapperAnalysis = MapperAnalyzer.Analyze(compilation, implementations.SourceMappers);
+ var providerAnalysis = ProviderAnalyzer.Analyze(compilation, implementations.Providers);
- var mapperClasses = new List();
+ foreach (var diagnostic in mapperAnalysis.Diagnostics.Concat(providerAnalysis.Diagnostics))
+ productionContext.ReportDiagnostic(diagnostic);
- Parallel.ForEach(
- classDeclarations,
- classDeclaration =>
- {
- lock (mapperClasses)
- {
- mapperClasses.Add(classDeclaration);
- }
- }
+ AddSource(
+ productionContext,
+ LegacyMapperCacheEmitter.HintName,
+ LegacyMapperCacheEmitter.Emit(mapperAnalysis.Mappers)
+ );
+ AddSource(
+ productionContext,
+ "StringErrorResultExtensions.g.cs",
+ new StringErrorResultExtensionsClassBuilder(providerAnalysis.Provider is not null).Build()
+ );
+ AddSource(
+ productionContext,
+ "ResultExtensions.g.cs",
+ new ResultExtensionsClassBuilder(mapperAnalysis.Mappers).Build()
+ );
+ AddSource(
+ productionContext,
+ "UnitResultExtensions.g.cs",
+ new UnitResultExtensionsClassBuilder(mapperAnalysis.Mappers).Build()
+ );
+ AddSource(
+ productionContext,
+ ServiceCollectionExtensionsEmitter.HintName,
+ ServiceCollectionExtensionsEmitter.Emit(
+ compilation,
+ providerAnalysis.Provider,
+ implementations.ReferencedServiceMappers,
+ mapperAnalysis.Mappers
+ )
);
-
- if (!ResultExtensionsGeneratorValidator.CheckRules(mapperClasses, context))
- return;
-
- var (fileName, sourceText) = CreateErrorMapperInstancesClass(mapperClasses, compilation);
- context.AddSource(fileName, SourceText.From(sourceText, Encoding.UTF8));
-
- var classBuilders = new List
- {
- new ResultExtensionsClassBuilder(mapperClasses, compilation),
- new UnitResultExtensionsClassBuilder(mapperClasses, compilation),
- };
-
- foreach (var classBuilder in classBuilders)
- context.AddSource(classBuilder.SourceFileName, SourceText.From(classBuilder.Build(), Encoding.UTF8));
}
);
}
- ///
- /// Creates a class to get singleton instances of the various
- ///
- private static (string FileName, string SourceText) CreateErrorMapperInstancesClass(
- List mapperClasses,
- Compilation compilation
- )
- {
- var sourceBuilder = new StringBuilder();
-
- sourceBuilder.AppendLine("// ");
- sourceBuilder.AppendLine();
- sourceBuilder.AppendLine("#nullable enable");
- sourceBuilder.AppendLine();
- sourceBuilder.AppendLine();
-
- sourceBuilder.AppendLine("public static class ErrorMapperInstances {");
-
- foreach (var mapper in mapperClasses)
- {
- var semanticModel = compilation.GetSemanticModel(mapper.SyntaxTree);
-
- if (semanticModel.GetDeclaredSymbol(mapper) is not ITypeSymbol mapperSymbol)
- continue;
-
- var mapperType = TypeNameResolver.GetFullyQualifiedTypeName(mapperSymbol);
- sourceBuilder.AppendLine($" public static {mapperType} {mapper.Identifier.Text} {{ get; }} = new();");
- }
-
- sourceBuilder.AppendLine("}");
-
- return ("ErrorMapperInstances.g.cs", sourceBuilder.ToString());
- }
-
- ///
- /// Checks if a class implements the interface.
- ///
- /// The symbol representing the class.
- /// True if the class implements the interface; otherwise, false.
- private static bool ImplementsResultErrorMapper(ITypeSymbol? classSymbol)
- {
- if (classSymbol is null)
- return false;
-
- // Check all interfaces (direct and indirect)
- return classSymbol.AllInterfaces.Any(interfaceSymbol =>
- interfaceSymbol.Name.StartsWith(ResultErrorMapperInterface)
- );
- }
+ private static void AddSource(SourceProductionContext context, string hintName, string source) =>
+ context.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGeneratorValidator.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGeneratorValidator.cs
deleted file mode 100644
index 15429eb..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensionsGeneratorValidator.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using CSharpFunctionalExtensions.HttpResults.Generators.Rules;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-
-namespace CSharpFunctionalExtensions.HttpResults.Generators;
-
-///
-/// Validates the rules for the .
-///
-internal static class ResultExtensionsGeneratorValidator
-{
- private static readonly List Rules = [new DuplicateMapperRule(), new ParameterlessConstructorRule()];
-
- ///
- /// Validates the rules for the generator.
- ///
- /// The list of mapper classes to validate.
- /// The source production context for reporting diagnostics.
- /// True if all rules are satisfied; otherwise, false.
- public static bool CheckRules(List mapperClasses, SourceProductionContext context)
- {
- var diagnostics = new List();
-
- foreach (var rule in Rules)
- diagnostics.AddRange(rule.Check(mapperClasses));
-
- foreach (var diagnostic in diagnostics)
- context.ReportDiagnostic(diagnostic);
-
- return !diagnostics.Any();
- }
-}
diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rules/DuplicateMapperRule.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rules/DuplicateMapperRule.cs
deleted file mode 100644
index ce564a9..0000000
--- a/CSharpFunctionalExtensions.HttpResults.Generators/Rules/DuplicateMapperRule.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-
-namespace CSharpFunctionalExtensions.HttpResults.Generators.Rules;
-
-internal class DuplicateMapperRule : IRule
-{
- private const string MapMethodName = "Map";
-
- public DiagnosticDescriptor RuleDescriptor { get; } =
- new(
- "CFEHTTPR002",
- "Duplicate ResultErrorMapper",
- "Class '{0}' does have multiple IResultErrorMapper",
- "Mapping",
- DiagnosticSeverity.Error,
- true,
- customTags: ["CompilationEnd"]
- );
-
- public IEnumerable Check(List mapperClasses)
- {
- var mappedResultErrorTypes = GetMappedResultErrorTypes(mapperClasses);
- var duplicateMappedResultErrorClassNames = GetDuplicateMappedResultErrorClassNames(mappedResultErrorTypes);
-
- foreach (var duplicateClassName in duplicateMappedResultErrorClassNames)
- {
- var location = GetLocationOfDuplicate(mapperClasses, duplicateClassName);
- yield return Diagnostic.Create(RuleDescriptor, location, duplicateClassName);
- }
- }
-
- private static List GetMappedResultErrorTypes(List mapperClasses)
- {
- return mapperClasses.Select(GetMappedResultErrorType).Where(type => type != null).ToList()!;
- }
-
- private static TypeSyntax? GetMappedResultErrorType(ClassDeclarationSyntax mapperClass)
- {
- var mappingMethod = mapperClass
- .Members.OfType