diff --git a/.github/workflows/test-version.yml b/.github/workflows/test-version.yml index 3469679..79fb1b4 100644 --- a/.github/workflows/test-version.yml +++ b/.github/workflows/test-version.yml @@ -81,8 +81,10 @@ jobs: with: filename: dist/tests/${{ env.SANITIZED_LIBVERSION }}/coverage.xml badge: true + fail_below_min: true format: markdown output: file + thresholds: "95 100" if: ${{ always() }} - name: Attach test coverage report to action summary diff --git a/CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj b/CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj new file mode 100644 index 0000000..b64890b --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.CodeFixes/CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj @@ -0,0 +1,20 @@ + + + netstandard2.0 + enable + enable + latest + true + false + false + RS2007 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs b/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs new file mode 100644 index 0000000..ed3eea1 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.CodeFixes/ServiceMapperCodeFixProvider.cs @@ -0,0 +1,89 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Simplification; + +namespace CSharpFunctionalExtensions.HttpResults.Generators; + +/// +/// Migrates a result error mapper to request-service resolution by replacing its directly implemented +/// IResultErrorMapper interface with IServiceResultErrorMapper and adding the required imports. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ServiceMapperCodeFixProvider)), Shared] +public sealed class ServiceMapperCodeFixProvider : CodeFixProvider +{ + private const string ResultMapperMetadataName = "CSharpFunctionalExtensions.HttpResults.IResultErrorMapper`2"; + private const string ServiceResultMapperMetadataName = + "CSharpFunctionalExtensions.HttpResults.IServiceResultErrorMapper`2"; + + public override ImmutableArray FixableDiagnosticIds => ["CFEHTTPR004"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + var mapperDefinition = semanticModel?.Compilation.GetTypeByMetadataName(ResultMapperMetadataName); + var serviceMapperDefinition = semanticModel?.Compilation.GetTypeByMetadataName(ServiceResultMapperMetadataName); + var classDeclaration = root?.FindNode(context.Span).FirstAncestorOrSelf(); + + if ( + root is null + || semanticModel is null + || mapperDefinition is null + || serviceMapperDefinition is null + || classDeclaration?.BaseList is null + ) + return; + + var mapperBase = classDeclaration.BaseList.Types.FirstOrDefault(baseType => + semanticModel.GetTypeInfo(baseType.Type, context.CancellationToken).Type is INamedTypeSymbol type + && SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, mapperDefinition) + ); + + if (mapperBase is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + "Use IServiceResultErrorMapper (generated mappings require httpContext on failure)", + cancellationToken => + ReplaceInterfaceAsync( + context.Document, + root, + semanticModel, + serviceMapperDefinition, + mapperBase, + cancellationToken + ), + nameof(ServiceMapperCodeFixProvider) + ), + context.Diagnostics + ); + } + + private static Task ReplaceInterfaceAsync( + Document document, + SyntaxNode root, + SemanticModel semanticModel, + INamedTypeSymbol serviceMapperDefinition, + BaseTypeSyntax mapperBase, + CancellationToken cancellationToken + ) + { + // RegisterCodeFixesAsync only calls this method for a semantically resolved closed mapper interface. + var mapperType = (INamedTypeSymbol)semanticModel.GetTypeInfo(mapperBase.Type, cancellationToken).Type!; + + var serviceMapperType = serviceMapperDefinition.Construct(mapperType.TypeArguments.ToArray()); + var replacement = ((TypeSyntax)SyntaxGenerator.GetGenerator(document).TypeExpression(serviceMapperType)) + .WithAdditionalAnnotations(Simplifier.Annotation, Simplifier.AddImportsAnnotation) + .WithTriviaFrom(mapperBase.Type); + + return Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(mapperBase.Type, replacement))); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs new file mode 100644 index 0000000..b7c4d78 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundError.cs @@ -0,0 +1,3 @@ +namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection; + +public sealed record DocumentNotFoundError(string DocumentId); diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs new file mode 100644 index 0000000..d50b390 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentNotFoundErrorMapper.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection; + +/// +/// Service variant of an error mapper: resolved from request services, so constructor injection works. +/// +public sealed class DocumentNotFoundErrorMapper(DocumentationLinkProvider linkProvider) + : IServiceResultErrorMapper +{ + public ProblemHttpResult Map(DocumentNotFoundError error) + { + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Document not found", + Type = linkProvider.For("document-not-found"), + Detail = $"Document with Id {error.DocumentId} could not be found.", + }; + + return TypedResults.Problem(problemDetails); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs new file mode 100644 index 0000000..12ebefd --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentationLinkProvider.cs @@ -0,0 +1,7 @@ +namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection; + +/// Builds documentation links and is injected into the service mapper. +public sealed class DocumentationLinkProvider +{ + public string For(string topic) => $"https://docs.example.com/errors/{topic}"; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs new file mode 100644 index 0000000..7cc5f8e --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection/DocumentsEndpoint.cs @@ -0,0 +1,19 @@ +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection; + +public static class DocumentsEndpoint +{ + public static IEndpointRouteBuilder MapDocuments(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/documents/{id}", Handle).WithName(nameof(DocumentsEndpoint)); + + return endpoints; + } + + private static Results, ProblemHttpResult> Handle(string id, HttpContext httpContext) + { + return Result.Failure(new DocumentNotFoundError(id)).ToOkHttpResult(httpContext); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsFactoryProvider.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsFactoryProvider.cs new file mode 100644 index 0000000..8bb95f7 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsFactoryProvider.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider; + +/// +/// Example integration with ASP.NET Core's . The generator discovers +/// this provider and registers it as a scoped automatically. +/// +public sealed class ProblemDetailsFactoryProvider(ProblemDetailsFactory problemDetailsFactory) + : IResultProblemDetailsProvider +{ + public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) + { + var problemDetails = problemDetailsFactory.CreateProblemDetails(httpContext, statusCode, detail: error); + problemDetails.Extensions["source"] = "custom-provider"; + return problemDetails; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsProviderEndpoint.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsProviderEndpoint.cs new file mode 100644 index 0000000..1a62c90 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider/ProblemDetailsProviderEndpoint.cs @@ -0,0 +1,31 @@ +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider; + +/// +/// The generator discovers and registers the factory-backed IResultProblemDetailsProvider. Its presence +/// enables the generated context overloads for built-in string-error results. +/// +public static class ProblemDetailsProviderEndpoint +{ + public static IEndpointRouteBuilder MapProblemDetailsProvider(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/problem-details-provider", Handle).WithName(nameof(ProblemDetailsProviderEndpoint)); + + return endpoints; + } + + private static Results, ProblemHttpResult> Handle(HttpContext httpContext) + { + return Result + .Failure("Example failure") + .ToOkHttpResult( + httpContext, + failureStatusCode: 400, + customizeProblemDetails: problemDetails => + problemDetails.Extensions["source"] = "custom-problem-details-example" + ); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs b/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs index f993264..b5f5358 100644 --- a/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs +++ b/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs @@ -1,7 +1,10 @@ using CSharpFunctionalExtensions.HttpResults; +using CSharpFunctionalExtensions.HttpResults.Examples; using CSharpFunctionalExtensions.HttpResults.Examples.Features.CRUD; using CSharpFunctionalExtensions.HttpResults.Examples.Features.CustomError; +using CSharpFunctionalExtensions.HttpResults.Examples.Features.DependencyInjection; using CSharpFunctionalExtensions.HttpResults.Examples.Features.FileStream; +using CSharpFunctionalExtensions.HttpResults.Examples.Features.ProblemDetailsProvider; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -35,6 +38,11 @@ builder.Services.AddSingleton(); +// Registers ASP.NET Core's ProblemDetailsFactory used by the example provider. +builder.Services.AddControllers(); +builder.Services.AddCSharpFunctionalExtensionsHttpResults(); +builder.Services.AddSingleton(); + builder.Services.AddOpenApi(); builder.Services.AddProblemDetails(); @@ -55,5 +63,7 @@ app.MapBooksGroup(); app.MapCheckAge(); app.MapStream(); +app.MapProblemDetailsProvider(); +app.MapDocuments(); app.Run(); diff --git a/CSharpFunctionalExtensions.HttpResults.Examples/README.md b/CSharpFunctionalExtensions.HttpResults.Examples/README.md index b723307..c680f7a 100644 --- a/CSharpFunctionalExtensions.HttpResults.Examples/README.md +++ b/CSharpFunctionalExtensions.HttpResults.Examples/README.md @@ -20,6 +20,20 @@ An example for a `FileStreamResult` is available under [`Features/FileStream`](. An example for a custom error `AgeRestrictionError` that is used when the age validation detects an age below 18 is available under [`Features/CustomError`](./Features/CustomError). +### Dependency injection + +[`Features/DependencyInjection`](./Features/DependencyInjection) shows an +`IServiceResultErrorMapper<,>` with constructor injection. Its endpoint passes the current `HttpContext` to the +generated mapping overload so the mapper can be resolved from request services. + +### Problem-details provider + +[`Features/ProblemDetailsProvider`](./Features/ProblemDetailsProvider) shows how an application-owned +`IResultProblemDetailsProvider` can delegate to ASP.NET Core MVC's `ProblemDetailsFactory`. The provider is +discovered and registered as scoped by `AddCSharpFunctionalExtensionsHttpResults()`; `Program.cs` calls +`AddControllers()` to register the MVC factory. The endpoint also demonstrates the per-call callback, which runs +after the provider. + ## Run You can run this project and access the OpenApi documentation under `/scalar/v1`. diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj index 52d3877..b6b1392 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj @@ -37,5 +37,6 @@ ReferenceOutputAssembly="false" /> + diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs index 8486c56..e143a95 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/GeneratorTestHelper.cs @@ -1,22 +1,31 @@ -using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using AwesomeAssertions; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; public static class GeneratorTestHelper { + private static readonly CSharpParseOptions ParseOptions = CSharpParseOptions + .Default.WithLanguageVersion(LanguageVersion.Preview) + .WithPreprocessorSymbols("NET10_0_OR_GREATER"); + public static (IEnumerable Diagnostics, string GeneratedSource) RunGenerator( string sourceCode, IEnumerable? additionalReferences = null ) { - var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, ParseOptions); + + var references = new List(); - var references = new List - { - MetadataReference.CreateFromFile(typeof(ResultExtensionsGenerator).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IResultErrorMapper<,>).Assembly.Location), - }; + // The trusted platform assemblies include the ASP.NET Core shared framework parts + // required to resolve types like ProblemHttpResult during generation. + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split( + Path.PathSeparator + ); + references.AddRange(trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path))); if (additionalReferences != null) references.AddRange(additionalReferences); @@ -24,14 +33,17 @@ public static (IEnumerable Diagnostics, string GeneratedSource) RunG var compilation = CSharpCompilation.Create( "TestAssembly", [syntaxTree], - references.OfType(), + DistinctReferences(references), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); var generator = new ResultExtensionsGenerator(); - var driver = CSharpGeneratorDriver.Create(generator); + var driver = CSharpGeneratorDriver.Create( + [generator.AsSourceGenerator()], + parseOptions: (CSharpParseOptions)syntaxTree.Options + ); - driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generatorDiagnostics); var sourceFiles = outputCompilation .SyntaxTrees.Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase)) @@ -39,6 +51,88 @@ public static (IEnumerable Diagnostics, string GeneratedSource) RunG var generatedSource = string.Join("\n\n", sourceFiles); - return (diagnostics, generatedSource); + return (CollectDiagnostics(generatorDiagnostics, outputCompilation), generatedSource); + } + + /// + /// Runs the generator and returns the generated sources keyed by their hint name, + /// e.g. ResultExtensions.g.cs. Assertions can target individual generated files. + /// + public static (IEnumerable Diagnostics, IReadOnlyDictionary Files) RunGeneratorPerFile( + string sourceCode, + IEnumerable? additionalReferences = null + ) + { + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode, ParseOptions); + + var references = new List(); + + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split( + Path.PathSeparator + ); + references.AddRange(trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path))); + + if (additionalReferences != null) + references.AddRange(additionalReferences); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [syntaxTree], + DistinctReferences(references), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var driver = CSharpGeneratorDriver.Create( + [new ResultExtensionsGenerator().AsSourceGenerator()], + parseOptions: (CSharpParseOptions)syntaxTree.Options + ); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generatorDiagnostics); + + var files = outputCompilation + .SyntaxTrees.Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(tree => Path.GetFileName(tree.FilePath), tree => tree.GetText().ToString()); + + return (CollectDiagnostics(generatorDiagnostics, outputCompilation), files); + } + + /// Compiles the given source into an assembly reference, e.g. to simulate referenced mapper libraries. + public static MetadataReference CreateReference(string assemblyName, string source) + { + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(source)], + trustedPlatformAssemblies.Append( + MetadataReference.CreateFromFile(typeof(IResultErrorMapper<,>).Assembly.Location) + ), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + using var stream = new MemoryStream(); + compilation.Emit(stream).Success.Should().BeTrue(); + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + private static IReadOnlyList CollectDiagnostics( + ImmutableArray generatorDiagnostics, + Compilation outputCompilation + ) => + generatorDiagnostics + .Concat(outputCompilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)) + .GroupBy(diagnostic => (diagnostic.Id, diagnostic.Location.SourceSpan, diagnostic.GetMessage())) + .Select(group => group.First()) + .ToArray(); + + private static IEnumerable DistinctReferences(IEnumerable references) + { + var portableReferences = references.OfType().ToArray(); + var fileReferences = portableReferences + .Where(reference => reference.FilePath is not null) + .GroupBy(reference => reference.FilePath!, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()); + + // References created from in-memory images have no path and must not be collapsed into one group. + return fileReferences.Concat(portableReferences.Where(reference => reference.FilePath is null)); } } diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs new file mode 100644 index 0000000..a831bba --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/HttpResultMethodCatalogTests.cs @@ -0,0 +1,33 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; +using Microsoft.CodeAnalysis.CSharp; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class HttpResultMethodCatalogTests +{ + [Fact] + public void Catalog_contains_every_supported_family_once() + { + HttpResultMethodCatalog.StringErrorMethods.Should().HaveCount(14).And.OnlyHaveUniqueItems(); + HttpResultMethodCatalog.ResultWithCustomErrorMethods.Should().HaveCount(12).And.OnlyHaveUniqueItems(); + HttpResultMethodCatalog.UnitResultWithCustomErrorMethods.Should().HaveCount(2).And.OnlyHaveUniqueItems(); + } + + [Fact] + public void Catalog_type_and_expression_fragments_are_valid_CSharp() + { + var methods = HttpResultMethodCatalog + .StringErrorMethods.Concat(HttpResultMethodCatalog.UnitResultWithCustomErrorMethods) + .Distinct(); + + foreach (var method in methods) + { + SyntaxFactory.ParseTypeName(method.SuccessArm).ContainsDiagnostics.Should().BeFalse(); + SyntaxFactory.ParseExpression(method.SuccessExpression).ContainsDiagnostics.Should().BeFalse(); + + foreach (var parameter in method.Parameters) + SyntaxFactory.ParseTypeName(parameter.Type).ContainsDiagnostics.Should().BeFalse(); + } + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs new file mode 100644 index 0000000..d839e2f --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/MapperShapeGenerationTests.cs @@ -0,0 +1,205 @@ +using AwesomeAssertions; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class MapperShapeGenerationTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Explicit_interface_Map_compiles_and_is_invoked_through_the_contract(bool serviceMapper) + { + var marker = serviceMapper ? "IServiceResultErrorMapper" : "IResultErrorMapper"; + var source = $$""" + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class Error; + public sealed class Mapper : {{marker}} + { + ProblemHttpResult IResultErrorMapper.Map(Error error) => + TypedResults.Problem(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + (files["ResultExtensions.g.cs"] + files["ResultErrorMapperCache.g.cs"]) + .Should() + .Contain( + "global::CSharpFunctionalExtensions.HttpResults.IResultErrorMapper" + ); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Inherited_Map_compiles(bool serviceMapper) + { + var marker = serviceMapper ? "IServiceResultErrorMapper" : "IResultErrorMapper"; + var source = $$""" + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class Error; + public abstract class MapperBase + { + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + public sealed class Mapper : MapperBase, {{marker}}; + """; + + var (diagnostics, _) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + } + + [Fact] + public void Partial_mapper_is_analysed_once() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class Error; + public sealed partial class Mapper : IResultErrorMapper; + public sealed partial class Mapper + { + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + files["ResultErrorMapperCache.g.cs"].Split("new global::Mapper()").Should().HaveCount(2); + } + + [Theory] + [MemberData(nameof(UnsupportedMapperSources))] + public void Unsupported_mapper_shapes_are_reported(string declaration) + { + var source = $$""" + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class Error; + {{declaration}} + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR006"); + files["StringErrorResultExtensions.g.cs"].Should().Contain("ToOkHttpResult"); + files["ResultExtensions.g.cs"].Should().NotContain("global::Error"); + } + + public static TheoryData UnsupportedMapperSources => + new() + { + "public abstract class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); }", + "public sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(T error) => TypedResults.Problem(); }", + "public static class Holder { private sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); } }", + "public sealed class Mapper : IServiceResultErrorMapper { private Mapper() { } public ProblemHttpResult Map(Error error) => TypedResults.Problem(); }", + }; + + [Fact] + public void Result_error_mapper_with_required_members_is_rejected() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class Error; + public sealed class Mapper : IResultErrorMapper + { + public required string Name { get; init; } + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR004"); + files["ResultErrorMapperCache.g.cs"].Should().NotContain("new global::Mapper"); + } + + [Fact] + public void SetsRequiredMembers_constructor_is_supported() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using System.Diagnostics.CodeAnalysis; + + public sealed class Error; + public sealed class Mapper : IResultErrorMapper + { + public required string Name { get; init; } + [SetsRequiredMembers] + public Mapper() => Name = "mapper"; + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + files["ResultErrorMapperCache.g.cs"].Should().Contain("new global::Mapper()"); + } + + [Fact] + public void Same_short_mapper_names_get_unique_cache_members() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + namespace First { public sealed class Error; public sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); } } + namespace Second { public sealed class Error; public sealed class Mapper : IResultErrorMapper { public ProblemHttpResult Map(Error error) => TypedResults.Problem(); } } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + var cache = files["ResultErrorMapperCache.g.cs"]; + cache.Should().Contain("Mapper0").And.Contain("Mapper1"); + cache.Should().Contain("new global::First.Mapper()").And.Contain("new global::Second.Mapper()"); + } + + [Fact] + public void Invalid_mapper_does_not_suppress_independent_generation() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class InvalidError; + public sealed class InvalidMapper(string dependency) : IResultErrorMapper + { + public ProblemHttpResult Map(InvalidError error) => TypedResults.Problem(); + } + public sealed class ValidError; + public sealed class ValidMapper : IServiceResultErrorMapper + { + public ProblemHttpResult Map(ValidError error) => TypedResults.Problem(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR004"); + files["StringErrorResultExtensions.g.cs"].Should().Contain("ToOkHttpResult"); + files["ResultExtensions.g.cs"].Should().Contain("global::ValidError").And.NotContain("global::InvalidError"); + files["ServiceCollectionExtensions.g.cs"].Should().Contain("TryAddScoped()"); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs new file mode 100644 index 0000000..de95566 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ProviderGenerationTests.cs @@ -0,0 +1,138 @@ +using AwesomeAssertions; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class ProviderGenerationTests +{ + private const string ProviderUsings = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + """; + + [Fact] + public void Multiple_providers_report_an_error_and_disable_provider_generation() + { + var source = + ProviderUsings + + """ + + public sealed class FirstProvider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + public sealed class SecondProvider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR005"); + files["StringErrorResultExtensions.g.cs"].Should().NotContain("HttpContext httpContext"); + files["ServiceCollectionExtensions.g.cs"].Should().NotContain("FirstProvider").And.NotContain("SecondProvider"); + } + + [Fact] + public void Abstract_provider_is_reported_without_suppressing_static_mappings() + { + var source = + ProviderUsings + + """ + + public abstract class AbstractProvider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR007"); + files["StringErrorResultExtensions.g.cs"].Should().Contain("ProblemDetailsMappingProvider.FindMapping"); + } + + [Fact] + public void Partial_provider_is_discovered_once() + { + var source = + ProviderUsings + + """ + + public sealed partial class Provider : IResultProblemDetailsProvider; + public sealed partial class Provider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + files["ServiceCollectionExtensions.g.cs"].Split("global::Provider").Should().HaveCount(2); + } + + [Fact] + public void Referenced_provider_is_fully_qualified_and_auto_registered() + { + var external = + ProviderUsings + + """ + + namespace External; + public sealed class Provider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + """; + var reference = GeneratorTestHelper.CreateReference("ExternalProvider", external); + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile("public static class Empty;", [reference]); + + diagnostics.Should().BeEmpty(); + files["StringErrorResultExtensions.g.cs"].Should().Contain("HttpContext httpContext"); + files["ServiceCollectionExtensions.g.cs"] + .Should() + .Contain( + "TryAddScoped()" + ); + } + + [Fact] + public void Providers_from_multiple_referenced_assemblies_report_an_error() + { + var firstReference = GeneratorTestHelper.CreateReference( + "FirstProviderAssembly", + ProviderUsings + + """ + + namespace First; + public sealed class Provider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + """ + ); + var secondReference = GeneratorTestHelper.CreateReference( + "SecondProviderAssembly", + ProviderUsings + + """ + + namespace Second; + public sealed class Provider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext context, string error, int statusCode) => new(); + } + """ + ); + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile( + "public static class Empty;", + [firstReference, secondReference] + ); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR005"); + files["StringErrorResultExtensions.g.cs"].Should().NotContain("HttpContext httpContext"); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs new file mode 100644 index 0000000..96e6a02 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/RegistrationGenerationTests.cs @@ -0,0 +1,119 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class RegistrationGenerationTests +{ + [Fact] + public void Registration_is_emitted_even_without_any_mappers() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile("public static class Empty { }"); + var registration = files["ServiceCollectionExtensions.g.cs"]; + + registration + .Should() + .Contain( + "public static IServiceCollection AddCSharpFunctionalExtensionsHttpResults(this IServiceCollection services)" + ); + registration.Should().NotContain("TryAddScoped<"); + } + + [Fact] + public void Source_service_mapper_is_registered_but_standard_mapper_is_not() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + namespace Sample; + + public sealed class ServiceError; + public sealed class StandardDomainError; + + public sealed class ServiceMapper : IServiceResultErrorMapper + { + public ProblemHttpResult Map(ServiceError error) => TypedResults.Problem(); + } + + public sealed class StandardMapper : IResultErrorMapper + { + public ProblemHttpResult Map(StandardDomainError error) => TypedResults.Problem(); + } + """; + + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + var registration = files["ServiceCollectionExtensions.g.cs"]; + + registration.Should().Contain("services.TryAddScoped();"); + registration.Should().NotContain("StandardMapper"); + } + + [Fact] + public void Public_referenced_service_mapper_is_registered_internal_is_not() + { + const string external = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + namespace External; + + public sealed class ExternalError; + + public sealed class PublicServiceMapper : IServiceResultErrorMapper + { + public ProblemHttpResult Map(ExternalError error) => TypedResults.Problem(); + } + + internal sealed class InternalServiceMapper : IServiceResultErrorMapper> + { + public NotFound Map(ExternalError error) => TypedResults.NotFound(string.Empty); + } + """; + + var reference = GeneratorTestHelper.CreateReference("ExternalMappers", external); + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile("public static class Empty { }", [reference]); + var registration = files["ServiceCollectionExtensions.g.cs"]; + + registration.Should().Contain("services.TryAddScoped();"); + registration.Should().NotContain("InternalServiceMapper"); + } + + [Fact] + public void Discovered_provider_is_auto_registered_as_scoped() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(StringErrorMethodGenerationTests.WithProviderSource); + var registration = files["ServiceCollectionExtensions.g.cs"]; + + registration + .Should() + .Contain( + "services.TryAddScoped();" + ); + } + + [Fact] + public void Registrations_are_deterministically_ordered() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + namespace Zeta { public sealed class ZMapper : IServiceResultErrorMapper { public ProblemHttpResult Map(Zeta.Error error) => TypedResults.Problem(); } + public sealed class Error; } + namespace Alpha { public sealed class AMapper : IServiceResultErrorMapper { public ProblemHttpResult Map(Alpha.Error error) => TypedResults.Problem(); } + public sealed class Error; } + """; + + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + var registration = files["ServiceCollectionExtensions.g.cs"]; + + var alphaIndex = registration.IndexOf("global::Alpha.AMapper", StringComparison.Ordinal); + var zetaIndex = registration.IndexOf("global::Zeta.ZMapper", StringComparison.Ordinal); + + alphaIndex.Should().BePositive().And.BeLessThan(zetaIndex); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs index fa119d5..ea30211 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/Rules/DuplicateMapperRuleTests.cs @@ -39,6 +39,11 @@ public class DocumentCreationErrorMapper2 : IResultErrorMapper Map(string error) => TypedResults.Conflict(error.DocumentId); + public Conflict Map(string error) => TypedResults.Conflict(error); } // Explicit parameterless & with parameters --> No error @@ -40,7 +40,7 @@ public class DocumentCreationErrorMapper3 : IResultErrorMapper Map(int error) => TypedResults.Conflict(error.DocumentId); + public Conflict Map(int error) => TypedResults.Conflict(error.ToString()); } """; @@ -57,6 +57,8 @@ public DocumentCreationErrorMapper3(string foo) { } diagnostic .GetMessage() .Should() - .Be("Class 'DocumentCreationErrorMapper' does not have a parameterless constructor"); + .Be( + "Class 'DocumentCreationErrorMapper' does not have an accessible parameterless constructor without unsatisfied required members. Implement IServiceResultErrorMapper<,> to use dependency injection instead." + ); } } diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs new file mode 100644 index 0000000..eb4e7ba --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperCodeFixProviderTests.cs @@ -0,0 +1,228 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions.HttpResults; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class ServiceMapperCodeFixProviderTests +{ + [Fact] + public void Metadata_exposes_the_supported_diagnostic_and_batch_fixer() + { + var provider = new ServiceMapperCodeFixProvider(); + + provider.FixableDiagnosticIds.Should().Equal("CFEHTTPR004"); + provider.GetFixAllProvider().Should().BeSameAs(WellKnownFixAllProviders.BatchFixer); + } + + [Fact] + public async Task Fix_replaces_only_the_result_mapper_interface_and_preserves_mapper_source() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class MyError; + + // This mapper needs constructor injection. + public sealed class MyMapper /* keep this comment */ : IResultErrorMapper + { + public MyMapper(string dependency) { } + + // Map implementation must be untouched. + public ProblemHttpResult Map(MyError error) => TypedResults.Problem(); + } + """; + + var fixedSource = await ApplyFixAsync(source); + + fixedSource.Should().Contain("IServiceResultErrorMapper"); + fixedSource.Should().NotContain("global::"); + fixedSource.Should().Contain("/* keep this comment */"); + fixedSource.Should().Contain("public ProblemHttpResult Map(MyError error) => TypedResults.Problem();"); + } + + [Fact] + public async Task No_fix_is_offered_when_the_rule_reports_no_diagnostic() + { + const string source = """ + public sealed class MyMapper : SomeOtherInterface + { + public SomeOtherInterface Map(MyError error) => throw null!; + } + """; + + var actions = await GetActionsAsync(source); + + actions.Should().BeEmpty(); + } + + [Theory] + [InlineData("public sealed class Mapper { }")] + [InlineData("public sealed class Mapper : System.IDisposable { public void Dispose() { } }")] + public async Task No_fix_is_offered_when_a_diagnostic_does_not_point_to_a_mapper_interface(string source) + { + var actions = await GetActionsAsync(source, createSyntheticDiagnostic: true); + + actions.Should().BeEmpty(); + } + + [Fact] + public async Task Fixed_mapper_is_accepted_as_a_service_mapper_by_the_generator() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + public sealed class Error; + public sealed class Mapper : IResultErrorMapper + { + public Mapper(string dependency) { } + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + """; + + var fixedSource = await ApplyFixAsync(source); + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(fixedSource); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CFEHTTPR004"); + files.Values.Should().Contain(source => source.Contains("GetRequiredService()")); + files["ResultErrorMapperCache.g.cs"].Should().NotContain("new global::Mapper"); + } + + [Fact] + public async Task Fix_supports_a_qualified_interface_name() + { + const string source = """ + using Microsoft.AspNetCore.Http.HttpResults; + + public sealed class Error; + public sealed class Mapper : CSharpFunctionalExtensions.HttpResults.IResultErrorMapper + { + public Mapper(string dependency) { } + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + """; + + var fixedSource = await ApplyFixAsync(source); + + fixedSource + .Should() + .Contain("using CSharpFunctionalExtensions.HttpResults;") + .And.Contain("IServiceResultErrorMapper"); + fixedSource.Should().NotContain("global::"); + } + + [Fact] + public async Task Fix_supports_an_alias_qualified_closed_interface() + { + const string source = """ + using CSharpFunctionalExtensions.HttpResults; + using Microsoft.AspNetCore.Http.HttpResults; + using ResultMapper = CSharpFunctionalExtensions.HttpResults.IResultErrorMapper; + + public sealed class Error; + public sealed class Mapper : ResultMapper + { + public Mapper(string dependency) { } + public ProblemHttpResult Map(Error error) => TypedResults.Problem(); + } + """; + + var fixedSource = await ApplyFixAsync(source); + + fixedSource.Should().NotContain(": ResultMapper"); + fixedSource.Should().Contain("IServiceResultErrorMapper"); + fixedSource.Should().NotContain("global::"); + } + + private static async Task ApplyFixAsync(string source) + { + var actions = await GetActionsAsync(source); + actions.Should().ContainSingle(); + actions[0].Title.Should().Be("Use IServiceResultErrorMapper (generated mappings require httpContext on failure)"); + + var operations = await actions[0].GetOperationsAsync(TestContext.Current.CancellationToken); + var changedSolution = operations.OfType().Should().ContainSingle().Which.ChangedSolution; + var changedDocument = changedSolution.Projects.Single().Documents.Single(); + return (await changedDocument.GetTextAsync(TestContext.Current.CancellationToken)).ToString(); + } + + /// + /// Produces the code-fix actions exactly as the IDE would: diagnostics come from the real generator, + /// so their spans match production. + /// + private static async Task> GetActionsAsync(string source, bool createSyntheticDiagnostic = false) + { + using var workspace = new AdhocWorkspace(); + var project = workspace + .AddProject("TestProject", LanguageNames.CSharp) + .WithParseOptions(new CSharpParseOptions(LanguageVersion.Preview)) + .WithMetadataReferences(CreateReferences()); + workspace.TryApplyChanges(project.Solution).Should().BeTrue(); + project = workspace.CurrentSolution.GetProject(project.Id)!; + var document = workspace.AddDocument(project.Id, "Mapper.cs", SourceText.From(source)); + var compilation = (await document.Project.GetCompilationAsync(TestContext.Current.CancellationToken))!; + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + var driver = CSharpGeneratorDriver.Create( + [new ResultExtensionsGenerator().AsSourceGenerator()], + parseOptions: parseOptions + ); + driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var generatorDiagnostics); + var diagnostics = createSyntheticDiagnostic + ? [CreateSyntheticDiagnostic(compilation.SyntaxTrees.Single())] + : generatorDiagnostics.Where(diagnostic => diagnostic.Id == "CFEHTTPR004").ToArray(); + + var actions = new List(); + foreach (var diagnostic in diagnostics) + { + var context = new CodeFixContext( + document, + diagnostic, + (action, _) => actions.Add(action), + TestContext.Current.CancellationToken + ); + + await new ServiceMapperCodeFixProvider().RegisterCodeFixesAsync(context); + } + + return actions; + } + + private static Diagnostic CreateSyntheticDiagnostic(SyntaxTree syntaxTree) + { + var classDeclaration = syntaxTree.GetRoot().DescendantNodes().OfType().Single(); + var descriptor = new DiagnosticDescriptor( + "CFEHTTPR004", + "Test diagnostic", + "Test diagnostic", + "Test", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + return Diagnostic.Create(descriptor, classDeclaration.Identifier.GetLocation()); + } + + private static IReadOnlyList CreateReferences() + { + var references = new List + { + MetadataReference.CreateFromFile(typeof(IResultErrorMapper<,>).Assembly.Location), + }; + + var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split( + Path.PathSeparator + ); + references.AddRange(trustedPlatformAssemblies.Select(path => MetadataReference.CreateFromFile(path))); + + return references; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs new file mode 100644 index 0000000..3d4b05c --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/ServiceMapperGeneratorTests.cs @@ -0,0 +1,252 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class ServiceMapperGeneratorTests +{ + private const string ServiceMapperSource = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + namespace Sample; + + public sealed class SampleError; + + public sealed class Dependency; + + public sealed class ServiceMapper(Dependency dependency) : IServiceResultErrorMapper + { + public ProblemHttpResult Map(SampleError error) => TypedResults.Problem(detail: dependency.ToString()); + } + """; + + [Fact] + public void ServiceMapper_generates_required_context_overloads_for_every_mapping_family() + { + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(ServiceMapperSource); + + diagnostics.Should().BeEmpty(); + var mapperMethods = files["ResultExtensions.g.cs"] + files["UnitResultExtensions.g.cs"]; + + mapperMethods.Should().NotContain("ResultErrorMapperCache.Mapper"); + mapperMethods.Should().Contain("GetRequiredService()"); + mapperMethods + .Should() + .Contain( + "global::CSharpFunctionalExtensions.HttpResults.IResultErrorMapper" + ); + mapperMethods.Should().Contain("A HttpContext is required for IServiceResultErrorMapper mappings."); + + // 12 Result families and 2 UnitResult families, each sync and async: + // 28 overloads carrying a REQUIRED HttpContext parameter (no default value). + mapperMethods.Split("HttpContext httpContext").Should().HaveCount(29); + mapperMethods.Should().NotContain("HttpContext? httpContext"); + mapperMethods.Should().NotContain("httpContext = null"); + + // Every async overload forwards to its sync counterpart. + mapperMethods.Split("await result").Should().HaveCount(15); + + // The mapper returns ProblemHttpResult: every sync failure branch applies the + // callback to the mapped result (14 families). + (files["ResultExtensions.g.cs"].Split("customizeProblemDetails?.Invoke(mapped.ProblemDetails);").Length - 1) + .Should() + .Be(12); + (files["UnitResultExtensions.g.cs"].Split("customizeProblemDetails?.Invoke(mapped.ProblemDetails);").Length - 1) + .Should() + .Be(2); + } + + [Fact] + public void ServiceMapper_overloads_place_httpContext_between_required_and_optional_parameters() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(ServiceMapperSource); + var mapperMethods = files["ResultExtensions.g.cs"] + files["UnitResultExtensions.g.cs"]; + + // Accepted requires uri -> context comes after it. + mapperMethods + .Should() + .Contain( + "ToAcceptedHttpResult(this Result result, Func uri, HttpContext httpContext, Action? customizeProblemDetails = null)" + ); + + // StatusCode only has optional parameters -> context comes first. + mapperMethods + .Should() + .Contain( + "ToStatusCodeHttpResult(this Result result, HttpContext httpContext, int successStatusCode = 204, Action? customizeProblemDetails = null)" + ); + + mapperMethods + .Should() + .Contain( + "ToNoContentHttpResult(this UnitResult result, HttpContext httpContext, Action? customizeProblemDetails = null)" + ); + + // File/FileStream forward context between the family parameters and the callback, + // mirroring the sync parameter order. + mapperMethods + .Should() + .Contain( + "ToFileHttpResult(await result, httpContext, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing, customizeProblemDetails)" + ); + mapperMethods + .Should() + .Contain( + "ToFileStreamHttpResult(await result, httpContext, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing, customizeProblemDetails)" + ); + } + + [Fact] + public void ServiceMapper_with_constructor_dependency_does_not_report_standard_constructor_diagnostic() + { + var (diagnostics, _) = GeneratorTestHelper.RunGeneratorPerFile(ServiceMapperSource); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CFEHTTPR004"); + } + + [Fact] + public void ServiceMapper_without_ProblemHttpResult_return_type_gets_no_customize_parameter() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + namespace Sample; + + public sealed class OtherError; + + public sealed class Service; + + public sealed class OtherMapper(Service service) : IServiceResultErrorMapper> + { + public NotFound Map(OtherError error) => TypedResults.NotFound(error.ToString()); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + var mapperMethods = files["ResultExtensions.g.cs"]; + + diagnostics.Should().BeEmpty(); + mapperMethods.Should().NotContain("customizeProblemDetails"); + mapperMethods.Should().Contain("(this Result result, HttpContext httpContext)"); + mapperMethods + .Should() + .Contain("httpContext.RequestServices.GetRequiredService()).Map(result.Error);"); + } + + [Fact] + public void StandardMapper_retains_static_instance_and_context_free_overloads() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + public sealed class StandardDomainError; + public sealed class StandardMapper : IResultErrorMapper + { + public ProblemHttpResult Map(StandardDomainError error) => TypedResults.Problem(); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + files["ResultErrorMapperCache.g.cs"] + .Should() + .Contain( + "internal static global::CSharpFunctionalExtensions.HttpResults.IResultErrorMapper Mapper0 { get; } = new global::StandardMapper();" + ); + + var mapperMethods = files["ResultExtensions.g.cs"]; + mapperMethods.Should().NotContain("GetRequiredService<"); + mapperMethods.Should().NotContain("HttpContext"); + mapperMethods + .Should() + .Contain("var mapped = CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache.Mapper0.Map(result.Error);"); + + // Standard mappers returning ProblemHttpResult get the optional customize parameter. + mapperMethods.Should().Contain("Action? customizeProblemDetails = null"); + mapperMethods.Should().Contain("customizeProblemDetails?.Invoke(mapped.ProblemDetails);"); + } + + [Fact] + public void StandardMapper_without_ProblemHttpResult_return_type_stays_parameterless() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + public sealed class StandardDomainError; + public sealed class StandardNotFoundMapper : IResultErrorMapper> + { + public NotFound Map(StandardDomainError error) => TypedResults.NotFound(string.Empty); + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().BeEmpty(); + files["UnitResultExtensions.g.cs"].Should().Contain("(this UnitResult result)"); + files["UnitResultExtensions.g.cs"] + .Should() + .Contain("return CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache.Mapper0.Map(result.Error);"); + } + + [Fact] + public void Duplicate_error_type_is_reported_for_mixed_standard_and_service_mappers() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + public sealed class SharedError; + public sealed class StandardMapper : IResultErrorMapper + { + public ProblemHttpResult Map(SharedError error) => TypedResults.Problem(); + } + public sealed class ServiceMapper : IServiceResultErrorMapper + { + public ProblemHttpResult Map(SharedError error) => TypedResults.Problem(); + } + """; + + var (diagnostics, _) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().ContainSingle(diagnostic => diagnostic.Id == "CFEHTTPR002"); + } + + [Fact] + public void Error_types_with_the_same_short_name_in_different_namespaces_are_not_duplicates() + { + const string source = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using CSharpFunctionalExtensions.HttpResults; + + namespace First { public sealed class Error; } + namespace Second { public sealed class Error; } + namespace Mappers + { + public sealed class FirstMapper : IResultErrorMapper + { + public ProblemHttpResult Map(First.Error error) => TypedResults.Problem(); + } + public sealed class SecondMapper : IServiceResultErrorMapper + { + public ProblemHttpResult Map(Second.Error error) => TypedResults.Problem(); + } + } + """; + + var (diagnostics, files) = GeneratorTestHelper.RunGeneratorPerFile(source); + + diagnostics.Should().NotContain(diagnostic => diagnostic.Id == "CFEHTTPR002"); + files.Should().ContainKey("ResultExtensions.g.cs"); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs new file mode 100644 index 0000000..b83e6a4 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators.Tests/StringErrorMethodGenerationTests.cs @@ -0,0 +1,135 @@ +using AwesomeAssertions; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Tests; + +public class StringErrorMethodGenerationTests +{ + private const string NoProviderSource = "public static class Empty { }"; + + internal const string WithProviderSource = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using CSharpFunctionalExtensions.HttpResults; + + public sealed class MyProvider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) => + new ProblemDetails { Status = statusCode, Detail = error }; + } + """; + + [Fact] + public void Static_variants_are_always_emitted_into_the_well_known_namespace() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource); + var builtin = files["StringErrorResultExtensions.g.cs"]; + + builtin.Should().Contain("namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions;"); + builtin.Should().Contain("public static partial class ResultExtensions {"); + + builtin + .Should() + .Contain( + "ToStatusCodeHttpResult(this Result result, int successStatusCode = 204, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + builtin + .Should() + .Contain( + "ToOkHttpResult(this Result result, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + builtin + .Should() + .Contain( + "ToAcceptedHttpResult(this Result result, Func uri, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + builtin + .Should() + .Contain( + "ToContentHttpResult(this Result result, string? contentType = null, Encoding? contentEncoding = null, int? statusCode = null, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + + builtin.Should().Contain("ProblemDetailsMappingProvider.FindMapping(failureStatusCode)"); + } + + [Fact] + public void Context_overloads_are_emitted_when_a_provider_exists_in_the_compilation() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(WithProviderSource); + var builtin = files["StringErrorResultExtensions.g.cs"]; + + builtin.Should().Contain("using Microsoft.Extensions.DependencyInjection;"); + builtin + .Should() + .Contain( + "ToOkHttpResult(this Result result, HttpContext httpContext, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + builtin + .Should() + .Contain( + "ToAcceptedHttpResult(this Result result, Func uri, HttpContext httpContext, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + builtin + .Should() + .Contain( + "ToStatusCodeHttpResult(this Result result, HttpContext httpContext, int successStatusCode = 204, int failureStatusCode = 400, Action? customizeProblemDetails = null)" + ); + + builtin.Should().Contain("httpContext.RequestServices.GetRequiredService()"); + builtin.Should().Contain("CreateProblemDetails(httpContext, result.Error, failureStatusCode)"); + + builtin.Split("customizeProblemDetails?.Invoke(problemDetails);").Should().HaveCount(29); + + builtin + .Should() + .Contain("ToOkHttpResult(await result, httpContext, failureStatusCode, customizeProblemDetails)"); + } + + [Fact] + public void Context_overloads_are_not_emitted_without_a_provider() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource); + var builtin = files["StringErrorResultExtensions.g.cs"]; + + builtin.Should().NotContain("HttpContext httpContext"); + builtin.Should().NotContain("GetRequiredService"); + builtin.Should().NotContain("Microsoft.Extensions.DependencyInjection"); + } + + [Fact] + public void Public_provider_from_referenced_assembly_enables_emission_internal_does_not() + { + const string externalPublic = """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using CSharpFunctionalExtensions.HttpResults; + + namespace External; + + public sealed class PublicProvider : IResultProblemDetailsProvider + { + public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) => new(); + } + """; + + string externalInternal = externalPublic.Replace("public sealed class", "internal sealed class"); + + var publicReference = GeneratorTestHelper.CreateReference("ExternalProvidersPublic", externalPublic); + var (_, filesWithPublic) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource, [publicReference]); + filesWithPublic["StringErrorResultExtensions.g.cs"].Should().Contain("HttpContext httpContext"); + + var internalReference = GeneratorTestHelper.CreateReference("ExternalProvidersInternal", externalInternal); + var (_, filesWithInternal) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource, [internalReference]); + filesWithInternal["StringErrorResultExtensions.g.cs"].Should().NotContain("HttpContext httpContext"); + } + + [Fact] + public void ServerSentEvents_family_is_wrapped_in_net10_conditional() + { + var (_, files) = GeneratorTestHelper.RunGeneratorPerFile(NoProviderSource); + var builtin = files["StringErrorResultExtensions.g.cs"]; + + builtin.Should().Contain("#if NET10_0_OR_GREATER"); + builtin.Should().Contain("ServerSentEventsResult"); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs new file mode 100644 index 0000000..95bd8a1 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/GeneratorDiagnostics.cs @@ -0,0 +1,52 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +/// All diagnostics emitted while analysing mapping implementations. +internal static class GeneratorDiagnostics +{ + public static readonly DiagnosticDescriptor DuplicateMapper = new( + "CFEHTTPR002", + "Duplicate ResultErrorMapper", + "Error type '{0}' has multiple IResultErrorMapper or IServiceResultErrorMapper implementations", + "Mapping", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor MissingParameterlessConstructor = new( + "CFEHTTPR004", + "Missing parameterless constructor in IResultErrorMapper", + "Class '{0}' does not have an accessible parameterless constructor without unsatisfied required members. Implement IServiceResultErrorMapper<,> to use dependency injection instead.", + "Mapping", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor DuplicateProvider = new( + "CFEHTTPR005", + "Multiple result problem-details providers", + "Multiple IResultProblemDetailsProvider implementations were found: {0}", + "Mapping", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor UnsupportedMapper = new( + "CFEHTTPR006", + "Unsupported result error mapper", + "Mapper class '{0}' is unsupported: {1}", + "Mapping", + DiagnosticSeverity.Error, + true + ); + + public static readonly DiagnosticDescriptor UnsupportedProvider = new( + "CFEHTTPR007", + "Unsupported result problem-details provider", + "Provider class '{0}' is unsupported: {1}", + "Mapping", + DiagnosticSeverity.Error, + true + ); +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs new file mode 100644 index 0000000..2d7217d --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalysis.cs @@ -0,0 +1,7 @@ +using CSharpFunctionalExtensions.HttpResults.Generators.Models; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +/// Valid source mappers and diagnostics produced while analysing mapper symbols. +internal sealed record MapperAnalysis(IReadOnlyList Mappers, IReadOnlyList Diagnostics); diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs new file mode 100644 index 0000000..fe9fb78 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperAnalyzer.cs @@ -0,0 +1,141 @@ +using CSharpFunctionalExtensions.HttpResults.Generators.Models; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +/// Validates source mapper symbols and creates generation metadata for valid entries only. +internal static class MapperAnalyzer +{ + private const string ProblemHttpResultMetadataName = "Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult"; + + public static MapperAnalysis Analyze(Compilation compilation, IReadOnlyList candidates) + { + var diagnostics = new List(); + var validCandidates = new List<(INamedTypeSymbol Symbol, INamedTypeSymbol MapperInterface)>(); + + foreach ( + var candidate in candidates.OrderBy(SymbolTypeNameFormatter.GetFullyQualifiedTypeName, StringComparer.Ordinal) + ) + { + var mapperInterfaces = MapperSymbolResolver.GetClosedErrorMapperInterfaces(candidate).ToArray(); + if (mapperInterfaces.Length != 1) + { + diagnostics.Add(CreateUnsupported(candidate, "exactly one closed IResultErrorMapper<,> is required")); + continue; + } + + if (candidate.TypeKind != TypeKind.Class || candidate.IsAbstract || candidate.IsStatic) + { + diagnostics.Add(CreateUnsupported(candidate, "the type must be a concrete, non-static class")); + continue; + } + + if (!SymbolAccessibility.IsReferenceable(candidate, compilation.Assembly)) + { + diagnostics.Add(CreateUnsupported(candidate, "the type must be closed and accessible to generated code")); + continue; + } + + if (MapperSymbolResolver.IsServiceMapper(candidate)) + { + if (!SymbolAccessibility.HasPublicInstanceConstructor(candidate)) + { + diagnostics.Add(CreateUnsupported(candidate, "a service mapper must have a public constructor for DI")); + continue; + } + } + else + { + var constructor = candidate.InstanceConstructors.FirstOrDefault(constructor => + constructor.Parameters.Length == 0 + && constructor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal + ); + if ( + constructor is null + || !SymbolAccessibility.HasAccessibleParameterlessConstructor(candidate, compilation.Assembly) + || SymbolAccessibility.HasUnsatisfiedRequiredMembers(candidate, constructor) + ) + { + diagnostics.Add( + Diagnostic.Create( + GeneratorDiagnostics.MissingParameterlessConstructor, + GetLocation(candidate), + candidate.Name + ) + ); + continue; + } + } + + validCandidates.Add((candidate, mapperInterfaces[0])); + } + + var duplicateSymbols = new HashSet(SymbolEqualityComparer.Default); + foreach ( + var group in validCandidates.GroupBy( + entry => entry.MapperInterface.TypeArguments[0], + SymbolEqualityComparer.Default + ) + ) + { + if (group.Count() <= 1) + continue; + + var entries = group.ToArray(); + foreach (var entry in entries) + duplicateSymbols.Add(entry.Symbol); + + diagnostics.Add( + Diagnostic.Create( + GeneratorDiagnostics.DuplicateMapper, + GetLocation(entries[1].Symbol), + entries[0].MapperInterface.TypeArguments[0].ToDisplayString() + ) + ); + } + + var problemHttpResult = compilation.GetTypeByMetadataName(ProblemHttpResultMetadataName); + var mapperDescriptors = validCandidates + .Where(entry => !duplicateSymbols.Contains(entry.Symbol)) + .Select(entry => new MapperDescriptor + { + Symbol = entry.Symbol, + CacheMemberName = "", + FullyQualifiedName = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.Symbol), + ErrorType = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.MapperInterface.TypeArguments[0]), + HttpResultType = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.MapperInterface.TypeArguments[1]), + MapperInterfaceType = SymbolTypeNameFormatter.GetFullyQualifiedTypeName(entry.MapperInterface), + IsServiceMapper = MapperSymbolResolver.IsServiceMapper(entry.Symbol), + IsProblemHttpResult = + problemHttpResult is not null + && SymbolEqualityComparer.Default.Equals( + entry.MapperInterface.TypeArguments[1].OriginalDefinition, + problemHttpResult + ), + }) + .OrderBy(info => info.FullyQualifiedName, StringComparer.Ordinal) + .Select( + (info, index) => + new MapperDescriptor + { + Symbol = info.Symbol, + CacheMemberName = $"Mapper{index}", + FullyQualifiedName = info.FullyQualifiedName, + ErrorType = info.ErrorType, + HttpResultType = info.HttpResultType, + MapperInterfaceType = info.MapperInterfaceType, + IsServiceMapper = info.IsServiceMapper, + IsProblemHttpResult = info.IsProblemHttpResult, + } + ) + .ToArray(); + + return new MapperAnalysis(mapperDescriptors, diagnostics); + } + + private static Diagnostic CreateUnsupported(INamedTypeSymbol symbol, string reason) => + Diagnostic.Create(GeneratorDiagnostics.UnsupportedMapper, GetLocation(symbol), symbol.Name, reason); + + private static Location GetLocation(INamedTypeSymbol symbol) => + symbol.Locations.FirstOrDefault(location => location.IsInSource) ?? Location.None; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs new file mode 100644 index 0000000..05532a0 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/MapperSymbolResolver.cs @@ -0,0 +1,33 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +internal static class MapperSymbolResolver +{ + public const string ResultErrorMapperInterfaceMetadataName = "IResultErrorMapper`2"; + public const string ServiceResultErrorMapperInterfaceMetadataName = "IServiceResultErrorMapper`2"; + public const string LibraryNamespace = "CSharpFunctionalExtensions.HttpResults"; + + /// + /// Checks whether the mapper implements the library's + /// . + /// Must be checked before the standard interface because it inherits it. + /// + public static bool IsServiceMapper(INamedTypeSymbol? mapperSymbol) => + mapperSymbol?.AllInterfaces.Any(interfaceSymbol => + interfaceSymbol.OriginalDefinition.MetadataName == ServiceResultErrorMapperInterfaceMetadataName + && interfaceSymbol.ContainingNamespace.ToDisplayString() == LibraryNamespace + ) == true; + + /// + /// Returns the closed IResultErrorMapper<,> interfaces implemented by the mapper, resolved + /// from its type symbol. Service mappers qualify through inheritance, so explicit or inherited + /// Map implementations are covered without any syntactic lookup. + /// + public static IEnumerable GetClosedErrorMapperInterfaces(INamedTypeSymbol? mapperSymbol) => + mapperSymbol?.AllInterfaces.Where(interfaceSymbol => + interfaceSymbol.OriginalDefinition.MetadataName == ResultErrorMapperInterfaceMetadataName + && interfaceSymbol.ContainingNamespace.ToDisplayString() == LibraryNamespace + ) + ?? []; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs new file mode 100644 index 0000000..4ef7782 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalysis.cs @@ -0,0 +1,6 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +/// The selected provider, if unambiguous, and provider diagnostics. +internal sealed record ProviderAnalysis(INamedTypeSymbol? Provider, IReadOnlyList Diagnostics); diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs new file mode 100644 index 0000000..d307758 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/ProviderAnalyzer.cs @@ -0,0 +1,53 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +/// Validates discovered provider implementations and selects the sole valid provider. +internal static class ProviderAnalyzer +{ + public static ProviderAnalysis Analyze(Compilation compilation, IReadOnlyList candidates) + { + var diagnostics = new List(); + var valid = new List(); + + foreach ( + var candidate in candidates.OrderBy(SymbolTypeNameFormatter.GetFullyQualifiedTypeName, StringComparer.Ordinal) + ) + { + string? reason = null; + if (candidate.TypeKind != TypeKind.Class || candidate.IsAbstract || candidate.IsStatic) + reason = "the type must be a concrete, non-static class"; + else if (!SymbolAccessibility.IsReferenceable(candidate, compilation.Assembly)) + reason = "the type must be closed and accessible to generated code"; + else if (!SymbolAccessibility.HasPublicInstanceConstructor(candidate)) + reason = "a public constructor is required for DI"; + + if (reason is not null) + { + diagnostics.Add( + Diagnostic.Create(GeneratorDiagnostics.UnsupportedProvider, GetLocation(candidate), candidate.Name, reason) + ); + continue; + } + + valid.Add(candidate); + } + + if (valid.Count > 1) + { + diagnostics.Add( + Diagnostic.Create( + GeneratorDiagnostics.DuplicateProvider, + GetLocation(valid[1]), + string.Join(", ", valid.Select(SymbolTypeNameFormatter.GetFullyQualifiedTypeName)) + ) + ); + return new ProviderAnalysis(null, diagnostics); + } + + return new ProviderAnalysis(valid.SingleOrDefault(), diagnostics); + } + + private static Location GetLocation(INamedTypeSymbol symbol) => + symbol.Locations.FirstOrDefault(location => location.IsInSource) ?? Location.None; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs new file mode 100644 index 0000000..d47c8c3 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolAccessibility.cs @@ -0,0 +1,75 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; + +/// Accessibility and constructibility checks shared by mapper and provider analysis. +internal static class SymbolAccessibility +{ + public static bool IsReferenceable(INamedTypeSymbol type, IAssemblySymbol generatedAssembly) + { + for (var current = type; current is not null; current = current.ContainingType) + { + if (current.Arity != 0) + return false; + + var sameAssembly = SymbolEqualityComparer.Default.Equals(current.ContainingAssembly, generatedAssembly); + var validAccessibility = sameAssembly + ? current.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal + : current.DeclaredAccessibility == Accessibility.Public; + + if (!validAccessibility) + return false; + } + + return true; + } + + public static bool HasPublicInstanceConstructor(INamedTypeSymbol type) => + type.InstanceConstructors.Any(constructor => constructor.DeclaredAccessibility == Accessibility.Public); + + public static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol type, IAssemblySymbol generatedAssembly) => + type.InstanceConstructors.Any(constructor => + constructor.Parameters.Length == 0 && IsAccessible(constructor.DeclaredAccessibility, type, generatedAssembly) + ); + + public static bool HasUnsatisfiedRequiredMembers(INamedTypeSymbol type, IMethodSymbol constructor) + { + if ( + constructor + .GetAttributes() + .Any(attribute => + attribute.AttributeClass?.ToDisplayString() == "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute" + ) + ) + return false; + + for (var current = type; current is not null; current = current.BaseType) + { + if ( + current + .GetMembers() + .Any(member => + member + is IPropertySymbol { IsStatic: false, IsRequired: true } + or IFieldSymbol { IsStatic: false, IsRequired: true } + ) + ) + return true; + } + + return false; + } + + private static bool IsAccessible( + Accessibility accessibility, + INamedTypeSymbol type, + IAssemblySymbol generatedAssembly + ) + { + if (accessibility == Accessibility.Public) + return true; + + return accessibility == Accessibility.Internal + && SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, generatedAssembly); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Utils/TypeNameResolver.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolTypeNameFormatter.cs similarity index 80% rename from CSharpFunctionalExtensions.HttpResults.Generators/Utils/TypeNameResolver.cs rename to CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolTypeNameFormatter.cs index 28c6e80..634d091 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators/Utils/TypeNameResolver.cs +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Analysis/SymbolTypeNameFormatter.cs @@ -1,8 +1,8 @@ using Microsoft.CodeAnalysis; -namespace CSharpFunctionalExtensions.HttpResults.Generators.Utils; +namespace CSharpFunctionalExtensions.HttpResults.Generators.Analysis; -internal static class TypeNameResolver +internal static class SymbolTypeNameFormatter { private static readonly SymbolDisplayFormat FullyQualifiedWithNullables = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md index 068e2b2..2fc3350 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md +++ b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md @@ -52,3 +52,20 @@ | Rule ID | Category | Severity | Notes | |-------------|----------|----------|---------------------------------------------------------| | CFEHTTPR004 | Mapping | Error | Missing parameterless constructor in IResultErrorMapper | + +## Release v2.0.0 + +### Changed Rules + +| Rule ID | Category | Severity | Notes | +|-------------|----------|----------|---------------------------------------------------------------------------------------| +| CFEHTTPR002 | Mapping | Error | Semantic duplicate groups are excluded without suppressing unrelated generated output | +| CFEHTTPR004 | Mapping | Error | Validates accessible construction and required members for result error mappers | + +### New Rules + +| Rule ID | Category | Severity | Notes | +|-------------|----------|----------|--------------------------------------------------------------| +| CFEHTTPR005 | Mapping | Error | Multiple valid IResultProblemDetailsProvider implementations | +| CFEHTTPR006 | Mapping | Error | Unsupported mapper type shape | +| CFEHTTPR007 | Mapping | Error | Unsupported provider type shape | diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md index e69de29..efa39b6 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md +++ b/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md @@ -0,0 +1 @@ +## Unshipped diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs deleted file mode 100644 index b1468e6..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ClassBuilder.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Text; -using CSharpFunctionalExtensions.HttpResults.Generators.Utils; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders; - -public abstract class ClassBuilder -{ - private const string MapMethodName = "Map"; - private readonly Compilation? _compilation; - private readonly List _mapperClasses; - - protected ClassBuilder(List mapperClasses, Compilation? compilation = null) - { - _mapperClasses = mapperClasses; - _compilation = compilation; - } - - private static string DefaultUsings => - """ - using CSharpFunctionalExtensions; - using Microsoft.AspNetCore.Http.HttpResults; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - using Microsoft.Net.Http.Headers; - using System.Text; - using IResult = Microsoft.AspNetCore.Http.IResult; - """; - - public string SourceFileName => $"{ClassName}.g.cs"; - - protected abstract string ClassName { get; } - protected abstract string ClassSummary { get; } - internal abstract List MethodGenerators { get; } - - public string Build() - { - var sourceBuilder = new StringBuilder(); - - sourceBuilder.AppendLine("// "); - sourceBuilder.AppendLine(); - sourceBuilder.AppendLine("#nullable enable"); - sourceBuilder.AppendLine(); - sourceBuilder.AppendLine(DefaultUsings); - - sourceBuilder.AppendLine(); - sourceBuilder.AppendLine(ClassSummary); - - sourceBuilder.AppendLine($"public static partial class {ClassName} {{"); - sourceBuilder.AppendLine(); - - foreach (var mapperClass in _mapperClasses) - { - var mapperClassName = mapperClass.Identifier.Text; - var mappingMethod = mapperClass - .Members.OfType() - .FirstOrDefault(method => method.Identifier.Text == MapMethodName); - - if (mappingMethod == null) - throw new ArgumentException($"Mapping method in class {mapperClassName} not found."); - - if (mappingMethod.ParameterList.Parameters.Count != 1) - throw new ArgumentException($"Mapping method in class {mapperClassName} must have exactly one parameter."); - - var resultErrorType = GetFullyQualifiedTypeName(mapperClass, mappingMethod.ParameterList.Parameters[0].Type!); - var httpResultType = mappingMethod.ReturnType!.ToString(); - - foreach (var methodGenerator in MethodGenerators) - { - sourceBuilder.AppendLine(methodGenerator.Generate(mapperClassName, resultErrorType, httpResultType)); - sourceBuilder.AppendLine(); - } - } - - sourceBuilder.AppendLine(); - sourceBuilder.AppendLine("}"); - - return sourceBuilder.ToString(); - } - - private string GetFullyQualifiedTypeName(ClassDeclarationSyntax mapperClass, TypeSyntax typeSyntax) - { - if (_compilation == null) - return typeSyntax.ToString(); - - var semanticModel = _compilation.GetSemanticModel(mapperClass.SyntaxTree); - var typeInfo = semanticModel.GetTypeInfo(typeSyntax); - - if (typeInfo.Type == null) - return typeSyntax.ToString(); - - return TypeNameResolver.GetFullyQualifiedTypeName(typeInfo.Type); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ExtensionClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ExtensionClassBuilder.cs new file mode 100644 index 0000000..578031c --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ExtensionClassBuilder.cs @@ -0,0 +1,49 @@ +using System.Text; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; +using CSharpFunctionalExtensions.HttpResults.Generators.Rendering; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders; + +/// +/// Base builder for the generated extension-method classes that are emitted per discovered error mapper. +/// +internal abstract class ExtensionClassBuilder(IReadOnlyList mapperDescriptors) +{ + public string SourceFileName => $"{ClassName}.g.cs"; + + protected abstract string ClassName { get; } + protected abstract string ClassSummary { get; } + internal abstract IReadOnlyList Methods { get; } + + public string Build() + { + var sourceBuilder = new StringBuilder(); + + sourceBuilder.AppendLine("// "); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("#nullable enable"); + sourceBuilder.AppendLine(); + GeneratedSourceUsings.AppendTo(sourceBuilder, mapperDescriptors.Any(info => info.IsServiceMapper)); + + sourceBuilder.AppendLine(ClassSummary); + + sourceBuilder.AppendLine($"public static partial class {ClassName} {{"); + sourceBuilder.AppendLine(); + + foreach (var mapperDescriptor in mapperDescriptors) + { + var context = mapperDescriptor.ToContext(); + + foreach (var method in Methods) + { + sourceBuilder.AppendLine(CustomErrorMethodRenderer.Generate(context, method)); + sourceBuilder.AppendLine(); + } + } + + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("}"); + + return sourceBuilder.ToString(); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs index 86ae6a7..12ae956 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/ResultExtensionsClassBuilder.cs @@ -1,11 +1,9 @@ -using CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders; -public class ResultExtensionsClassBuilder(List mapperClasses, Compilation? compilation = null) - : ClassBuilder(mapperClasses, compilation) +internal sealed class ResultExtensionsClassBuilder(IReadOnlyList mapperDescriptors) + : ExtensionClassBuilder(mapperDescriptors) { protected override string ClassName => "ResultExtensions"; @@ -16,19 +14,6 @@ public class ResultExtensionsClassBuilder(List mapperCla /// """; - internal override List MethodGenerators => - [ - new ToAcceptedAtRouteHttpResultTE(), - new ToAcceptedHttpResultTE(), - new ToCreatedAtRouteHttpResultTE(), - new ToCreatedHttpResultTE(), - new ToFileHttpResultByteArrayE(), - new ToFileStreamHttpResultStreamE(), - new ToJsonHttpResultTE(), - new ToNoContentHttpResultTE(), - new ToStatusCodeHttpResultTE(), - new ToOkHttpResultTE(), - new ToContentHttpResultStringE(), - new ToServerSentEventsHttpResultIAsyncEnumerableTE(), - ]; + internal override IReadOnlyList Methods => + HttpResultMethodCatalog.ResultWithCustomErrorMethods; } diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs new file mode 100644 index 0000000..e7f9b7f --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/StringErrorResultExtensionsClassBuilder.cs @@ -0,0 +1,55 @@ +using System.Text; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; +using CSharpFunctionalExtensions.HttpResults.Generators.Rendering; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders; + +/// +/// Emits the built-in extension methods for and +/// string errors into the same partial class the +/// handwritten implementations used to live in. The static variants are always emitted; the +/// request-service-aware variants only when a provider implementation was discovered. +/// +internal sealed class StringErrorResultExtensionsClassBuilder(bool includeContextOverloads) +{ + public string SourceFileName => "StringErrorResultExtensions.g.cs"; + + public string Build() + { + var sourceBuilder = new StringBuilder(); + + sourceBuilder.AppendLine("// "); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("#nullable enable"); + sourceBuilder.AppendLine(); + GeneratedSourceUsings.AppendTo(sourceBuilder, includeContextOverloads); + sourceBuilder.AppendLine("namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions;"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine( + """ + /// + /// Extension methods for and + /// + """ + ); + sourceBuilder.AppendLine("public static partial class ResultExtensions {"); + sourceBuilder.AppendLine(); + + foreach (var method in HttpResultMethodCatalog.StringErrorMethods) + { + sourceBuilder.AppendLine(StringErrorMethodRenderer.GenerateStatic(method)); + sourceBuilder.AppendLine(); + + if (includeContextOverloads) + { + sourceBuilder.AppendLine(StringErrorMethodRenderer.GenerateWithContext(method)); + sourceBuilder.AppendLine(); + } + } + + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine("}"); + + return sourceBuilder.ToString(); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs index 7f03bd1..d09fdea 100644 --- a/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Builders/UnitResultExtensionsClassBuilder.cs @@ -1,13 +1,9 @@ -using CSharpFunctionalExtensions.HttpResults.Generators.UnitResultExtensions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; namespace CSharpFunctionalExtensions.HttpResults.Generators.Builders; -public class UnitResultExtensionsClassBuilder( - List mapperClasses, - Compilation? compilation = null -) : ClassBuilder(mapperClasses, compilation) +internal sealed class UnitResultExtensionsClassBuilder(IReadOnlyList mapperDescriptors) + : ExtensionClassBuilder(mapperDescriptors) { protected override string ClassName => "UnitResultExtensions"; @@ -18,6 +14,6 @@ public class UnitResultExtensionsClassBuilder( /// """; - internal override List MethodGenerators => - [new ToStatusCodeHttpResultE(), new ToNoContentHttpResultE()]; + internal override IReadOnlyList Methods => + HttpResultMethodCatalog.UnitResultWithCustomErrorMethods; } diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Compatibility/CompilerFeaturePolyfills.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Compatibility/CompilerFeaturePolyfills.cs new file mode 100644 index 0000000..3da7add --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Compatibility/CompilerFeaturePolyfills.cs @@ -0,0 +1,27 @@ +// Polyfills for language features that are not available on netstandard2.0: +// init-only setters (IsExternalInit) and required members. + +namespace System.Runtime.CompilerServices +{ + /// Polyfill for init-only property setters. + internal static class IsExternalInit { } + + /// Polyfill for the required members feature. + [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = false)] + internal sealed class CompilerFeatureRequiredAttribute : Attribute + { + public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName; + + public string FeatureName { get; } + } + + /// Polyfill for the required members feature. + internal sealed class RequiredMemberAttribute : Attribute { } +} + +namespace System.Diagnostics.CodeAnalysis +{ + /// Polyfill for constructors of types with required members. + [AttributeUsage(AttributeTargets.All, AllowMultiple = false, Inherited = false)] + internal sealed class SetsRequiredMembersAttribute : Attribute { } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs new file mode 100644 index 0000000..c77274c --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScan.cs @@ -0,0 +1,10 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Discovery; + +/// Symbols relevant to mapping generation discovered in source and referenced assemblies. +internal sealed record ImplementationScan( + IReadOnlyList SourceMappers, + IReadOnlyList Providers, + IReadOnlyList ReferencedServiceMappers +); diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs new file mode 100644 index 0000000..8a9a2d3 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Discovery/ImplementationScanner.cs @@ -0,0 +1,110 @@ +using CSharpFunctionalExtensions.HttpResults.Generators.Analysis; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Discovery; + +/// Discovers mapper and provider implementations once per compilation. +internal static class ImplementationScanner +{ + public static ImplementationScan Scan(Compilation compilation) + { + var resultMapperInterface = compilation.GetTypeByMetadataName( + $"{MapperSymbolResolver.LibraryNamespace}.{MapperSymbolResolver.ResultErrorMapperInterfaceMetadataName}" + ); + var serviceMapperInterface = compilation.GetTypeByMetadataName( + $"{MapperSymbolResolver.LibraryNamespace}.{MapperSymbolResolver.ServiceResultErrorMapperInterfaceMetadataName}" + ); + var providerInterface = compilation.GetTypeByMetadataName( + $"{MapperSymbolResolver.LibraryNamespace}.IResultProblemDetailsProvider" + ); + + var sourceTypes = GetAllTypes(compilation.Assembly.GlobalNamespace).ToArray(); + var sourceMappers = resultMapperInterface is null + ? [] + : sourceTypes.Where(type => ImplementsInterface(type, resultMapperInterface)).ToArray(); + + var providers = providerInterface is null + ? new List() + : sourceTypes.Where(type => ImplementsInterface(type, providerInterface)).ToList(); + var referencedServiceMappers = new List(); + + var libraryAssembly = providerInterface?.ContainingAssembly ?? resultMapperInterface?.ContainingAssembly; + if (libraryAssembly is not null) + { + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly) + continue; + + if (SymbolEqualityComparer.Default.Equals(assembly, libraryAssembly)) + continue; + + // Only assemblies that reference this library can contain relevant implementations. This avoids + // traversing every type in the framework and unrelated dependencies for each consumer compilation. + if (!ReferencesAssembly(assembly, libraryAssembly)) + continue; + + foreach (var type in GetAllTypes(assembly.GlobalNamespace)) + { + if ( + providerInterface is not null + && ImplementsInterface(type, providerInterface) + && SymbolAccessibility.IsReferenceable(type, compilation.Assembly) + ) + providers.Add(type); + + if ( + serviceMapperInterface is not null + && ImplementsInterface(type, serviceMapperInterface) + && SymbolAccessibility.IsReferenceable(type, compilation.Assembly) + ) + referencedServiceMappers.Add(type); + } + } + } + + return new ImplementationScan( + sourceMappers.Distinct(SymbolEqualityComparer.Default).OfType().ToArray(), + providers.Distinct(SymbolEqualityComparer.Default).OfType().ToArray(), + referencedServiceMappers.Distinct(SymbolEqualityComparer.Default).OfType().ToArray() + ); + } + + private static bool ImplementsInterface(INamedTypeSymbol type, INamedTypeSymbol interfaceType) => + type.AllInterfaces.Any(implemented => + SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, interfaceType) + ); + + private static bool ReferencesAssembly(IAssemblySymbol assembly, IAssemblySymbol referencedAssembly) => + assembly.Modules.Any(module => + module.ReferencedAssemblySymbols.Any(reference => + SymbolEqualityComparer.Default.Equals(reference, referencedAssembly) + ) + ); + + private static IEnumerable GetAllTypes(INamespaceSymbol namespaceSymbol) + { + foreach (var type in namespaceSymbol.GetTypeMembers()) + { + yield return type; + + foreach (var nestedType in GetNestedTypes(type)) + yield return nestedType; + } + + foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers()) + foreach (var type in GetAllTypes(childNamespace)) + yield return type; + } + + private static IEnumerable GetNestedTypes(INamedTypeSymbol type) + { + foreach (var nestedType in type.GetTypeMembers()) + { + yield return nestedType; + + foreach (var descendant in GetNestedTypes(nestedType)) + yield return descendant; + } + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ResultErrorMapperCacheEmitter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ResultErrorMapperCacheEmitter.cs new file mode 100644 index 0000000..c56ff62 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ResultErrorMapperCacheEmitter.cs @@ -0,0 +1,28 @@ +using System.Text; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Emitters; + +/// Emits internal singleton instances for valid result error mappers. +internal static class ResultErrorMapperCacheEmitter +{ + public const string HintName = "ResultErrorMapperCache.g.cs"; + + public static string Emit(IReadOnlyList mapperDescriptors) + { + var source = new StringBuilder(); + source.AppendLine("// "); + source.AppendLine(); + source.AppendLine("#nullable enable"); + source.AppendLine(); + source.AppendLine("internal static class CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache {"); + + foreach (var mapper in mapperDescriptors.Where(info => !info.IsServiceMapper)) + source.AppendLine( + $" internal static {mapper.MapperInterfaceType} {mapper.CacheMemberName} {{ get; }} = new {mapper.FullyQualifiedName}();" + ); + + source.AppendLine("}"); + return source.ToString(); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs new file mode 100644 index 0000000..121c53a --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Emitters/ServiceCollectionExtensionsEmitter.cs @@ -0,0 +1,72 @@ +using System.Text; +using CSharpFunctionalExtensions.HttpResults.Generators.Analysis; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Emitters; + +/// Emits zero-configuration scoped registrations for discovered DI implementations. +internal static class ServiceCollectionExtensionsEmitter +{ + public const string HintName = "ServiceCollectionExtensions.g.cs"; + + public static string Emit( + Compilation compilation, + INamedTypeSymbol? provider, + IReadOnlyList referencedServiceMappers, + IReadOnlyList sourceMappers + ) + { + var mapperTypes = sourceMappers + .Where(info => info.IsServiceMapper) + .Select(info => info.Symbol) + .Concat(referencedServiceMappers) + .Where(symbol => IsValidServiceMapper(symbol, compilation)) + .Distinct(SymbolEqualityComparer.Default) + .OfType() + .OrderBy(SymbolTypeNameFormatter.GetFullyQualifiedTypeName, StringComparer.Ordinal) + .ToArray(); + + var source = new StringBuilder(); + source.AppendLine("// "); + source.AppendLine(); + source.AppendLine("#nullable enable"); + source.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + source.AppendLine("using Microsoft.Extensions.DependencyInjection.Extensions;"); + source.AppendLine(); + source.AppendLine("namespace CSharpFunctionalExtensions.HttpResults;"); + source.AppendLine(); + // The helper is emitted into every consuming compilation. Keeping its containing type internal avoids + // clashes with the same generated helper in referenced projects while the extension remains available + // everywhere inside the application being compiled. + source.AppendLine("internal static class GeneratedServiceCollectionExtensions {"); + source.AppendLine( + " /// Registers discovered DI-enabled problem-details and error mappers as scoped services." + ); + source.AppendLine( + " public static IServiceCollection AddCSharpFunctionalExtensionsHttpResults(this IServiceCollection services) {" + ); + + if (provider is not null) + source.AppendLine( + $" services.TryAddScoped();" + ); + + foreach (var mapperType in mapperTypes) + source.AppendLine( + $" services.TryAddScoped<{SymbolTypeNameFormatter.GetFullyQualifiedTypeName(mapperType)}>();" + ); + + source.AppendLine(" return services;"); + source.AppendLine(" }"); + source.AppendLine("}"); + return source.ToString(); + } + + private static bool IsValidServiceMapper(INamedTypeSymbol type, Compilation compilation) => + type.TypeKind == TypeKind.Class + && !type.IsAbstract + && !type.IsStatic + && SymbolAccessibility.IsReferenceable(type, compilation.Assembly) + && SymbolAccessibility.HasPublicInstanceConstructor(type); +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs b/CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs deleted file mode 100644 index 5239aa3..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/IGenerateMethods.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators; - -internal interface IGenerateMethods -{ - string Generate(string mapperClassName, string resultErrorType, string httpResultType); -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodCatalog.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodCatalog.cs new file mode 100644 index 0000000..cdb0e00 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodCatalog.cs @@ -0,0 +1,36 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// The single source of truth for every generated HTTP-result method family. +internal static class HttpResultMethodCatalog +{ + private static readonly IReadOnlyList Definitions = + [ + new(HttpResultMethodKind.StatusCode, ResultReceiverKind.Result), + new(HttpResultMethodKind.StatusCode, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.Json, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.Ok, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.NoContent, ResultReceiverKind.Result), + new(HttpResultMethodKind.NoContent, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.Created, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.CreatedAtRoute, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.Accepted, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.AcceptedAtRoute, ResultReceiverKind.ResultOfT), + new(HttpResultMethodKind.File, ResultReceiverKind.ResultOfByteArray), + new(HttpResultMethodKind.FileStream, ResultReceiverKind.ResultOfStream), + new(HttpResultMethodKind.Content, ResultReceiverKind.ResultOfString), + new(HttpResultMethodKind.ServerSentEvents, ResultReceiverKind.ResultOfAsyncEnumerable), + new(HttpResultMethodKind.NoContent, ResultReceiverKind.UnitResult), + new(HttpResultMethodKind.StatusCode, ResultReceiverKind.UnitResult), + ]; + + public static IReadOnlyList StringErrorMethods { get; } = + Definitions.Where(definition => definition.SupportsStringErrors).ToArray(); + + public static IReadOnlyList ResultWithCustomErrorMethods { get; } = + Definitions + .Where(definition => definition.SupportsCustomErrors && definition.Receiver != ResultReceiverKind.UnitResult) + .ToArray(); + + public static IReadOnlyList UnitResultWithCustomErrorMethods { get; } = + Definitions.Where(definition => definition.Receiver == ResultReceiverKind.UnitResult).ToArray(); +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodDefinition.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodDefinition.cs new file mode 100644 index 0000000..475a902 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodDefinition.cs @@ -0,0 +1,155 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// +/// Structured metadata for one generated HTTP-result method family. All string-error and custom-error +/// generation derives from this single model so their signatures cannot drift independently. +/// +internal sealed record HttpResultMethodDefinition(HttpResultMethodKind Kind, ResultReceiverKind Receiver) +{ + public bool SupportsStringErrors => Receiver != ResultReceiverKind.UnitResult; + + public bool SupportsCustomErrors => Receiver != ResultReceiverKind.Result; + + public string MethodName => + Kind switch + { + HttpResultMethodKind.File => "ToFileHttpResult", + _ => $"To{Kind}HttpResult", + }; + + public string SuccessArm => + Kind switch + { + HttpResultMethodKind.StatusCode => "StatusCodeHttpResult", + HttpResultMethodKind.Json => "JsonHttpResult", + HttpResultMethodKind.Ok => "Ok", + HttpResultMethodKind.NoContent => "NoContent", + HttpResultMethodKind.Created => "Created", + HttpResultMethodKind.CreatedAtRoute => "CreatedAtRoute", + HttpResultMethodKind.Accepted => "Accepted", + HttpResultMethodKind.AcceptedAtRoute => "AcceptedAtRoute", + HttpResultMethodKind.File => "FileContentHttpResult", + HttpResultMethodKind.FileStream => "FileStreamHttpResult", + HttpResultMethodKind.Content => "ContentHttpResult", + HttpResultMethodKind.ServerSentEvents => "ServerSentEventsResult", + _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null), + }; + + public string SuccessExpression => + Kind switch + { + HttpResultMethodKind.StatusCode => "TypedResults.StatusCode(successStatusCode)", + HttpResultMethodKind.Json => "TypedResults.Json(result.Value, statusCode: successStatusCode)", + HttpResultMethodKind.Ok => "TypedResults.Ok(result.Value)", + HttpResultMethodKind.NoContent => "TypedResults.NoContent()", + HttpResultMethodKind.Created => + "uri is null ? TypedResults.Created(string.Empty, result.Value) : TypedResults.Created(uri.Invoke(result.Value), result.Value)", + HttpResultMethodKind.CreatedAtRoute => + "TypedResults.CreatedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value))", + HttpResultMethodKind.Accepted => "TypedResults.Accepted(uri(result.Value), result.Value)", + HttpResultMethodKind.AcceptedAtRoute => + "TypedResults.AcceptedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value))", + HttpResultMethodKind.File => + "TypedResults.File(result.Value, contentType, fileDownloadName, enableRangeProcessing, lastModified, entityTag)", + HttpResultMethodKind.FileStream => + "TypedResults.Stream(result.Value, contentType, fileDownloadName, lastModified, entityTag, enableRangeProcessing)", + HttpResultMethodKind.Content => "TypedResults.Content(result.Value, contentType, contentEncoding, statusCode)", + HttpResultMethodKind.ServerSentEvents => "TypedResults.ServerSentEvents(result.Value, eventType)", + _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null), + }; + + public IReadOnlyList Parameters => + Kind switch + { + HttpResultMethodKind.StatusCode => [new(MethodParameterKind.EmptySuccessStatusCode)], + HttpResultMethodKind.Json => [new(MethodParameterKind.JsonSuccessStatusCode)], + HttpResultMethodKind.Created => [new(MethodParameterKind.OptionalUri)], + HttpResultMethodKind.CreatedAtRoute or HttpResultMethodKind.AcceptedAtRoute => + [ + new(MethodParameterKind.RouteName), + new(MethodParameterKind.RouteValues), + ], + HttpResultMethodKind.Accepted => [new(MethodParameterKind.RequiredUri)], + HttpResultMethodKind.File or HttpResultMethodKind.FileStream => + [ + new(MethodParameterKind.ContentType), + new(MethodParameterKind.FileDownloadName), + new(MethodParameterKind.LastModified), + new(MethodParameterKind.EntityTag), + new(MethodParameterKind.EnableRangeProcessing), + ], + HttpResultMethodKind.Content => + [ + new(MethodParameterKind.ContentType), + new(MethodParameterKind.ContentEncoding), + new(MethodParameterKind.ContentStatusCode), + ], + HttpResultMethodKind.ServerSentEvents => [new(MethodParameterKind.EventType)], + _ => [], + }; + + public bool HasTypeParameter => + Receiver + is ResultReceiverKind.ResultOfT + or ResultReceiverKind.ResultOfStream + or ResultReceiverKind.ResultOfAsyncEnumerable; + + public string TypeConstraints => Receiver == ResultReceiverKind.ResultOfStream ? " where T : Stream" : ""; + + public string PreprocessorDirective => Kind == HttpResultMethodKind.ServerSentEvents ? "NET10_0_OR_GREATER" : ""; + + public string GetStringReceiver(bool isAsync) + { + var receiver = Receiver switch + { + ResultReceiverKind.Result => "Result", + ResultReceiverKind.ResultOfT or ResultReceiverKind.ResultOfStream => "Result", + ResultReceiverKind.ResultOfByteArray => "Result", + ResultReceiverKind.ResultOfString => "Result", + ResultReceiverKind.ResultOfAsyncEnumerable => "Result>", + _ => throw new InvalidOperationException($"{Receiver} does not support string-error mapping."), + }; + + return isAsync ? $"Task<{receiver}>" : receiver; + } + + public string GetCustomReceiver(string errorType, bool isAsync) + { + var receiver = Receiver switch + { + ResultReceiverKind.ResultOfT or ResultReceiverKind.ResultOfStream => $"Result", + ResultReceiverKind.ResultOfByteArray => $"Result", + ResultReceiverKind.ResultOfString => $"Result", + ResultReceiverKind.ResultOfAsyncEnumerable => $"Result,{errorType}>", + ResultReceiverKind.UnitResult => $"UnitResult<{errorType}>", + _ => throw new InvalidOperationException($"{Receiver} does not support custom-error mapping."), + }; + + return isAsync ? $"Task<{receiver}>" : receiver; + } + + public string BuildSummary(bool customError) + { + var valueText = Kind switch + { + HttpResultMethodKind.StatusCode => "a ", + HttpResultMethodKind.Json => "a ", + HttpResultMethodKind.Ok => "an ", + HttpResultMethodKind.NoContent => "a ", + HttpResultMethodKind.Created => "a ", + HttpResultMethodKind.CreatedAtRoute => "a ", + HttpResultMethodKind.Accepted => "an ", + HttpResultMethodKind.AcceptedAtRoute => "an ", + HttpResultMethodKind.File => "a ", + HttpResultMethodKind.FileStream => "a ", + HttpResultMethodKind.Content => "a ", + HttpResultMethodKind.ServerSentEvents => "a ", + _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null), + }; + + var failureText = customError + ? "Returns the custom error mapping in case of failure." + : "Returns a in case of failure."; + return $"Returns {valueText} in case of success. {failureText}"; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodKind.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodKind.cs new file mode 100644 index 0000000..eda24c8 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodKind.cs @@ -0,0 +1,18 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// The ASP.NET Core typed-result family produced on a successful result. +internal enum HttpResultMethodKind +{ + StatusCode, + Json, + Ok, + NoContent, + Created, + CreatedAtRoute, + Accepted, + AcceptedAtRoute, + File, + FileStream, + Content, + ServerSentEvents, +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodParameter.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodParameter.cs new file mode 100644 index 0000000..0eb5557 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/HttpResultMethodParameter.cs @@ -0,0 +1,56 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// Strongly typed metadata for one parameter in a generated mapping method. +internal readonly record struct HttpResultMethodParameter(MethodParameterKind Kind) +{ + public string Type => + Kind switch + { + MethodParameterKind.JsonSuccessStatusCode or MethodParameterKind.EmptySuccessStatusCode => "int", + MethodParameterKind.OptionalUri => "Func?", + MethodParameterKind.RequiredUri => "Func", + MethodParameterKind.RouteName + or MethodParameterKind.ContentType + or MethodParameterKind.FileDownloadName + or MethodParameterKind.EventType => "string?", + MethodParameterKind.RouteValues => "Func?", + MethodParameterKind.LastModified => "DateTimeOffset?", + MethodParameterKind.EntityTag => "EntityTagHeaderValue?", + MethodParameterKind.EnableRangeProcessing => "bool", + MethodParameterKind.ContentEncoding => "Encoding?", + MethodParameterKind.ContentStatusCode => "int?", + _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null), + }; + + public string Name => + Kind switch + { + MethodParameterKind.JsonSuccessStatusCode or MethodParameterKind.EmptySuccessStatusCode => "successStatusCode", + MethodParameterKind.OptionalUri or MethodParameterKind.RequiredUri => "uri", + MethodParameterKind.RouteName => "routeName", + MethodParameterKind.RouteValues => "routeValues", + MethodParameterKind.ContentType => "contentType", + MethodParameterKind.FileDownloadName => "fileDownloadName", + MethodParameterKind.LastModified => "lastModified", + MethodParameterKind.EntityTag => "entityTag", + MethodParameterKind.EnableRangeProcessing => "enableRangeProcessing", + MethodParameterKind.ContentEncoding => "contentEncoding", + MethodParameterKind.ContentStatusCode => "statusCode", + MethodParameterKind.EventType => "eventType", + _ => throw new ArgumentOutOfRangeException(nameof(Kind), Kind, null), + }; + + public string? DefaultValue => + Kind switch + { + MethodParameterKind.RequiredUri => null, + MethodParameterKind.JsonSuccessStatusCode => "200", + MethodParameterKind.EmptySuccessStatusCode => "204", + MethodParameterKind.EnableRangeProcessing => "false", + _ => "null", + }; + + public bool IsRequired => DefaultValue is null; + + public override string ToString() => IsRequired ? $"{Type} {Name}" : $"{Type} {Name} = {DefaultValue}"; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperDescriptor.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperDescriptor.cs new file mode 100644 index 0000000..fafc381 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperDescriptor.cs @@ -0,0 +1,45 @@ +using Microsoft.CodeAnalysis; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// +/// Precomputed information about one discovered error mapper, shared by analysis, +/// the builders and the registration emitter. +/// +internal sealed class MapperDescriptor +{ + /// Declared symbol of the mapper class. + public required INamedTypeSymbol Symbol { get; init; } + + /// Unique generated cache member for non-service mappers. + public required string CacheMemberName { get; init; } + + /// Fully qualified name of the mapper type. + public required string FullyQualifiedName { get; init; } + + /// Fully qualified name of the mapped error type (first type argument of IResultErrorMapper<,>). + public required string ErrorType { get; init; } + + /// Fully qualified name of the HttpResult type (second type argument of IResultErrorMapper<,>). + public required string HttpResultType { get; init; } + + /// Fully qualified closed IResultErrorMapper interface implemented by the mapper. + public required string MapperInterfaceType { get; init; } + + public required bool IsServiceMapper { get; init; } + + /// Indicates whether the Map method returns exactly . + public required bool IsProblemHttpResult { get; init; } + + public MapperGenerationContext ToContext() => + new() + { + CacheMemberName = CacheMemberName, + ErrorType = ErrorType, + HttpResultType = HttpResultType, + IsServiceMapper = IsServiceMapper, + MapperFullyQualifiedName = FullyQualifiedName, + MapperInterfaceType = MapperInterfaceType, + IsProblemHttpResult = IsProblemHttpResult, + }; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperGenerationContext.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperGenerationContext.cs new file mode 100644 index 0000000..016acd3 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MapperGenerationContext.cs @@ -0,0 +1,29 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// +/// Carries all information a method renderer needs to generate the extension methods +/// for a single discovered error mapper. +/// +internal readonly record struct MapperGenerationContext +{ + /// Unique generated cache member used for a standard mapper. + public required string CacheMemberName { get; init; } + + /// Fully qualified name of the mapped error type. + public required string ErrorType { get; init; } + + /// Fully qualified name of the HttpResult type returned by the mapper's Map method. + public required string HttpResultType { get; init; } + + /// Indicates whether the mapper implements . + public required bool IsServiceMapper { get; init; } + + /// Fully qualified name of the mapper type; used for request-service resolution of service mappers. + public required string MapperFullyQualifiedName { get; init; } + + /// Fully qualified closed mapper interface used to invoke Map through its contract. + public required string MapperInterfaceType { get; init; } + + /// Indicates whether the mapper's Map method returns exactly . + public required bool IsProblemHttpResult { get; init; } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/MethodParameterKind.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MethodParameterKind.cs new file mode 100644 index 0000000..496bb20 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/MethodParameterKind.cs @@ -0,0 +1,20 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// A supported parameter in a generated mapping-method signature. +internal enum MethodParameterKind +{ + JsonSuccessStatusCode, + EmptySuccessStatusCode, + OptionalUri, + RequiredUri, + RouteName, + RouteValues, + ContentType, + FileDownloadName, + LastModified, + EntityTag, + EnableRangeProcessing, + ContentEncoding, + ContentStatusCode, + EventType, +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Models/ResultReceiverKind.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Models/ResultReceiverKind.cs new file mode 100644 index 0000000..4c5be37 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Models/ResultReceiverKind.cs @@ -0,0 +1,13 @@ +namespace CSharpFunctionalExtensions.HttpResults.Generators.Models; + +/// The CSharpFunctionalExtensions result shape accepted by a generated method. +internal enum ResultReceiverKind +{ + Result, + ResultOfT, + ResultOfByteArray, + ResultOfStream, + ResultOfString, + ResultOfAsyncEnumerable, + UnitResult, +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/CustomErrorMethodRenderer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/CustomErrorMethodRenderer.cs new file mode 100644 index 0000000..0ab5f54 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/CustomErrorMethodRenderer.cs @@ -0,0 +1,145 @@ +using CSharpFunctionalExtensions.HttpResults.Generators.Models; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering; + +/// +/// Renders the sync and async extension-method overloads for one custom-error method family +/// and one discovered error mapper. Standard mappers resolve through their static instance, +/// service mappers through HttpContext.RequestServices. +/// +internal static class CustomErrorMethodRenderer +{ + private const string ServiceGuard = "A HttpContext is required for IServiceResultErrorMapper mappings."; + + public static string Generate(in MapperGenerationContext context, HttpResultMethodDefinition method) + { + var summary = BuildSummary(context, method); + return SyncAsyncMethodPairRenderer.Render( + method, + summary, + BuildSignature(context, method, isAsync: false), + BuildSignature(context, method, isAsync: true), + FailureBlock(context), + BuildForwardCall(context, method, awaitReceiver: true) + ); + } + + private static string BuildSummary(in MapperGenerationContext context, HttpResultMethodDefinition method) + { + var summary = method.BuildSummary(customError: true); + + if (context.IsServiceMapper) + summary += + " Requires an argument to resolve the registered from request services on failure."; + + if (context.IsProblemHttpResult) + summary += " The callback is applied to the returned ProblemHttpResult."; + + return summary; + } + + private static string BuildParameters( + in MapperGenerationContext context, + HttpResultMethodDefinition method, + bool isAsync + ) + { + var parameters = new List { $"this {method.GetCustomReceiver(context.ErrorType, isAsync)} result" }; + + var httpContextAdded = false; + + foreach (var parameter in method.Parameters) + { + if (context.IsServiceMapper && !httpContextAdded && !parameter.IsRequired) + { + parameters.Add("HttpContext httpContext"); + httpContextAdded = true; + } + + parameters.Add(parameter.ToString()); + } + + if (context.IsServiceMapper && !httpContextAdded) + parameters.Add("HttpContext httpContext"); + + if (context.IsProblemHttpResult) + parameters.Add("Action? customizeProblemDetails = null"); + + return string.Join(", ", parameters); + } + + private static string BuildSignature( + in MapperGenerationContext context, + HttpResultMethodDefinition method, + bool isAsync + ) + { + var generic = method.HasTypeParameter ? "" : ""; + var asyncKeyword = isAsync ? "async " : ""; + var returnType = isAsync + ? $"Task>" + : $"Results<{method.SuccessArm}, {context.HttpResultType}>"; + + return $"public static {asyncKeyword}{returnType} {method.MethodName}{generic}({BuildParameters(context, method, isAsync)}){method.TypeConstraints}"; + } + + private static string BuildForwardArguments( + in MapperGenerationContext context, + HttpResultMethodDefinition method, + bool awaitReceiver + ) + { + var arguments = new List { awaitReceiver ? "await result" : "result" }; + + foreach (var parameter in method.Parameters.Where(parameter => parameter.IsRequired)) + arguments.Add(parameter.Name); + + if (context.IsServiceMapper) + arguments.Add("httpContext"); + + foreach (var parameter in method.Parameters.Where(parameter => !parameter.IsRequired)) + arguments.Add(parameter.Name); + + if (context.IsProblemHttpResult) + arguments.Add("customizeProblemDetails"); + + return string.Join(", ", arguments); + } + + private static string BuildForwardCall( + in MapperGenerationContext context, + HttpResultMethodDefinition method, + bool awaitReceiver + ) => + $"{method.MethodName}{(method.HasTypeParameter ? "" : "")}({BuildForwardArguments(context, method, awaitReceiver)})"; + + private static string FailureBlock(in MapperGenerationContext context) + { + var lines = new List(); + + if (context.IsServiceMapper) + { + lines.Add(" if (httpContext is null)"); + lines.Add($" throw new InvalidOperationException(\"{ServiceGuard}\");"); + lines.Add(""); + } + + var mapped = context.IsServiceMapper + ? $"(({context.MapperInterfaceType})httpContext.RequestServices.GetRequiredService<{context.MapperFullyQualifiedName}>()).Map(result.Error)" + : $"CSharpFunctionalExtensionsHttpResultsResultErrorMapperCache.{context.CacheMemberName}.Map(result.Error)"; + + if (context.IsProblemHttpResult) + { + lines.Add($" var mapped = {mapped};"); + lines.Add(" customizeProblemDetails?.Invoke(mapped.ProblemDetails);"); + lines.Add(""); + lines.Add(" return mapped;"); + } + else + { + lines.Add($" return {mapped};"); + } + + return string.Join("\n", lines); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/GeneratedSourceUsings.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/GeneratedSourceUsings.cs new file mode 100644 index 0000000..57f3b98 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/GeneratedSourceUsings.cs @@ -0,0 +1,30 @@ +using System.Text; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering; + +/// Writes the common using directives required by generated result-extension source files. +internal static class GeneratedSourceUsings +{ + public static void AppendTo(StringBuilder sourceBuilder, bool includeDependencyInjection) + { + sourceBuilder.AppendLine( + """ + using CSharpFunctionalExtensions; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Net.Http.Headers; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading.Tasks; + """ + ); + + if (includeDependencyInjection) + sourceBuilder.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + + sourceBuilder.AppendLine(); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/StringErrorMethodRenderer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/StringErrorMethodRenderer.cs new file mode 100644 index 0000000..2c6c898 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/StringErrorMethodRenderer.cs @@ -0,0 +1,126 @@ +using CSharpFunctionalExtensions.HttpResults.Generators.Models; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering; + +/// +/// Renders the built-in mapping methods for string-error results. The static variants replicate the +/// former handwritten behavior; the context variants resolve an +/// from +/// HttpContext.RequestServices on failure. +/// +internal static class StringErrorMethodRenderer +{ + private const string StandardTailParameters = + "int failureStatusCode = 400, Action? customizeProblemDetails = null"; + + private const string ProviderGuard = + "A HttpContext is required to resolve the registered IResultProblemDetailsProvider."; + + private const string StaticFailureBlock = """ + var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); + var problemDetails = new ProblemDetails + { + Status = failureStatusCode, + Title = problemDetailsInfo.Title, + Type = problemDetailsInfo.Type, + Detail = result.Error, + }; + + customizeProblemDetails?.Invoke(problemDetails); + + return TypedResults.Problem(problemDetails); + """; + + private const string ContextFailureBlock = """ + if (httpContext is null) + throw new InvalidOperationException( + "A HttpContext is required to resolve the registered IResultProblemDetailsProvider."); + + var problemDetails = httpContext.RequestServices.GetRequiredService() + .CreateProblemDetails(httpContext, result.Error, failureStatusCode); + + customizeProblemDetails?.Invoke(problemDetails); + + return TypedResults.Problem(problemDetails); + """; + + public static string GenerateStatic(HttpResultMethodDefinition method) + { + var summary = method.BuildSummary(customError: false); + return SyncAsyncMethodPairRenderer.Render( + method, + summary, + BuildSignature(method, includeContext: false, isAsync: false), + BuildSignature(method, includeContext: false, isAsync: true), + StaticFailureBlock, + BuildForwardCall(method, includeContext: false, awaitReceiver: true) + ); + } + + public static string GenerateWithContext(HttpResultMethodDefinition method) + { + var summary = + $"{method.BuildSummary(customError: false)} Resolves the registered from request services for failures."; + return SyncAsyncMethodPairRenderer.Render( + method, + summary, + BuildSignature(method, includeContext: true, isAsync: false), + BuildSignature(method, includeContext: true, isAsync: true), + ContextFailureBlock, + BuildForwardCall(method, includeContext: true, awaitReceiver: true) + ); + } + + private static string BuildParameters(HttpResultMethodDefinition method, bool includeContext, bool isAsync) + { + var parameters = new List { $"this {method.GetStringReceiver(isAsync)} result" }; + var httpContextAdded = false; + + foreach (var parameter in method.Parameters) + { + if (includeContext && !httpContextAdded && !parameter.IsRequired) + { + parameters.Add("HttpContext httpContext"); + httpContextAdded = true; + } + + parameters.Add(parameter.ToString()); + } + + if (includeContext && !httpContextAdded) + parameters.Add("HttpContext httpContext"); + + parameters.Add(StandardTailParameters); + + return string.Join(", ", parameters); + } + + private static string BuildSignature(HttpResultMethodDefinition method, bool includeContext, bool isAsync) + { + var generic = method.HasTypeParameter ? "" : ""; + var asyncKeyword = isAsync ? "async " : ""; + var returnType = isAsync + ? $"Task>" + : $"Results<{method.SuccessArm}, ProblemHttpResult>"; + + return $"public static {asyncKeyword}{returnType} {method.MethodName}{generic}({BuildParameters(method, includeContext, isAsync)}){method.TypeConstraints}"; + } + + private static string BuildForwardCall(HttpResultMethodDefinition method, bool includeContext, bool awaitReceiver) + { + var arguments = new List { awaitReceiver ? "await result" : "result" }; + + foreach (var required in method.Parameters.Where(p => p.IsRequired)) + arguments.Add(required.Name); + + if (includeContext) + arguments.Add("httpContext"); + + foreach (var optional in method.Parameters.Where(p => !p.IsRequired)) + arguments.Add(optional.Name); + + arguments.Add("failureStatusCode, customizeProblemDetails"); + + return $"{method.MethodName}{(method.HasTypeParameter ? "" : "")}({string.Join(", ", arguments)})"; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/SyncAsyncMethodPairRenderer.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/SyncAsyncMethodPairRenderer.cs new file mode 100644 index 0000000..5cea6d6 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Generators/Rendering/SyncAsyncMethodPairRenderer.cs @@ -0,0 +1,47 @@ +using System.Text; +using CSharpFunctionalExtensions.HttpResults.Generators.Models; + +namespace CSharpFunctionalExtensions.HttpResults.Generators.Rendering; + +/// Renders the common sync/async pair and optional target-framework guard for a method family. +internal static class SyncAsyncMethodPairRenderer +{ + public static string Render( + HttpResultMethodDefinition method, + string summary, + string syncSignature, + string asyncSignature, + string failureBlock, + string asyncForwardCall + ) + { + var builder = new StringBuilder(); + + if (!string.IsNullOrEmpty(method.PreprocessorDirective)) + builder.AppendLine($"#if {method.PreprocessorDirective}"); + + AppendSummary(builder, summary); + builder.AppendLine(syncSignature + " {"); + builder.AppendLine($" if (result.IsSuccess) return {method.SuccessExpression};"); + builder.AppendLine(); + builder.AppendLine(failureBlock); + builder.AppendLine("}"); + builder.AppendLine(); + AppendSummary(builder, summary); + builder.AppendLine(asyncSignature + " {"); + builder.AppendLine($" return {asyncForwardCall};"); + builder.AppendLine("}"); + + if (!string.IsNullOrEmpty(method.PreprocessorDirective)) + builder.AppendLine("#endif"); + + return builder.ToString(); + } + + private static void AppendSummary(StringBuilder builder, string summary) + { + builder.AppendLine("/// "); + builder.AppendLine($"/// {summary}"); + builder.AppendLine("/// "); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs deleted file mode 100644 index c18526c..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedAtRouteHttpResultTE.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; - -internal class ToAcceptedAtRouteHttpResultTE : IGenerateMethods -{ - public string Generate(string mapperClassName, string resultErrorType, string httpResultType) - { - return $$""" - /// - /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header. - /// - public static Results, {{httpResultType}}> ToAcceptedAtRouteHttpResult(this Result result, string? routeName = null, Func? routeValues = null) - { - if (result.IsSuccess) return TypedResults.AcceptedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value)); - - return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error); - } - - /// - /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header. - /// - public static async Task, {{httpResultType}}>> ToAcceptedAtRouteHttpResult(this Task> result, string? routeName = null, Func? routeValues = null) - { - return (await result).ToAcceptedAtRouteHttpResult(routeName, routeValues); - } - """; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs deleted file mode 100644 index fe71561..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToAcceptedHttpResultTE.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; - -internal class ToAcceptedHttpResultTE : IGenerateMethods -{ - public string Generate(string mapperClassName, string resultErrorType, string httpResultType) - { - return $$""" - /// - /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header. - /// - public static Results, {{httpResultType}}> ToAcceptedHttpResult(this Result result, Func uri) - { - if (result.IsSuccess) return TypedResults.Accepted(uri(result.Value), result.Value); - - return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error); - } - - /// - /// Returns a with Accepted status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header. - /// - public static async Task, {{httpResultType}}>> ToAcceptedHttpResult(this Task> result, Func uri) - { - return (await result).ToAcceptedHttpResult(uri); - } - """; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs deleted file mode 100644 index 906738c..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToContentHttpResultStringE.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; - -internal class ToContentHttpResultStringE : IGenerateMethods -{ - public string Generate(string mapperClassName, string resultErrorType, string httpResultType) - { - return $$""" - /// - /// Returns a in case of success result. Returns custom mapping in case of failure. - /// - public static Results ToContentHttpResult(this Result result, string? contentType = null, Encoding? contentEncoding = null, int? statusCode = null) - { - if (result.IsSuccess) return TypedResults.Content(result.Value, contentType, contentEncoding, statusCode); - - return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error); - } - - /// - /// Returns a in case of success result. Returns custom mapping in case of failure. - /// - public static async Task> ToContentHttpResult(this Task> result, string? contentType = null, Encoding? contentEncoding = null, int? statusCode = null) - { - return (await result).ToContentHttpResult(contentType, contentEncoding, statusCode); - } - """; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs deleted file mode 100644 index 3b5c8ab..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedAtRouteHttpResultTE.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; - -internal class ToCreatedAtRouteHttpResultTE : IGenerateMethods -{ - public string Generate(string mapperClassName, string resultErrorType, string httpResultType) - { - return $$""" - /// - /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header. - /// - public static Results, {{httpResultType}}> ToCreatedAtRouteHttpResult(this Result result, string? routeName = null, Func? routeValues = null) - { - if (result.IsSuccess) return TypedResults.CreatedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value)); - - return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error); - } - - /// - /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide route info to create a location HTTP-Header. - /// - public static async Task, {{httpResultType}}>> ToCreatedAtRouteHttpResult(this Task> result, string? routeName = null, Func? routeValues = null) - { - return (await result).ToCreatedAtRouteHttpResult(routeName, routeValues); - } - """; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs deleted file mode 100644 index f2a98b6..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToCreatedHttpResultTE.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; - -internal class ToCreatedHttpResultTE : IGenerateMethods -{ - public string Generate(string mapperClassName, string resultErrorType, string httpResultType) - { - return $$""" - /// - /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header. - /// - public static Results, {{httpResultType}}> ToCreatedHttpResult(this Result result, Func? uri = null) - { - if (result.IsSuccess) - return uri is null - ? TypedResults.Created(string.Empty, result.Value) - : TypedResults.Created(uri.Invoke(result.Value), result.Value); - - return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error); - } - - /// - /// Returns a with Created status code in case of success result. Returns custom mapping in case of failure. You can provide an URI to create a location HTTP-Header. - /// - public static async Task, {{httpResultType}}>> ToCreatedHttpResult(this Task> result, Func? uri = null) - { - return (await result).ToCreatedHttpResult(uri); - } - """; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs deleted file mode 100644 index 2f5052f..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/ResultExtensions/ToFileHttpResultByteArrayE.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions; - -internal class ToFileHttpResultByteArrayE : IGenerateMethods -{ - public string Generate(string mapperClassName, string resultErrorType, string httpResultType) - { - return $$""" - /// - /// 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..f7fc96d 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, + ResultErrorMapperCacheEmitter.HintName, + ResultErrorMapperCacheEmitter.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() - .FirstOrDefault(method => method.Identifier.Text == MapMethodName); - - return mappingMethod?.ParameterList.Parameters[0].Type; - } - - private static List GetDuplicateMappedResultErrorClassNames(List mappedResultErrorTypes) - { - return mappedResultErrorTypes - .GroupBy(type => type!.ToString()) - .Where(grouping => grouping.Count() > 1) - .Select(grouping => grouping.Key) - .ToList(); - } - - private static Location? GetLocationOfDuplicate(List mapperClasses, string duplicateClassName) - { - return mapperClasses - .Select(GetMappedResultErrorType) - .Last(typeSyntax => typeSyntax!.ToString() == duplicateClassName) - ?.GetLocation(); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rules/IRule.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rules/IRule.cs deleted file mode 100644 index 0058712..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/Rules/IRule.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace CSharpFunctionalExtensions.HttpResults.Generators.Rules; - -/// -/// Defines a rule for validating mapper classes. -/// -internal interface IRule -{ - /// - /// Gets the diagnostic descriptor for the rule. - /// - DiagnosticDescriptor RuleDescriptor { get; } - - /// - /// Checks the rule against a list of mapper classes. - /// - /// The list of mapper classes to validate. - /// A collection of diagnostics representing rule violations. - IEnumerable Check(List mapperClasses); -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/Rules/ParameterlessConstructorRule.cs b/CSharpFunctionalExtensions.HttpResults.Generators/Rules/ParameterlessConstructorRule.cs deleted file mode 100644 index 7fd792b..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/Rules/ParameterlessConstructorRule.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace CSharpFunctionalExtensions.HttpResults.Generators.Rules; - -internal class ParameterlessConstructorRule : IRule -{ - public DiagnosticDescriptor RuleDescriptor { get; } = - new( - "CFEHTTPR004", - "Missing parameterless constructor in IResultErrorMapper", - "Class '{0}' does not have a parameterless constructor", - "Mapping", - DiagnosticSeverity.Error, - true, - customTags: ["CompilationEnd"] - ); - - public IEnumerable Check(List mapperClasses) - { - return mapperClasses - .Where(mapperClass => !HasParameterlessConstructor(mapperClass)) - .Select(mapperClass => - Diagnostic.Create(RuleDescriptor, mapperClass.Identifier.GetLocation(), mapperClass.Identifier.Text) - ); - } - - private static bool HasParameterlessConstructor(ClassDeclarationSyntax classDeclaration) - { - var hasExplicitParameterless = classDeclaration - .Members.OfType() - .Any(c => c.ParameterList.Parameters.Count == 0); - - if (hasExplicitParameterless) - return true; - - var hasAnyConstructors = classDeclaration.Members.OfType().Any(); - - return !hasAnyConstructors; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToNoContentHttpResultE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToNoContentHttpResultE.cs deleted file mode 100644 index 4e5b114..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToNoContentHttpResultE.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.UnitResultExtensions; - -internal class ToNoContentHttpResultE : 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 ToNoContentHttpResult(this UnitResult<{{resultErrorType}}> result) - { - if (result.IsSuccess) return TypedResults.NoContent(); - - return ErrorMapperInstances.{{mapperClassName}}.Map(result.Error); - } - - /// - /// 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/UnitResultExtensions/ToStatusCodeHttpResultE.cs b/CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToStatusCodeHttpResultE.cs deleted file mode 100644 index 026b320..0000000 --- a/CSharpFunctionalExtensions.HttpResults.Generators/UnitResultExtensions/ToStatusCodeHttpResultE.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CSharpFunctionalExtensions.HttpResults.Generators.UnitResultExtensions; - -internal class ToStatusCodeHttpResultE : 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 ToStatusCodeHttpResult(this UnitResult<{{resultErrorType}}> result, int successStatusCode = 204) - { - if (result.IsSuccess) return TypedResults.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> ToStatusCodeHttpResult(this Task> result, int successStatusCode = 204) - { - return (await result).ToStatusCodeHttpResult(successStatusCode); - } - """; - } -} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/CSharpFunctionalExtensions.HttpResults.IntegrationTests.csproj b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/CSharpFunctionalExtensions.HttpResults.IntegrationTests.csproj new file mode 100644 index 0000000..7618e5e --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/CSharpFunctionalExtensions.HttpResults.IntegrationTests.csproj @@ -0,0 +1,52 @@ + + + net8.0;net9.0;net10.0 + enable + enable + latest + false + true + Exe + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/DependencyInjectionEndpointsTests.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/DependencyInjectionEndpointsTests.cs new file mode 100644 index 0000000..c4bb59f --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/DependencyInjectionEndpointsTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using AwesomeAssertions; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests; + +/// +/// End-to-end tests over a real request pipeline (TestServer): proves that service mappers and +/// the registered IResultProblemDetailsProvider are resolved from RequestServices at runtime, +/// and that customizeProblemDetails still runs last - the pieces unit tests can only simulate. +/// +public class DependencyInjectionEndpointsTests : IClassFixture +{ + private readonly TestAppFactory _factory; + + public DependencyInjectionEndpointsTests(TestAppFactory factory) => _factory = factory; + + [Fact] + public async Task Service_mapper_is_resolved_from_request_services_with_injected_dependency() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/documents/doc-42", TestContext.Current.CancellationToken); + var problem = await ReadProblemDetailsAsync(response); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + problem.GetProperty("title").GetString().Should().Be("Document not found"); + problem.GetProperty("type").GetString().Should().Be("https://docs.example.test/errors/document-not-found"); + problem.GetProperty("detail").GetString().Should().Be("Document doc-42 could not be found."); + } + + [Fact] + public async Task Built_in_string_error_uses_the_registered_provider_and_the_callback_runs_last() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/books/7", TestContext.Current.CancellationToken); + var problem = await ReadProblemDetailsAsync(response); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + problem.GetProperty("status").GetInt32().Should().Be(404); + problem + .GetProperty("title") + .GetString() + .Should() + .Be("Not Found", "the MVC ProblemDetailsFactory supplies the RFC9457 title"); + problem.GetProperty("detail").GetString().Should().Be("Book 7 not found"); + problem + .GetProperty("source") + .GetString() + .Should() + .Be("mvc-problemdetails-factory", "the registered provider created the instance"); + problem + .GetProperty("callback") + .GetString() + .Should() + .Be("ran-last", "customizeProblemDetails must be applied after provider creation"); + } + + [Fact] + public async Task Static_zero_config_mapping_still_works_without_a_context() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/static/books/7", TestContext.Current.CancellationToken); + var problem = await ReadProblemDetailsAsync(response); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + problem.GetProperty("status").GetInt32().Should().Be(404); + problem.GetProperty("title").GetString().Should().Be("Not Found"); + problem.TryGetProperty("source", out _).Should().BeFalse("no provider is involved on static paths"); + } + + [Fact] + public async Task Success_arm_is_served_without_touching_request_services() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/books", TestContext.Current.CancellationToken); + + response.IsSuccessStatusCode.Should().BeTrue(); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().Contain("all-books"); + } + + private static Task ReadProblemDetailsAsync(HttpResponseMessage response) => + response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Books/BookEndpoints.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Books/BookEndpoints.cs new file mode 100644 index 0000000..e8dd84d --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Books/BookEndpoints.cs @@ -0,0 +1,34 @@ +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Books; + +public static class BookEndpoints +{ + public static IEndpointRouteBuilder MapBookEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/books/{id}", + (string id, HttpContext httpContext) => + Result + .Failure($"Book {id} not found") + .ToOkHttpResult( + httpContext, + failureStatusCode: 404, + customizeProblemDetails: problemDetails => problemDetails.Extensions["callback"] = "ran-last" + ) + ); + + endpoints.MapGet( + "/static/books/{id}", + (string id) => Result.Failure($"Book {id} not found").ToOkHttpResult(failureStatusCode: 404) + ); + + endpoints.MapGet("/books", (HttpContext httpContext) => Result.Success("all-books").ToOkHttpResult(httpContext)); + + return endpoints; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentEndpoints.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentEndpoints.cs new file mode 100644 index 0000000..231139d --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentEndpoints.cs @@ -0,0 +1,21 @@ +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Documents; + +public static class DocumentEndpoints +{ + public static IEndpointRouteBuilder MapDocumentEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet( + "/documents/{id}", + (string id, HttpContext httpContext) => + Result.Failure(new DocumentNotFoundError(id)).ToOkHttpResult(httpContext) + ); + + return endpoints; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentNotFoundError.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentNotFoundError.cs new file mode 100644 index 0000000..e9a59de --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentNotFoundError.cs @@ -0,0 +1,3 @@ +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Documents; + +public sealed record DocumentNotFoundError(string DocumentId); diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentNotFoundErrorMapper.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentNotFoundErrorMapper.cs new file mode 100644 index 0000000..6726b1f --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentNotFoundErrorMapper.cs @@ -0,0 +1,24 @@ +using CSharpFunctionalExtensions.HttpResults; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Documents; + +/// Service mapper resolved from request services; builds its ProblemDetails by hand. +public 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 {error.DocumentId} could not be found.", + }; + + return TypedResults.Problem(problemDetails); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentationLinkProvider.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentationLinkProvider.cs new file mode 100644 index 0000000..d125fb5 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/Documents/DocumentationLinkProvider.cs @@ -0,0 +1,7 @@ +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Documents; + +/// Injected into the service mapper to prove constructor injection through the real pipeline. +public sealed class DocumentationLinkProvider +{ + public string For(string topic) => $"https://docs.example.test/errors/{topic}"; +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/ProblemDetailsProvider/MvcFactoryProblemDetailsProvider.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/ProblemDetailsProvider/MvcFactoryProblemDetailsProvider.cs new file mode 100644 index 0000000..ea6675a --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Features/ProblemDetailsProvider/MvcFactoryProblemDetailsProvider.cs @@ -0,0 +1,21 @@ +using CSharpFunctionalExtensions.HttpResults; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.ProblemDetailsProvider; + +/// +/// Issue-#36 integration: creates the failure ProblemDetails synchronously via MVC's +/// , honoring ClientErrorMapping and custom factories. +/// +public sealed class MvcFactoryProblemDetailsProvider(ProblemDetailsFactory problemDetailsFactory) + : IResultProblemDetailsProvider +{ + public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) + { + var problemDetails = problemDetailsFactory.CreateProblemDetails(httpContext, statusCode, detail: error); + problemDetails.Extensions["source"] = "mvc-problemdetails-factory"; + return problemDetails; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Program.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Program.cs new file mode 100644 index 0000000..d170e21 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/Program.cs @@ -0,0 +1,37 @@ +using CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Books; +using CSharpFunctionalExtensions.HttpResults.IntegrationTests.Features.Documents; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests; + +/// +/// Entry point convention picked up by . +/// Builds a minimal Web API that exercises the generated dependency-injection paths end to end. +/// +public class Program +{ + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHost(webHost => + webHost + .ConfigureServices(services => + { + // Provides MVC's ProblemDetailsFactory used by the factory-backed provider below (issue #36). + services.AddControllers(); + services.AddCSharpFunctionalExtensionsHttpResults(); + services.AddSingleton(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapDocumentEndpoints(); + endpoints.MapBookEndpoints(); + }); + }) + ); +} diff --git a/CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestAppFactory.cs b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestAppFactory.cs new file mode 100644 index 0000000..74335f5 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.IntegrationTests/TestAppFactory.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; + +namespace CSharpFunctionalExtensions.HttpResults.IntegrationTests; + +/// Boots the in-memory test application for end-to-end requests. +public sealed class TestAppFactory : WebApplicationFactory +{ + protected override IHostBuilder CreateHostBuilder() => Program.CreateHostBuilder([]); +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderCollection.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderCollection.cs new file mode 100644 index 0000000..2b20031 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderCollection.cs @@ -0,0 +1,10 @@ +using Xunit; + +namespace CSharpFunctionalExtensions.HttpResults.Tests; + +/// +/// Tests around mutate its static mappings and must +/// not run in parallel with each other or with tests reading those mappings. +/// +[CollectionDefinition(nameof(ProblemDetailsMappingProvider), DisableParallelization = true)] +public sealed class ProblemDetailsMappingProviderCollection { } diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderTests.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderTests.cs index 80898e3..ff49416 100644 --- a/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderTests.cs +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ProblemDetailsMappingProviderTests.cs @@ -1,9 +1,11 @@ using AwesomeAssertions; using CSharpFunctionalExtensions.HttpResults.ResultExtensions; using Microsoft.AspNetCore.Http.HttpResults; +using Xunit; namespace CSharpFunctionalExtensions.HttpResults.Tests; +[Collection(nameof(ProblemDetailsMappingProvider))] public class ProblemDetailsMappingProviderTests { public ProblemDetailsMappingProviderTests() diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToAcceptedAtRouteHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToAcceptedAtRouteHttpResultT.cs new file mode 100644 index 0000000..4f6e5ec --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToAcceptedAtRouteHttpResultT.cs @@ -0,0 +1,63 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToAcceptedAtRouteHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToAcceptedAtRouteHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToAcceptedAtRouteHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToAcceptedAtRouteHttpResult(http, customizeProblemDetails: p => p.Title = customTitle) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToAcceptedAtRouteHttpResult(http)).Result + as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToAcceptedHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToAcceptedHttpResultT.cs new file mode 100644 index 0000000..12e5a6e --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToAcceptedHttpResultT.cs @@ -0,0 +1,66 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToAcceptedHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToAcceptedHttpResult(v => new Uri($"http://x/{v}"), http).Result + as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToAcceptedHttpResult(v => new Uri($"http://x/{v}"), http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToAcceptedHttpResult(v => new Uri($"http://x/{v}"), http, customizeProblemDetails: p => p.Title = customTitle) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + ( + await Task.FromResult(Result.Failure("Error")).ToAcceptedHttpResult(v => new Uri($"http://x/{v}"), http) + ).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToContentHttpResultString.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToContentHttpResultString.cs new file mode 100644 index 0000000..6a36479 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToContentHttpResultString.cs @@ -0,0 +1,57 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToContentHttpResultString_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToContentHttpResult(http).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToContentHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToContentHttpResult(http, customizeProblemDetails: p => p.Title = "Custom Title") + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be("Custom Title"); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + (await Task.FromResult(Result.Failure("Error")).ToContentHttpResult(http)).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToCreatedAtRouteHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToCreatedAtRouteHttpResultT.cs new file mode 100644 index 0000000..697faa0 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToCreatedAtRouteHttpResultT.cs @@ -0,0 +1,63 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToCreatedAtRouteHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToCreatedAtRouteHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToCreatedAtRouteHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToCreatedAtRouteHttpResult(http, customizeProblemDetails: p => p.Title = customTitle) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToCreatedAtRouteHttpResult(http)).Result + as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToCreatedHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToCreatedHttpResultT.cs new file mode 100644 index 0000000..90c7812 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToCreatedHttpResultT.cs @@ -0,0 +1,62 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToCreatedHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToCreatedHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToCreatedHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToCreatedHttpResult(http, customizeProblemDetails: p => p.Title = customTitle) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToCreatedHttpResult(http)).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToFileHttpResultByteArray.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToFileHttpResultByteArray.cs new file mode 100644 index 0000000..1f551aa --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToFileHttpResultByteArray.cs @@ -0,0 +1,57 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToFileHttpResultByteArray_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToFileHttpResult(http).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToFileHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToFileHttpResult(http, customizeProblemDetails: p => p.Title = "Custom Title") + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be("Custom Title"); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + (await Task.FromResult(Result.Failure("Error")).ToFileHttpResult(http)).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToFileStreamHttpResultStream.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToFileStreamHttpResultStream.cs new file mode 100644 index 0000000..19d6ad3 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToFileStreamHttpResultStream.cs @@ -0,0 +1,58 @@ +using System.IO; +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToFileStreamHttpResultStream_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToFileStreamHttpResult(http).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToFileStreamHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToFileStreamHttpResult(http, customizeProblemDetails: p => p.Title = "Custom Title") + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be("Custom Title"); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + (await Task.FromResult(Result.Failure("Error")).ToFileStreamHttpResult(http)).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToJsonHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToJsonHttpResultT.cs new file mode 100644 index 0000000..77e08b6 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToJsonHttpResultT.cs @@ -0,0 +1,60 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToJsonHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToJsonHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToJsonHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToJsonHttpResult(http, customizeProblemDetails: p => p.Title = customTitle).Result + as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToJsonHttpResult(http)).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToNoContentHttpResult.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToNoContentHttpResult.cs new file mode 100644 index 0000000..cee04c3 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToNoContentHttpResult.cs @@ -0,0 +1,60 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToNoContentHttpResult_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToNoContentHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToNoContentHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToNoContentHttpResult(http, customizeProblemDetails: p => p.Title = customTitle).Result + as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToNoContentHttpResult(http)).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToNoContentHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToNoContentHttpResultT.cs new file mode 100644 index 0000000..2e437f7 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToNoContentHttpResultT.cs @@ -0,0 +1,62 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToNoContentHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToNoContentHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToNoContentHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToNoContentHttpResult(http, customizeProblemDetails: p => p.Title = customTitle) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToNoContentHttpResult(http)).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToOkHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToOkHttpResultT.cs new file mode 100644 index 0000000..d6fa5bf --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToOkHttpResultT.cs @@ -0,0 +1,86 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToOkHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = Result.Failure("Error").ToOkHttpResult(http).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToOkHttpResult(http); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToOkHttpResult(http, customizeProblemDetails: p => p.Title = customTitle).Result + as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + (await Task.FromResult(Result.Failure("Error")).ToOkHttpResult(http)).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Success_never_touches_request_services_even_with_a_throwing_provider() + { + var services = new ServiceCollection(); + services.AddSingleton(_ => + throw new InvalidOperationException("provider must not be constructed") + ); + using var http = TestHttpContextHelper.Create(services); + + var act = () => Result.Success("ok").ToOkHttpResult(http); + + act.Should().NotThrow(); + } + + [Fact] + public void Null_context_fails_fast_with_a_descriptive_exception() + { + var act = () => Result.Failure("Error").ToOkHttpResult((HttpContext)null!); + + act.Should() + .Throw() + .WithMessage("A HttpContext is required to resolve the registered IResultProblemDetailsProvider."); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToServerSentEventsHttpResultIAsyncEnumerableT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToServerSentEventsHttpResultIAsyncEnumerableT.cs new file mode 100644 index 0000000..cb25289 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToServerSentEventsHttpResultIAsyncEnumerableT.cs @@ -0,0 +1,68 @@ +#if NET10_0_OR_GREATER + +using AwesomeAssertions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using CSharpFunctionalExtensions.HttpResults.Tests.Utils; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToServerSentEventsHttpResultIAsyncEnumerableT_Context +{ + [Fact] + public async Task Success_returns_server_sent_events_without_resolving_the_provider() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + var values = new[] { 1, 2 }.AsAsyncEnumerable(); + + var result = + Result.Success(values).ToServerSentEventsHttpResult(http, "number").Result as ServerSentEventsResult; + var (_, events) = await result!.ExecuteAndGetResponseAndValues(); + + events.Select(item => item.Data).Should().Contain(["1", "2"]); + } + + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure>("Error").ToServerSentEventsHttpResult(http).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public async Task Async_overload_forwards_context_and_parameters() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + ( + await Task.FromResult(Result.Failure>("Error")) + .ToServerSentEventsHttpResult(http, eventType: "number", failureStatusCode: 418) + ).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(418); + http.Recorder!.ReceivedStatusCode.Should().Be(418); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure>("Error") + .ToServerSentEventsHttpResult(http, customizeProblemDetails: details => details.Title = "custom") + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be("custom"); + } +} + +#endif diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToStatusCodeHttpResult.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToStatusCodeHttpResult.cs new file mode 100644 index 0000000..88ca2f5 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToStatusCodeHttpResult.cs @@ -0,0 +1,63 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToStatusCodeHttpResult_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToStatusCodeHttpResult(http, successStatusCode: 200).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToStatusCodeHttpResult(http, successStatusCode: 200); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToStatusCodeHttpResult(http, customizeProblemDetails: p => p.Title = customTitle).Result + as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + ( + await Task.FromResult(Result.Failure("Error")).ToStatusCodeHttpResult(http, successStatusCode: 200) + ).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToStatusCodeHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToStatusCodeHttpResultT.cs new file mode 100644 index 0000000..eec16f6 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ProblemDetailsProvider/ToStatusCodeHttpResultT.cs @@ -0,0 +1,65 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions.ProblemDetailsProvider; + +public class ToStatusCodeHttpResultT_Context +{ + [Fact] + public void Failure_uses_the_registered_provider() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result.Failure("Error").ToStatusCodeHttpResult(http, successStatusCode: 200).Result as ProblemHttpResult; + + result!.StatusCode.Should().Be(400); + result.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + result.ProblemDetails.Type.Should().Be(TestHttpContextHelper.ProviderType); + result.ProblemDetails.Detail.Should().Be("Error"); + http.Recorder!.ReceivedError.Should().Be("Error"); + http.Recorder!.ReceivedStatusCode.Should().Be(400); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } + + [Fact] + public void Failure_without_registered_provider_fails_fast() + { + using var http = TestHttpContextHelper.CreateWithoutProvider(); + + var act = () => Result.Failure("Error").ToStatusCodeHttpResult(http, successStatusCode: 200); + + act.Should().Throw(); + } + + [Fact] + public void Customize_callback_runs_after_the_provider() + { + const string customTitle = "Custom Title"; + using var http = TestHttpContextHelper.CreateWithProvider(); + + var result = + Result + .Failure("Error") + .ToStatusCodeHttpResult(http, customizeProblemDetails: p => p.Title = customTitle) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be(customTitle); + } + + [Fact] + public async Task Async_overload_forwards_the_context() + { + using var http = TestHttpContextHelper.CreateWithProvider(); + + var problem = + ( + await Task.FromResult(Result.Failure("Error")).ToStatusCodeHttpResult(http, successStatusCode: 200) + ).Result as ProblemHttpResult; + problem!.ProblemDetails.Title.Should().Be(TestHttpContextHelper.ProviderTitle); + http.Recorder!.ReceivedContext.Should().BeSameAs(http.Context); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ResultProblemMapperCallbackTests.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ResultProblemMapperCallbackTests.cs new file mode 100644 index 0000000..ad366fd --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ResultExtensions/ResultProblemMapperCallbackTests.cs @@ -0,0 +1,34 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ResultExtensions; + +public class ResultProblemMapperCallbackTests +{ + [Fact] + public void Callback_modifies_the_exact_ProblemHttpResult_returned_by_a_result_error_mapper() + { + var mapped = + Result + .Failure(new ResultProblemError("failure")) + .ToOkHttpResult(customizeProblemDetails: details => details.Title = "custom") + .Result as ProblemHttpResult; + + mapped!.ProblemDetails.Detail.Should().Be("failure"); + mapped.ProblemDetails.Title.Should().Be("custom"); + } + + [Fact] + public async Task Async_callback_modifies_the_exact_ProblemHttpResult_returned_by_a_result_error_mapper() + { + var mapped = + ( + await Task.FromResult(Result.Failure(new ResultProblemError("failure"))) + .ToOkHttpResult(customizeProblemDetails: details => details.Title = "custom") + ).Result as ProblemHttpResult; + + mapped!.ProblemDetails.Detail.Should().Be("failure"); + mapped.ProblemDetails.Title.Should().Be("custom"); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ServiceCollectionExtensionsTests.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..16c7814 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,101 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpFunctionalExtensions.HttpResults.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void Registers_the_discovered_provider_as_scoped() + { + var services = new ServiceCollection(); + services.AddCSharpFunctionalExtensionsHttpResults(); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var rootResolution = () => provider.GetRequiredService(); + rootResolution.Should().Throw(); + + using var firstScope = provider.CreateScope(); + using var secondScope = provider.CreateScope(); + var first = firstScope.ServiceProvider.GetRequiredService(); + first.Should().BeOfType(); + firstScope.ServiceProvider.GetRequiredService().Should().BeSameAs(first); + secondScope.ServiceProvider.GetRequiredService().Should().NotBeSameAs(first); + } + + [Fact] + public void A_pre_registered_provider_wins_regardless_of_call_order() + { + var custom = new RecordingResultProblemDetailsProvider(); + + var services = new ServiceCollection(); + services.AddSingleton(custom); + services.AddCSharpFunctionalExtensionsHttpResults(); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Should().BeSameAs(custom); + } + + [Fact] + public void A_provider_registered_after_the_generated_registration_wins() + { + var custom = new RecordingResultProblemDetailsProvider(); + var services = new ServiceCollection(); + services.AddCSharpFunctionalExtensionsHttpResults(); + services.AddSingleton(custom); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().BeSameAs(custom); + } + + [Fact] + public void Is_idempotent_when_called_twice() + { + var services = new ServiceCollection(); + services.AddCSharpFunctionalExtensionsHttpResults(); + services.AddCSharpFunctionalExtensionsHttpResults(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + scope.ServiceProvider.GetServices().Should().HaveCount(1); + } + + [Fact] + public void Discovered_service_mappers_are_registered_scoped_with_dependencies() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddCSharpFunctionalExtensionsHttpResults(); + + using var provider = services.BuildServiceProvider(); + using var firstScope = provider.CreateScope(); + using var secondScope = provider.CreateScope(); + var mapper = firstScope.ServiceProvider.GetRequiredService(); + mapper.Map(new ServiceDocumentError { DocumentId = "x" }).ProblemDetails.Detail.Should().Be("resolved-by-di:x"); + firstScope.ServiceProvider.GetRequiredService().Should().BeSameAs(mapper); + secondScope.ServiceProvider.GetRequiredService().Should().NotBeSameAs(mapper); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Explicit_service_mapper_registration_can_override_the_generated_default(bool registerBeforeHelper) + { + var custom = new ServiceDocumentErrorMapper(new ServiceMapperDependency()); + var services = new ServiceCollection(); + if (registerBeforeHelper) + services.AddSingleton(custom); + + services.AddCSharpFunctionalExtensionsHttpResults(); + + if (!registerBeforeHelper) + services.AddSingleton(custom); + + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().BeSameAs(custom); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/ServiceMappers/ServiceMapperFamilyTests.cs b/CSharpFunctionalExtensions.HttpResults.Tests/ServiceMappers/ServiceMapperFamilyTests.cs new file mode 100644 index 0000000..447a3be --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/ServiceMappers/ServiceMapperFamilyTests.cs @@ -0,0 +1,250 @@ +using AwesomeAssertions; +using CSharpFunctionalExtensions; +using CSharpFunctionalExtensions.HttpResults.ResultExtensions; +using CSharpFunctionalExtensions.HttpResults.Tests.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.ServiceMappers; + +/// +/// Exercises the generated overloads for IServiceResultErrorMapper mappers across every +/// mapping family. ServiceDocumentErrorMapper returns ProblemHttpResult, which is a valid +/// failure arm for all built-in unions. +/// +public class ServiceMapperFamilyTests +{ + private static TestHttpContext Context() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddScoped(); + return TestHttpContextHelper.Create(services); + } + + private static ServiceDocumentError Error => new() { DocumentId = "d1" }; + + [Fact] + public void Ok_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToOkHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void StatusCode_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToStatusCodeHttpResult(context, successStatusCode: 200).Result + as ProblemHttpResult; + + result!.StatusCode.Should().Be(404); + } + + [Fact] + public void Json_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToJsonHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be("Document not found"); + } + + [Fact] + public void NoContent_failure_for_ResultTE_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToNoContentHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void NoContent_failure_for_UnitResult_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + UnitResult.Failure(Error).ToNoContentHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void StatusCode_failure_for_UnitResult_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + UnitResult.Failure(Error).ToStatusCodeHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void Created_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result + .Failure(Error) + .ToCreatedHttpResult(context, uri: _ => new Uri("https://example.test/created")) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public async Task Async_Created_overload_forwards_the_context() + { + using var context = Context(); + + var result = + (await Task.FromResult(Result.Failure(Error)).ToCreatedHttpResult(context)).Result + as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void CreatedAtRoute_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result + .Failure(Error) + .ToCreatedAtRouteHttpResult(context, routeValues: _ => new { id = "d1" }) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void Accepted_failure_with_required_uri_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result + .Failure(Error) + .ToAcceptedHttpResult(_ => new Uri("https://example.test/accepted"), context) + .Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void AcceptedAtRoute_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result + .Failure(Error) + .ToAcceptedAtRouteHttpResult(context, routeName: "document") + .Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void FileContent_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToFileHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void FileStream_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToFileStreamHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + + [Fact] + public void Content_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + Result.Failure(Error).ToContentHttpResult(context).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } + +#if NET10_0_OR_GREATER + [Fact] + public async Task ServerSentEvents_failure_is_resolved_from_request_services() + { + using var context = Context(); + + var result = + ( + await Task.FromResult(Result.Failure, ServiceDocumentError>(Error)) + .ToServerSentEventsHttpResult(context) + ).Result as ProblemHttpResult; + + result!.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } +#endif + + [Fact] + public void Missing_context_fails_fast_with_a_descriptive_exception() + { + var act = () => Result.Failure(Error).ToOkHttpResult((HttpContext)null!); + + act.Should() + .Throw() + .WithMessage("A HttpContext is required for IServiceResultErrorMapper mappings."); + } + + [Fact] + public void Success_paths_never_resolve_the_mapper() + { + var services = new ServiceCollection(); + services.AddScoped(); + using var context = TestHttpContextHelper.Create(services); + + // The success arm of the very error the throwing mapper is registered for: + // resolving it would throw in its constructor. + var act = () => Result.Success("ok").ToOkHttpResult(context); + + act.Should().NotThrow(); + } + + [Fact] + public void Mapper_returning_ProblemHttpResult_supports_customize_callback() + { + using var context = Context(); + + var result = + Result + .Failure(Error) + .ToOkHttpResult(context, customizeProblemDetails: p => p.Title = "Edge Case") + .Result as ProblemHttpResult; + + result!.ProblemDetails.Title.Should().Be("Edge Case"); + result.ProblemDetails.Detail.Should().Be("resolved-by-di:d1"); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/RecordingResultProblemDetailsProvider.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/RecordingResultProblemDetailsProvider.cs new file mode 100644 index 0000000..f0d6d0b --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/RecordingResultProblemDetailsProvider.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// Records every call and returns distinguishable problem details. +public sealed class RecordingResultProblemDetailsProvider(Action? additionalConfiguration = null) + : IResultProblemDetailsProvider +{ + private int _calls; + + internal HttpContext? ReceivedContext { get; private set; } + internal string? ReceivedError { get; private set; } + internal int ReceivedStatusCode { get; private set; } + internal int Calls => _calls; + + public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode) + { + _calls++; + ReceivedContext = httpContext; + ReceivedError = error; + ReceivedStatusCode = statusCode; + + var problemDetails = new ProblemDetails + { + Status = statusCode, + Title = TestHttpContextHelper.ProviderTitle, + Type = TestHttpContextHelper.ProviderType, + Detail = error, + }; + additionalConfiguration?.Invoke(problemDetails); + + return problemDetails; + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ResultProblemError.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ResultProblemError.cs new file mode 100644 index 0000000..e8d6555 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ResultProblemError.cs @@ -0,0 +1,3 @@ +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +public sealed record ResultProblemError(string Detail); diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ResultProblemErrorMapper.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ResultProblemErrorMapper.cs new file mode 100644 index 0000000..cc92e55 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ResultProblemErrorMapper.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +public sealed class ResultProblemErrorMapper : IResultErrorMapper +{ + public ProblemHttpResult Map(ResultProblemError error) => + TypedResults.Problem(detail: error.Detail, statusCode: StatusCodes.Status422UnprocessableEntity); +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentError.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentError.cs new file mode 100644 index 0000000..a9fb11d --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentError.cs @@ -0,0 +1,7 @@ +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// Custom error used by the DI mapper fixtures. +public sealed class ServiceDocumentError +{ + public required string DocumentId { get; init; } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentErrorMapper.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentErrorMapper.cs new file mode 100644 index 0000000..2a22f81 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceDocumentErrorMapper.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// DI mapper returning ProblemHttpResult - usable as failure arm in every built-in union. +public sealed class ServiceDocumentErrorMapper(ServiceMapperDependency dependency) + : IServiceResultErrorMapper +{ + public ProblemHttpResult Map(ServiceDocumentError error) + { + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Title = "Document not found", + Detail = dependency.Prefix + error.DocumentId, + }; + + return TypedResults.Problem(problemDetails); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceMapperDependency.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceMapperDependency.cs new file mode 100644 index 0000000..aedee4e --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ServiceMapperDependency.cs @@ -0,0 +1,7 @@ +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// Injected dependency proving constructor injection works. +public sealed class ServiceMapperDependency +{ + public string Prefix { get; } = "resolved-by-di:"; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContext.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContext.cs new file mode 100644 index 0000000..516d38c --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContext.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// +/// A test whose request services are owned by the instance; disposing +/// releases the service provider. Converts implicitly to , so instances +/// can be passed directly to the mapping methods. +/// +internal sealed record TestHttpContext(HttpContext context, ServiceProvider serviceProvider) : IDisposable +{ + internal HttpContext Context => context; + + internal RecordingResultProblemDetailsProvider? Recorder { get; init; } + + public void Dispose() => serviceProvider.Dispose(); + + public static implicit operator HttpContext(TestHttpContext request) => request.Context; +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContextHelper.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContextHelper.cs new file mode 100644 index 0000000..5439902 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/TestHttpContextHelper.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// Builds an whose request services are backed by a real service collection. +internal static class TestHttpContextHelper +{ + internal const string ProviderTitle = "Created by DI"; + internal const string ProviderType = "https://example.test/problems/from-di"; + + /// Creates a request with a registered . + internal static TestHttpContext CreateWithProvider(Action? additionalConfiguration = null) + { + var recorder = new RecordingResultProblemDetailsProvider(additionalConfiguration); + var services = new ServiceCollection(); + services.AddSingleton(recorder); + + return Create(services) with + { + Recorder = recorder, + }; + } + + /// Creates a request without any registered . + internal static TestHttpContext CreateWithoutProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(); + + return Create(services); + } + + internal static TestHttpContext Create(IServiceCollection services) + { + var serviceProvider = services.BuildServiceProvider(); + return new TestHttpContext(new DefaultHttpContext { RequestServices = serviceProvider }, serviceProvider); + } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorError.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorError.cs new file mode 100644 index 0000000..e6766b6 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorError.cs @@ -0,0 +1,7 @@ +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// Distinct error so both DI mappers do not collide. +public sealed class ThrowingCtorError +{ + public required string DocumentId { get; init; } +} diff --git a/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorMapper.cs b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorMapper.cs new file mode 100644 index 0000000..c9c02e9 --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults.Tests/Shared/ThrowingCtorMapper.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CSharpFunctionalExtensions.HttpResults.Tests.Shared; + +/// Throws on construction - proves that success paths never resolve mappers. +public sealed class ThrowingCtorMapper : IServiceResultErrorMapper +{ + public ThrowingCtorMapper() => throw new InvalidOperationException("mapper must not be constructed"); + + public ProblemHttpResult Map(ThrowingCtorError error) => throw new NotSupportedException(); +} diff --git a/CSharpFunctionalExtensions.HttpResults.sln b/CSharpFunctionalExtensions.HttpResults.sln index f6560ab..470e41e 100644 --- a/CSharpFunctionalExtensions.HttpResults.sln +++ b/CSharpFunctionalExtensions.HttpResults.sln @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpFunctionalExtensions. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpFunctionalExtensions.HttpResults.Generators.Tests", "CSharpFunctionalExtensions.HttpResults.Generators.Tests\CSharpFunctionalExtensions.HttpResults.Generators.Tests.csproj", "{9FB6DEEC-BB56-44E8-B5B3-D94712E9C231}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpFunctionalExtensions.HttpResults.IntegrationTests", "CSharpFunctionalExtensions.HttpResults.IntegrationTests\CSharpFunctionalExtensions.HttpResults.IntegrationTests.csproj", "{617DD3C4-8470-4399-AAE7-13E8CE487B8B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpFunctionalExtensions.HttpResults.CodeFixes", "CSharpFunctionalExtensions.HttpResults.CodeFixes\CSharpFunctionalExtensions.HttpResults.CodeFixes.csproj", "{40EEB156-7934-4BC0-9AF7-E812973927A4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +43,14 @@ Global {9FB6DEEC-BB56-44E8-B5B3-D94712E9C231}.Debug|Any CPU.Build.0 = Debug|Any CPU {9FB6DEEC-BB56-44E8-B5B3-D94712E9C231}.Release|Any CPU.ActiveCfg = Release|Any CPU {9FB6DEEC-BB56-44E8-B5B3-D94712E9C231}.Release|Any CPU.Build.0 = Release|Any CPU + {617DD3C4-8470-4399-AAE7-13E8CE487B8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {617DD3C4-8470-4399-AAE7-13E8CE487B8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {617DD3C4-8470-4399-AAE7-13E8CE487B8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {617DD3C4-8470-4399-AAE7-13E8CE487B8B}.Release|Any CPU.Build.0 = Release|Any CPU + {40EEB156-7934-4BC0-9AF7-E812973927A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40EEB156-7934-4BC0-9AF7-E812973927A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40EEB156-7934-4BC0-9AF7-E812973927A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40EEB156-7934-4BC0-9AF7-E812973927A4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CSharpFunctionalExtensions.HttpResults/CSharpFunctionalExtensions.HttpResults.csproj b/CSharpFunctionalExtensions.HttpResults/CSharpFunctionalExtensions.HttpResults.csproj index bceace0..7eb3e11 100644 --- a/CSharpFunctionalExtensions.HttpResults/CSharpFunctionalExtensions.HttpResults.csproj +++ b/CSharpFunctionalExtensions.HttpResults/CSharpFunctionalExtensions.HttpResults.csproj @@ -15,7 +15,7 @@ CSharpFunctionalExtensions.HttpResults co-IT, Stimmler Seamlessly map Results from CSharpFunctionalExtensions to HttpResults for cleaner, more fluent Web APIs - Copyright (c) co-IT.eu GmbH 2025 + Copyright (c) co-IT.eu GmbH 2026 README.md https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults MIT @@ -41,6 +41,12 @@ PackagePath="analyzers/dotnet/cs" Visible="false" /> + @@ -48,5 +54,9 @@ Include="..\CSharpFunctionalExtensions.HttpResults.Generators\CSharpFunctionalExtensions.HttpResults.Generators.csproj" PrivateAssets="All" /> + diff --git a/CSharpFunctionalExtensions.HttpResults/IResultProblemDetailsProvider.cs b/CSharpFunctionalExtensions.HttpResults/IResultProblemDetailsProvider.cs new file mode 100644 index 0000000..f651d1c --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults/IResultProblemDetailsProvider.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CSharpFunctionalExtensions.HttpResults; + +/// +/// Creates the failure for built-in +/// and +/// mappings. Implement this interface to opt in to request-service-aware mapping. When exactly one valid +/// implementation is visible, generated context overloads and scoped registration are enabled. +/// +public interface IResultProblemDetailsProvider +{ + /// + /// Creates the failure problem details for the given error and status code. + /// + /// The current request context. Can be used to access request services or request information. + /// The error string of the failed . + /// The failure status code of the HTTP response. + public ProblemDetails CreateProblemDetails(HttpContext httpContext, string error, int statusCode); +} diff --git a/CSharpFunctionalExtensions.HttpResults/IServiceResultErrorMapper.cs b/CSharpFunctionalExtensions.HttpResults/IServiceResultErrorMapper.cs new file mode 100644 index 0000000..9ca7d7c --- /dev/null +++ b/CSharpFunctionalExtensions.HttpResults/IServiceResultErrorMapper.cs @@ -0,0 +1,9 @@ +namespace CSharpFunctionalExtensions.HttpResults; + +/// +/// Marks an error mapper as a request-service-resolved mapper. Implementations may use constructor injection. +/// The generated extension methods require an argument to +/// resolve the mapper from on failure. +/// +public interface IServiceResultErrorMapper : IResultErrorMapper + where THttpResult : Microsoft.AspNetCore.Http.IResult; diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedAtRouteHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedAtRouteHttpResultT.cs deleted file mode 100644 index 2457135..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedAtRouteHttpResultT.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a with Accepted status code in case of success result. Returns - /// in case of failure. You can provide route info to create a location HTTP-Header. You - /// can override the error status code. - /// - public static Results, ProblemHttpResult> ToAcceptedAtRouteHttpResult( - this Result result, - string? routeName = null, - Func? routeValues = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.AcceptedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value)); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a with Accepted status code in case of success result. Returns - /// in case of failure. You can provide route info to create a location HTTP-Header. You - /// can override the error status code. - /// - public static async Task, ProblemHttpResult>> ToAcceptedAtRouteHttpResult( - this Task> result, - string? routeName = null, - Func? routeValues = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToAcceptedAtRouteHttpResult( - routeName, - routeValues, - failureStatusCode, - customizeProblemDetails - ); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedHttpResultT.cs deleted file mode 100644 index 7e13c2c..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToAcceptedHttpResultT.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a with Accepted status code in case of success result. Returns - /// in case of failure. You can provide an URI to create a location HTTP-Header. You can - /// override the error status code. - /// - public static Results, ProblemHttpResult> ToAcceptedHttpResult( - this Result result, - Func uri, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.Accepted(uri(result.Value), result.Value); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a with Accepted status code in case of success result. Returns - /// in case of failure. You can provide an URI to create a location HTTP-Header. You can - /// override the error status code. - /// - public static async Task, ProblemHttpResult>> ToAcceptedHttpResult( - this Task> result, - Func uri, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToAcceptedHttpResult(uri, failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToContentHttpResultString.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToContentHttpResultString.cs deleted file mode 100644 index aaf11af..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToContentHttpResultString.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Text; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a in case of success result. Returns in case of - /// failure. You can override the error status code. - /// - public static Results ToContentHttpResult( - this Result result, - string? contentType = null, - Encoding? contentEncoding = null, - int? statusCode = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.Content(result.Value, contentType, contentEncoding, statusCode); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a in case of success result. Returns in case of - /// failure. You can override the error status code. - /// - public static async Task> ToContentHttpResult( - this Task> result, - string? contentType = null, - Encoding? contentEncoding = null, - int? statusCode = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToContentHttpResult( - contentType, - contentEncoding, - statusCode, - failureStatusCode, - customizeProblemDetails - ); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedAtRouteHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedAtRouteHttpResultT.cs deleted file mode 100644 index a7e5438..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedAtRouteHttpResultT.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a with Created status code in case of success result. Returns - /// in case of failure. You can provide route info to create a location HTTP-Header. You - /// can override the error status code. - /// - public static Results, ProblemHttpResult> ToCreatedAtRouteHttpResult( - this Result result, - string? routeName = null, - Func? routeValues = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.CreatedAtRoute(result.Value, routeName, routeValues?.Invoke(result.Value)); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a with Created status code in case of success result. Returns - /// in case of failure. You can provide route info to create a location HTTP-Header. You - /// can override the error status code. - /// - public static async Task, ProblemHttpResult>> ToCreatedAtRouteHttpResult( - this Task> result, - string? routeName = null, - Func? routeValues = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToCreatedAtRouteHttpResult( - routeName, - routeValues, - failureStatusCode, - customizeProblemDetails - ); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedHttpResultT.cs deleted file mode 100644 index 01cdebc..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToCreatedHttpResultT.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a with Created status code in case of success result. Returns - /// in case of failure. You can provide an URI to create a location HTTP-Header. You can - /// override the error status code. - /// - public static Results, ProblemHttpResult> ToCreatedHttpResult( - this Result result, - Func? uri = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return uri is null - ? TypedResults.Created(string.Empty, result.Value) - : TypedResults.Created(uri.Invoke(result.Value), result.Value); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a with Created status code in case of success result. Returns - /// in case of failure. You can provide an URI to create a location HTTP-Header. You can - /// override the error status code. - /// - public static async Task, ProblemHttpResult>> ToCreatedHttpResult( - this Task> result, - Func? uri = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToCreatedHttpResult(uri, failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileHttpResultByteArray.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileHttpResultByteArray.cs deleted file mode 100644 index 057d945..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileHttpResultByteArray.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a based of a byte array in case of success result. Returns - /// in case of failure. You can override the error status code. - /// - public static Results ToFileHttpResult( - this Result result, - string? contentType = null, - string? fileDownloadName = null, - DateTimeOffset? lastModified = null, - EntityTagHeaderValue? entityTag = null, - bool enableRangeProcessing = false, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.File( - result.Value, - contentType, - fileDownloadName, - enableRangeProcessing, - lastModified, - entityTag - ); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a based of a byte array in case of success result. Returns - /// in case of failure. You can override the error status code. - /// - public static async Task> ToFileHttpResult( - this Task> result, - string? contentType = null, - string? fileDownloadName = null, - DateTimeOffset? lastModified = null, - EntityTagHeaderValue? entityTag = null, - bool enableRangeProcessing = false, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToFileHttpResult( - contentType, - fileDownloadName, - lastModified, - entityTag, - enableRangeProcessing, - failureStatusCode, - customizeProblemDetails - ); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileStreamHttpResultStream.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileStreamHttpResultStream.cs deleted file mode 100644 index d66aa56..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToFileStreamHttpResultStream.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a based of a Stream in case of success result. Returns - /// in case of failure. You can override the error status code. - /// - public static Results ToFileStreamHttpResult( - this Result result, - string? contentType = null, - string? fileDownloadName = null, - DateTimeOffset? lastModified = null, - EntityTagHeaderValue? entityTag = null, - bool enableRangeProcessing = false, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - where T : Stream - { - if (result.IsSuccess) - return TypedResults.Stream( - result.Value, - contentType, - fileDownloadName, - lastModified, - entityTag, - enableRangeProcessing - ); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a based of a Stream in case of success result. Returns - /// in case of failure. You can override the error status code. - /// - public static async Task> ToFileStreamHttpResult( - this Task> result, - string? contentType = null, - string? fileDownloadName = null, - DateTimeOffset? lastModified = null, - EntityTagHeaderValue? entityTag = null, - bool enableRangeProcessing = false, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - where T : Stream - { - return (await result).ToFileStreamHttpResult( - contentType, - fileDownloadName, - lastModified, - entityTag, - enableRangeProcessing, - failureStatusCode, - customizeProblemDetails - ); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToJsonHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToJsonHttpResultT.cs deleted file mode 100644 index 5053ff2..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToJsonHttpResultT.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a in case of success result. Returns - /// in case of failure. You can override the success and error status code. - /// - public static Results, ProblemHttpResult> ToJsonHttpResult( - this Result result, - int successStatusCode = 200, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.Json(result.Value, statusCode: successStatusCode); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a in case of success result. Returns - /// in case of failure. You can override the success and error status code. - /// - public static async Task, ProblemHttpResult>> ToJsonHttpResult( - this Task> result, - int successStatusCode = 200, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToJsonHttpResult(successStatusCode, failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResult.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResult.cs deleted file mode 100644 index 9458215..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResult.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a in case of success result. Returns in case of - /// failure. You can override the error status code. - /// - public static Results ToNoContentHttpResult( - this Result result, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.NoContent(); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a in case of success result. Returns in case of - /// failure. You can override the error status code. - /// - public static async Task> ToNoContentHttpResult( - this Task result, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToNoContentHttpResult(failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResultT.cs deleted file mode 100644 index ff8f6df..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToNoContentHttpResultT.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Discards the value of and Returns a in case of success result. - /// Returns in case of failure. You can override the error status code. - /// - public static Results ToNoContentHttpResult( - this Result result, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.NoContent(); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Discards the value of and Returns a in case of success result. - /// Returns in case of failure. You can override the error status code. - /// - public static async Task> ToNoContentHttpResult( - this Task> result, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToNoContentHttpResult(failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToOkHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToOkHttpResultT.cs deleted file mode 100644 index 67980e1..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToOkHttpResultT.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a in case of success result. Returns in case of - /// failure. You can override the error status code. - /// - public static Results, ProblemHttpResult> ToOkHttpResult( - this Result result, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.Ok(result.Value); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a in case of success result. Returns in case of - /// failure. You can override the error status code. - /// - public static async Task, ProblemHttpResult>> ToOkHttpResult( - this Task> result, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToOkHttpResult(failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableT.cs deleted file mode 100644 index bcae8ba..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToServerSentEventsHttpResultIAsyncEnumerableT.cs +++ /dev/null @@ -1,56 +0,0 @@ -#if NET10_0_OR_GREATER - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a based of a in case of success - /// result. Returns in case of failure. You can override the error status code. - /// - public static Results, ProblemHttpResult> ToServerSentEventsHttpResult( - this Result> result, - string? eventType = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.ServerSentEvents(result.Value, eventType); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a based of a in case of success - /// result. Returns in case of failure. You can override the error status code. - /// - public static async Task, ProblemHttpResult>> ToServerSentEventsHttpResult( - this Task>> result, - string? eventType = null, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToServerSentEventsHttpResult(eventType, failureStatusCode, customizeProblemDetails); - } -} -#endif diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResult.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResult.cs deleted file mode 100644 index f28b662..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResult.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Returns a in case of success result. Returns in - /// case of failure. You can override the success and error status code. - /// - public static Results ToStatusCodeHttpResult( - this Result result, - int successStatusCode = 204, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.StatusCode(successStatusCode); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Returns a in case of success result. Returns in - /// case of failure. You can override the success and error status code. - /// - public static async Task> ToStatusCodeHttpResult( - this Task result, - int successStatusCode = 204, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToStatusCodeHttpResult(successStatusCode, failureStatusCode, customizeProblemDetails); - } -} diff --git a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResultT.cs b/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResultT.cs deleted file mode 100644 index 827ae51..0000000 --- a/CSharpFunctionalExtensions.HttpResults/ResultExtensions/ToStatusCodeHttpResultT.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; - -namespace CSharpFunctionalExtensions.HttpResults.ResultExtensions; - -/// -/// Extension methods for -/// -public static partial class ResultExtensions -{ - /// - /// Discards the value of and Returns a in case of success - /// result. Returns in case of failure. You can override the success and error status - /// code. - /// - public static Results ToStatusCodeHttpResult( - this Result result, - int successStatusCode = 204, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - if (result.IsSuccess) - return TypedResults.StatusCode(successStatusCode); - - var problemDetailsInfo = ProblemDetailsMappingProvider.FindMapping(failureStatusCode); - var problemDetails = new ProblemDetails - { - Status = failureStatusCode, - Title = problemDetailsInfo.Title, - Type = problemDetailsInfo.Type, - Detail = result.Error, - }; - - customizeProblemDetails?.Invoke(problemDetails); - - return TypedResults.Problem(problemDetails); - } - - /// - /// Discards the value of and Returns a in case of success - /// result. Returns in case of failure. You can override the success and error status - /// code. - /// - public static async Task> ToStatusCodeHttpResult( - this Task> result, - int successStatusCode = 204, - int failureStatusCode = 400, - Action? customizeProblemDetails = null - ) - { - return (await result).ToStatusCodeHttpResult(successStatusCode, failureStatusCode, customizeProblemDetails); - } -} diff --git a/LICENSE.md b/LICENSE.md index 5328567..1bdf9b5 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 co-IT.eu GmbH +Copyright (c) 2026 co-IT.eu GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index a0f4d04..a8da85d 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,10 @@ Seamlessly map Results from [CSharpFunctionalExtensions](https://github.com/vkho 1. [Available methods](#available-methods) 2. [Default mapping](#default-mapping) 3. [Custom error mapping](#custom-error-mapping) -4. [Analyzers](#analyzers) -5. [Examples](#examples) -6. [Development](#development) +4. [Dependency injection](#dependency-injection) +5. [Analyzers](#analyzers) +6. [Examples](#examples) +7. [Development](#development) @@ -113,7 +114,10 @@ app.MapGet("/books", (BookService service) => -All methods are available in sync and async variants. +All methods are available in sync and async variants. For custom error mappers using dependency injection - and for built-in mappings once a custom `IResultProblemDetailsProvider` exists (see [Dependency injection](#dependency-injection)) - additional overloads with a required `HttpContext` parameter are generated. + +> [!IMPORTANT] +> All mapping methods - including the built-in ones for `Result`/`Result` - are provided by the integrated source generator. When upgrading from a version where they shipped as compiled library code, **recompile your project**: assemblies built against an older package version will miss these methods at runtime. ### Default mapping @@ -215,7 +219,7 @@ When using `Result` or `UnitResult`, this library uses a Source Generato ``` > [!IMPORTANT] -> Make sure that each custom error type has exactly one corresponding `IResultMapper` implementation. +> Make sure that each custom error type has exactly one corresponding `IResultErrorMapper<,>` or `IServiceResultErrorMapper<,>` implementation. > [!TIP] > You can use the `ProblemDetailsMappingProvider.FindMapping()` method to find a suitable title and type for a status code based on [RFC9110](https://tools.ietf.org/html/rfc9110). @@ -223,13 +227,129 @@ When using `Result` or `UnitResult`, this library uses a Source Generato > [!NOTE] > If extension methods for custom errors are missing, rebuild the project to trigger Source Generation. + +## Dependency injection + +All mapping methods come from an integrated source generator, so they can adapt to your project: + +- Custom error mappers may opt into dependency injection by implementing `IServiceResultErrorMapper<,>` instead of `IResultErrorMapper<,>`. The generated overloads then require an `HttpContext` and resolve the mapper from `HttpContext.RequestServices` on failure — constructor injection works out of the box. +- Built-in string-error mappings (`Result`, `Result`) can be overridden by registering a custom `IResultProblemDetailsProvider`. As soon as such an implementation exists in your compilation, the generator additionally emits context overloads for every built-in method. + +### Setup + +Call the generated zero-config registration method during startup: + +```csharp +builder.Services.AddCSharpFunctionalExtensionsHttpResults(); +``` + +This registers every valid discovered `IServiceResultErrorMapper<,>` as scoped. If exactly one valid +`IResultProblemDetailsProvider` implementation is visible, it is also registered as scoped. Without a provider, +the context-free methods keep using `ProblemDetailsMappingProvider.FindMapping()` and no provider service is registered. + +### Custom error mappers with constructor injection + +```csharp +public sealed class UserNotFoundErrorMapper(DocumentationLinkProvider links) + : IServiceResultErrorMapper +{ + public ProblemHttpResult Map(UserNotFoundError error) => + TypedResults.Problem( + statusCode: StatusCodes.Status404NotFound, + type: links.For("user-not-found"), + detail: error.Message); +} +``` + +Pass the `HttpContext` that Minimal APIs and controllers provide anyway: + +```csharp +app.MapGet("/users/{id}", (string id, HttpContext httpContext, UserRepository repo) => + repo.Find(id) //Result + .ToOkHttpResult(httpContext) //Results,ProblemHttpResult> +); + +// Controller: +public IActionResult Get(string id) => + _repo.Find(id).ToOkHttpResult(HttpContext); +``` + +> [!IMPORTANT] +> Mappers implementing `IServiceResultErrorMapper<,>` are registered automatically when they are concrete, +> closed, accessible to generated code, and have a public constructor. Mapper implementations declared in the +> application may be internal. Mapper implementations discovered in referenced assemblies must be public so the +> consuming application's generated registration code can reference them. + +### Overriding the built-in failure mapping (e.g. ProblemDetailsFactory) + +Implement `IResultProblemDetailsProvider` to control how failures of `Result`/`Result` are turned into +`ProblemDetails`. The library intentionally provides only the contract; framework-specific policy remains in your +application. For example, a provider can delegate to ASP.NET Core's `ProblemDetailsFactory`: + +```csharp +public sealed class ProblemDetailsFactoryProvider(ProblemDetailsFactory factory) + : IResultProblemDetailsProvider +{ + public ProblemDetails CreateProblemDetails( + HttpContext httpContext, + string error, + int statusCode) => + factory.CreateProblemDetails(httpContext, statusCode, detail: error); +} + +// ProblemDetailsFactory is supplied by MVC. +builder.Services.AddControllers(); +builder.Services.AddCSharpFunctionalExtensionsHttpResults(); +``` + +`ProblemDetailsFactory.CreateProblemDetails` creates the instance synchronously, including a custom factory's +defaults. `IProblemDetailsService` writes to the response rather than returning an instance, so it is not used by +this mapping contract. + +Once exactly one valid implementation is visible in the current or a referenced assembly, every built-in method +additionally offers an overload with a required `HttpContext`, and the generated startup helper registers that +provider automatically. If the helper is not called, a context overload fails fast at runtime when its failure path +tries to resolve the provider. + +```csharp +app.MapGet("/books", (HttpContext httpContext, BookService svc) => + svc.Get() + .ToOkHttpResult( + httpContext, + failureStatusCode: 404, + customizeProblemDetails: problemDetails => + { + problemDetails.Title = "Custom Title"; + problemDetails.Extensions.Add("custom", "value"); + })); +``` + +The selected overload determines the base mapping: context-free overloads use the static RFC 9457 mapping, while +context overloads use the registered provider. There is no runtime fallback between the two. In both cases, +`customizeProblemDetails` runs last; this is also true for custom mappers returning `ProblemHttpResult`. + +Generated registrations use `TryAddScoped`. A matching registration made before +`AddCSharpFunctionalExtensionsHttpResults()` is preserved; a matching registration made afterwards becomes the +last registration and is returned by the default `GetRequiredService` resolution. This lets applications override +provider and mapper lifetimes explicitly. + ## Analyzers -This library includes analyzers to help you use it correctly. +This library includes analyzers to help you use it correctly: + +- **CFEHTTPR002** - reported when multiple `IResultErrorMapper`/`IServiceResultErrorMapper` implementations exist for the same error type (across standard and service mappers). +- **CFEHTTPR004** - reported when a standard mapper cannot be created through an accessible parameterless constructor, including unsatisfied required members. Its code fix migrates the mapper to `IServiceResultErrorMapper<,>`; generated calls then require `HttpContext`. +- **CFEHTTPR005** - reported when more than one valid `IResultProblemDetailsProvider` is visible. +- **CFEHTTPR006** - reported for unsupported mapper shapes, such as abstract, open-generic, inaccessible, or non-DI-constructible service mappers. +- **CFEHTTPR007** - reported for unsupported provider shapes. -For example, they will notify you if you have multiple mappers for the same custom error type or if your mapper class doesn't have a parameterless constructor. +The analyzer package also provides a code fix for `CFEHTTPR004` that converts an `IResultErrorMapper<,>` requiring +constructor injection into an `IServiceResultErrorMapper<,>`. -You can find a complete list of all analyzers [here](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md). +The complete analyzer history is documented in the +[shipped](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Shipped.md) +and [unshipped](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Generators/AnalyzerReleases.Unshipped.md) +release files. ## Examples @@ -240,6 +360,8 @@ The [`CSharpFunctionalExtensions.HttpResults.Examples`](https://github.com/co-IT - **[Custom error mapping](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Examples/Features/CustomError)** – Defining and mapping custom error types to meaningful HTTP responses - **[Multiple errors in chain](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Examples/Features/MultipleErrorChain)** – Using different kind of custom errors in the same result chain - **[Customizing default mapping](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Examples/Program.cs)** – Overriding default mappings for localization or specific use cases +- **[Dependency injection](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Examples/Features/DependencyInjection)** – Service mappers with constructor injection +- **[ProblemDetails provider](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/CSharpFunctionalExtensions.HttpResults.Examples/Features/ProblemDetailsProvider)** – Application-owned integration of `IResultProblemDetailsProvider` with ASP.NET Core MVC Check out the example project for hands-on implementation details! @@ -258,8 +380,9 @@ This project uses [CSharpier](https://csharpier.com) for code formatting. You ca To add new methods follow these steps: -1. Add methods for `Result` and `Result` to `CSharpFunctionalExtensions.HttpResults.ResultExtensions` -2. Add methods for `Result` to `CSharpFunctionalExtensions.HttpResults.Generators.ResultExtensions` and add the class to `ResultExtensionsClassBuilder` -3. Add methods for `UnitResult` to `CSharpFunctionalExtensions.HttpResults.Generators.UnitResultExtensions` and add the class to `UnitResultExtensionsClassBuilder` -4. Add tests for **all** new methods to `CSharpFunctionalExtensions.HttpResults.Tests` -5. Add methods to [README](https://github.com/co-IT/CSharpFunctionalExtensions.HttpResults/blob/main/README.md) +1. Add the method once to `Models/HttpResultMethodCatalog.cs` using `HttpResultMethodKind` and `ResultReceiverKind` +2. Add parameter metadata to `MethodParameterKind` / `HttpResultMethodParameter` only when the family needs a new parameter shape +3. Add tests for the built-in, custom-error, sync, async, and DI variants to `CSharpFunctionalExtensions.HttpResults.Tests` +4. Add generator tests that compile the emitted source +5. Add integration tests when behavior depends on generated registrations, dependency injection, or the ASP.NET Core request pipeline +6. Add the method to this README diff --git a/coverlet.runsettings b/coverlet.runsettings index af65b87..05f8f50 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -5,10 +5,10 @@ cobertura - [CSharpFunctionalExtensions.HttpResults.Examples]*,[CSharpFunctionalExtensions.HttpResults.Generators]*ResultExtensions*,[CSharpFunctionalExtensions.HttpResults.Generators]*UnitResultExtensions*, + [CSharpFunctionalExtensions.HttpResults.Examples]* **/*.g.cs true - true + false true