From 268ae3d19f47bf3bd117b23bdc84095b3c2a2177 Mon Sep 17 00:00:00 2001 From: Brenton Farmer Date: Thu, 19 Mar 2026 10:51:20 -0700 Subject: [PATCH 1/5] feat: replace IResultMapper with ResultMapper base class - Replace IResultMapper interface with ResultMapper base class with virtual methods: MapException, GetStatusCode, MapValidationFailures, MapCancellation, MapSuccess - Add ResultMapper.Create() factory for one-off lambda customizations - Change default validation status code from 400 to 422 - Change default exception handling from throw to ProblemDetails 500 - Add chainable IResult decorators: WithHeader, WithNoContent - Add DecoratedResult internal class for response customization - Rename selector parameter to contentSelector - Null success results now return 404 NotFound instead of Ok(null) - Update docs and tests Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/dependency-injection.md | 4 +- docs/validation.md | 30 +- .../DecoratedResult.cs | 20 + .../Extensions/CommandResultExtensions.cs | 309 +++--------- .../Extensions/ResultExtensions.cs | 40 ++ .../IResultMapper.cs | 30 -- src/Hyperbee.Pipeline.AspNetCore/README.md | 96 ++-- .../ResultMapper.cs | 212 ++++++++ .../CommandResultExtensionsTests.cs | 92 +++- .../ResultExtensionsTests.cs | 96 ++++ .../ResultMapperTests.cs | 451 ++++++++++++++---- 11 files changed, 955 insertions(+), 425 deletions(-) create mode 100644 src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs create mode 100644 src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs delete mode 100644 src/Hyperbee.Pipeline.AspNetCore/IResultMapper.cs create mode 100644 src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs create mode 100644 test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 8d2d337..3eaf1e8 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -79,9 +79,9 @@ services.AddSingleton(); ## Result Mapper -Register an `IResultMapper` to apply shared exception-to-HTTP-status mapping across endpoints. +Subclass `ResultMapper` to apply shared exception-to-HTTP-status mapping across endpoints. See the [AspNetCore README](../src/Hyperbee.Pipeline.AspNetCore/README.md) for details. ```csharp -services.AddSingleton(); +services.AddSingleton(); ``` diff --git a/docs/validation.md b/docs/validation.md index 49f52b7..141123e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -332,7 +332,7 @@ app.MapPost("/orders", async (Order order, ICommandFunction var result = await command.ExecuteAsync(order, cancellationToken); // Automatically returns: - // - 400 Bad Request for validation failures + // - 422 Unprocessable Entity for validation failures // - 404 Not Found for NotFoundValidationFailure // - 401 Unauthorized for UnauthorizedValidationFailure // - 403 Forbidden for ForbiddenValidationFailure @@ -357,23 +357,41 @@ app.MapPost("/orders", async (Order order, ICommandFunction }); ``` -For shared mapping logic across many endpoints, implement `IResultMapper` and register it with DI: +For shared mapping logic across many endpoints, subclass `ResultMapper` and register it with DI: ```csharp -services.AddSingleton(); +services.AddSingleton(); app.MapPost("/orders", async ( Order order, ICommandFunction command, - IResultMapper resultMapper) => + ResultMapper mapper) => { var result = await command.ExecuteAsync(order); - return result.ToResult( resultMapper ); + return result.ToResult( mapper ); }); ``` +For one-off customizations without subclassing, use `ResultMapper.Create()` with lambdas: + +```csharp +app.MapPost("/orders", async (Order order, ICommandFunction command) => +{ + var mapper = ResultMapper.Create( + mapException: ex => ex is DbUpdateConcurrencyException + ? Results.Conflict( "Version conflict. Reload and retry." ) + : null + ); + + var result = await command.ExecuteAsync(order); + return result.ToResult( x => x?.ToPresentation(), mapper ); +}); +``` + +Any lambda not provided falls back to the default behavior. + See the [AspNetCore README](../src/Hyperbee.Pipeline.AspNetCore/README.md) for full details on -`IResultMapper` and all `ToResult` overloads. +`ResultMapper` and all `ToResult` overloads. ## Advanced Scenarios diff --git a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs new file mode 100644 index 0000000..78242fd --- /dev/null +++ b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace Hyperbee.Pipeline.AspNetCore; + +/// +/// Wraps an with an action that decorates the +/// before the inner result executes. Used by and +/// to chain response customizations. +/// +internal sealed class DecoratedResult( IResult inner, Action decorator ) : IResult +{ + internal IResult Inner { get; } = inner; + internal Action Decorator { get; } = decorator; + + public async Task ExecuteAsync( HttpContext httpContext ) + { + Decorator( httpContext ); + await Inner.ExecuteAsync( httpContext ); + } +} diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs index df56449..8614f3a 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs @@ -1,316 +1,135 @@ -using System.Reflection; using Hyperbee.Pipeline.Commands; -using Hyperbee.Pipeline.Context; -using Hyperbee.Pipeline.Validation; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Logging; namespace Hyperbee.Pipeline.AspNetCore.Extensions; /// -/// Provides extension methods for converting and instances -/// to representations. +/// Extension methods for converting and +/// to using the pipeline. /// -/// These extension methods simplify the process of transforming command execution results into -/// standardized objects, which can be used in APIs or other result-driven workflows. The methods -/// handle invalid commands by returning appropriate error results, such as forbidden, unauthorized, or bad request -/// responses, based on the validation context. public static class CommandResultExtensions { + // Generic overloads + /// - /// Converts a to an representation. + /// Converts a to an . /// - /// The type of the result contained in the . - /// The command result to convert. Must not be . - /// An representing the outcome of the command. If the command is invalid, returns an error - /// result; otherwise, returns an with the successful result. - public static IResult ToResult( this CommandResult commandResult ) + public static IResult ToResult( this CommandResult commandResult, ResultMapper? mapper = null ) { - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; + mapper ??= ResultMapper.Default; - return Results.Ok( commandResult.Result ); + return mapper.HandleCommand( + commandResult.Context, + commandResult.CommandType, + () => mapper.MapSuccess( commandResult.Result ) + ); } /// - /// Converts a to an using a result mapper - /// that has full access to the command result for custom error and success handling. + /// Converts a to an using a + /// result mapper function for custom error and success handling. /// /// - /// The runs before default error handling. If it returns a non-null - /// , that result is used directly. If it returns , the - /// default error and success handling logic applies. + /// The runs before all other handling. Return a non-null + /// to short-circuit, or to fall through + /// to the pipeline. /// - /// The type of the result contained in the . - /// The command result to convert. - /// A function that inspects the full command result and optionally returns - /// an . Return to fall through to default handling. - /// An representing the outcome of the command. public static IResult ToResult( this CommandResult commandResult, - Func, IResult?> resultMapper + Func, IResult?> resultMapper, + ResultMapper? mapper = null ) { var mapped = resultMapper( commandResult ); if ( mapped != null ) return mapped; - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; - - return Results.Ok( commandResult.Result ); + return commandResult.ToResult( mapper ); } /// - /// Converts a to an using an injected - /// for cross-cutting result handling. + /// Converts a to an using a + /// content selector to project the result body. /// - /// - /// The mapper runs before default error handling. If it returns a non-null - /// , that result is used directly. If it returns , the - /// default error and success handling logic applies. - /// - /// The type of the result contained in the . + /// The type of the command result. + /// The projected body type. /// The command result to convert. - /// An that inspects the command result. - /// An representing the outcome of the command. - public static IResult ToResult( - this CommandResult commandResult, - IResultMapper resultMapper - ) - { - var mapped = resultMapper.Map( commandResult ); - if ( mapped != null ) - return mapped; - - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; - - return Results.Ok( commandResult.Result ); - } - - /// - /// Converts a to an by transforming the result - /// using the provided selector function. - /// - /// The type of the result contained in the . - /// The type of the transformed result. - /// The command result to convert. Must not be . - /// A function to transform the result from to . - /// An representing the outcome of the command. If the command is invalid, returns an error - /// result; otherwise, returns with the transformed result if non-null, or if the result is null. + /// Projects the result into the response body shape. + /// Optional custom mapper. Uses if null. public static IResult ToResult( this CommandResult commandResult, - Func selector + Func contentSelector, + ResultMapper? mapper = null ) where TResult : class { - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; + mapper ??= ResultMapper.Default; - var transformedResult = selector( commandResult.Result ); + return mapper.HandleCommand( + commandResult.Context, + commandResult.CommandType, + () => + { + var transformed = contentSelector( commandResult.Result ); - // If the selector returns an IResult, use it directly - if ( transformedResult is IResult result ) - return result; + if ( transformed is IResult result ) + return result; - return transformedResult is not null ? Results.Ok( transformedResult ) : Results.NotFound(); + return mapper.MapSuccess( transformed ); + } + ); } + // Non-generic overloads + /// - /// Converts a to an based on the command's execution context and - /// type. + /// Converts a to an . /// - /// This method evaluates the validity of the command using its context and type. If the command - /// is determined to be invalid, an appropriate error result is returned. Otherwise, a successful result is - /// returned. - /// The result of a command execution, including its context and type. - /// An representing the outcome of the command. If the command is invalid, returns an error - /// result; otherwise, returns a successful result. - public static IResult ToResult( this CommandResult commandResult ) + public static IResult ToResult( this CommandResult commandResult, ResultMapper? mapper = null ) { - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; + mapper ??= ResultMapper.Default; - return Results.Ok(); + return mapper.HandleCommand( + commandResult.Context, + commandResult.CommandType, + () => Results.Ok() + ); } /// - /// Converts a to an using a result mapper - /// that has full access to the command result for custom error and success handling. + /// Converts a to an using a + /// result mapper function for custom error and success handling. /// - /// - /// The runs before default error handling. If it returns a non-null - /// , that result is used directly. If it returns , the - /// default error and success handling logic applies. - /// - /// The command result to convert. - /// A function that inspects the full command result and optionally returns - /// an . Return to fall through to default handling. - /// An representing the outcome of the command. public static IResult ToResult( this CommandResult commandResult, - Func resultMapper + Func resultMapper, + ResultMapper? mapper = null ) { var mapped = resultMapper( commandResult ); if ( mapped != null ) return mapped; - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; - - return Results.Ok(); + return commandResult.ToResult( mapper ); } - /// - /// Converts a to an using an injected - /// for cross-cutting result handling. - /// - /// - /// The mapper runs before default error handling. If it returns a non-null - /// , that result is used directly. If it returns , the - /// default error and success handling logic applies. - /// - /// The command result to convert. - /// An that inspects the command result. - /// An representing the outcome of the command. - public static IResult ToResult( - this CommandResult commandResult, - IResultMapper resultMapper - ) - { - var mapped = resultMapper.Map( commandResult ); - if ( mapped != null ) - return mapped; - - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; - - return Results.Ok(); - } + // File result /// - /// Converts a to an that represents a file response. + /// Converts a to a file . /// - /// The command result containing the stream to be returned as a file. - /// The MIME type of the file content. Defaults to "application/json". - /// An representing the file response. If the command result is invalid, an error result is - /// returned. public static IResult ToFileResult( this CommandResult commandResult, - string contentType = "application/json" - ) - { - if ( TryHandleInvalidCommand( commandResult.Context, commandResult.CommandType, out var errorResult ) ) - return errorResult; - - return Results.File( commandResult.Result, contentType ); - } - - private static bool TryHandleInvalidCommand( - IPipelineContext context, - MemberInfo commandType, - out IResult errorResult - ) - { - if ( !context.IsValid() ) - { - var failures = context.ValidationFailures(); - - if ( TryHandleValidationFailure( failures, StatusCodes.Status404NotFound, out errorResult ) ) - return true; - - if ( TryHandleValidationFailure( failures, StatusCodes.Status403Forbidden, out errorResult ) ) - return true; - - if ( TryHandleValidationFailure( failures, StatusCodes.Status401Unauthorized, out errorResult ) ) - return true; - - if ( TryHandleValidationFailure( failures, StatusCodes.Status400BadRequest, out errorResult ) ) - return true; - - if ( TryHandleValidationFailure( failures, StatusCodes.Status400BadRequest, out errorResult ) ) - return true; - - // This should never be reached, but just in case - errorResult = Results.Problem( - detail: "An unexpected validation error occurred.", - statusCode: StatusCodes.Status400BadRequest, - title: "Validation Failed" - ); - return true; - } - - if ( context.IsCanceled && context.CancellationValue is IResult cancellationValue ) - { - errorResult = cancellationValue; - - context.Logger?.LogDebug( - "Command [name={Name}, action={Action}, cancellationResult={@CancellationResult}]", - commandType.Name, - "Canceled", - errorResult - ); - return true; - } - - if ( context.IsError ) - throw new CommandException( $"Command {commandType.Name} failed", context.Exception ); - - errorResult = null!; - return false; - } - - private static bool TryHandleValidationFailure( - IEnumerable failures, - int statusCode, - out IResult errorResult, - Func, object>? bodySelector = null + string contentType = "application/json", + ResultMapper? mapper = null ) - where TValidationFailure : IValidationFailure { - var matchingFailures = failures.OfType().ToList(); - - if ( matchingFailures.Count == 0 ) - { - errorResult = null!; - return false; - } - - // Determine error body: custom selector or default structure - var errors = - bodySelector != null ? bodySelector( matchingFailures ) : matchingFailures.Select( CreateErrorObject ).ToList(); + mapper ??= ResultMapper.Default; - errorResult = Results.Problem( - detail: "One or more validation errors occurred.", - statusCode: statusCode, - title: ReasonPhrases.GetReasonPhrase( statusCode ), - extensions: new Dictionary { ["errors"] = errors } + return mapper.HandleCommand( + commandResult.Context, + commandResult.CommandType, + () => Results.File( commandResult.Result, contentType ) ); - - return true; - - // Local function to create error object - - static Dictionary CreateErrorObject( TValidationFailure failure ) - { - var errorDict = new Dictionary(); - - if ( !string.IsNullOrWhiteSpace( failure.PropertyName ) ) - errorDict["propertyName"] = failure.PropertyName; - - if ( !string.IsNullOrWhiteSpace( failure.ErrorMessage ) ) - errorDict["errorMessage"] = failure.ErrorMessage; - - if ( !string.IsNullOrWhiteSpace( failure.ErrorCode ) ) - errorDict["errorCode"] = failure.ErrorCode; - - if ( failure.AttemptedValue != null ) - errorDict["attemptedValue"] = failure.AttemptedValue.ToString() ?? string.Empty; - - return errorDict; - } } } diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..1f6845f --- /dev/null +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; + +namespace Hyperbee.Pipeline.AspNetCore.Extensions; + +/// +/// Chainable extension methods for decorating responses. +/// +public static class ResultExtensions +{ + /// + /// Adds an HTTP header to the response. If the value is null or whitespace, the + /// header is not added. + /// + /// The result to decorate. + /// The header name. + /// The header value. If null or whitespace, the result is returned unchanged. + /// A decorated that sets the header before executing. + public static IResult WithHeader( this IResult result, string name, string? value ) + { + if ( string.IsNullOrWhiteSpace( value ) ) + return result; + + return new DecoratedResult( result, ctx => ctx.Response.Headers[name] = value ); + } + + /// + /// Replaces the response with a 204 No Content result while preserving any + /// previously chained decorators (e.g., headers). + /// + /// The result to replace. + /// A 204 No Content with any prior decorators preserved. + public static IResult WithNoContent( this IResult result ) + { + // If there are existing decorators, preserve them on top of NoContent + if ( result is DecoratedResult decorated ) + return new DecoratedResult( Results.NoContent(), decorated.Decorator ); + + return Results.NoContent(); + } +} diff --git a/src/Hyperbee.Pipeline.AspNetCore/IResultMapper.cs b/src/Hyperbee.Pipeline.AspNetCore/IResultMapper.cs deleted file mode 100644 index b77bb91..0000000 --- a/src/Hyperbee.Pipeline.AspNetCore/IResultMapper.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Hyperbee.Pipeline.Commands; -using Microsoft.AspNetCore.Http; - -namespace Hyperbee.Pipeline.AspNetCore; - -/// -/// Maps a to an for cross-cutting error handling. -/// -/// -/// -/// Register implementations in the DI container and inject into endpoints to apply shared -/// result mapping logic (e.g., converting specific exceptions to HTTP status codes). -/// -/// -/// The mapper runs before default error handling. Return an to handle the -/// command result, or to fall through to the default handling logic. -/// -/// -public interface IResultMapper -{ - /// - /// Attempts to map the given command result to an . - /// - /// The command result to inspect. - /// - /// An if this mapper handles the command result; - /// otherwise, to delegate to default handling. - /// - IResult? Map( CommandResult commandResult ); -} diff --git a/src/Hyperbee.Pipeline.AspNetCore/README.md b/src/Hyperbee.Pipeline.AspNetCore/README.md index 0f0af9c..79623e2 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/README.md +++ b/src/Hyperbee.Pipeline.AspNetCore/README.md @@ -17,7 +17,7 @@ app.MapPost( "/items", async ( } ); ``` -### Transform Results Before Returning +### Transform Results with a Content Selector ```csharp app.MapGet( "/items/{id}", async ( @@ -41,60 +41,78 @@ app.MapGet( "/reports/{id}", async ( } ); ``` -### Custom Result Mapping +### Custom Result Mapping with ResultMapper -Use a `resultMapper` to handle specific exceptions or customize both error and success responses. -The mapper receives the full `CommandResult` and returns an `IResult`, or `null` to fall through -to default handling. +Subclass `ResultMapper` to customize error handling, status codes, and success responses. +Override only the methods you need. ```csharp +public class BillingResultMapper : ResultMapper +{ + public override IResult? MapException( Exception exception ) => exception switch + { + WriteConflictRepositoryException => Results.Conflict( "Version mismatch. Reload and retry." ), + CommandException { InnerException: WriteConflictRepositoryException } + => Results.Conflict( "Version mismatch. Reload and retry." ), + _ => base.MapException( exception ) + }; +} +``` + +Register with DI and inject into endpoints: + +```csharp +services.AddSingleton(); + app.MapPost( "/items", async ( CreateItemRequest request, - ICommandFunction command ) => + ICommandFunction command, + ResultMapper mapper ) => { var commandResult = await command.ExecuteAsync( request ); - return commandResult.ToResult( cr => - { - if ( cr.Context.IsError && cr.Context.Exception is DbUpdateConcurrencyException ) - return Results.Conflict( "Version conflict. Reload and retry." ); - return null; // fall through to default handling - } ); + return commandResult.ToResult( mapper ); } ); ``` -### Cross-Cutting Result Mapping with IResultMapper +### One-Off Mapper with Lambdas -For shared result mapping logic, implement the `IResultMapper` interface and register it with DI. -This is useful when multiple endpoints need the same exception-to-HTTP-status mapping. +Use `ResultMapper.Create()` for one-off customizations without subclassing: ```csharp -public class ConflictResultMapper : IResultMapper -{ - public IResult? Map( CommandResult commandResult ) - { - if ( commandResult.Context.IsError && - commandResult.Context.Exception is DbUpdateConcurrencyException ) - { - return Results.Conflict( "Version conflict. Reload and retry." ); - } - - return null; // fall through to default handling - } -} +var mapper = ResultMapper.Create( + mapException: ex => ex is DbUpdateConcurrencyException + ? Results.Conflict( "Version conflict." ) + : null +); + +return commandResult.ToResult( x => x?.ToPresentation(), mapper ); ``` -Register with DI and inject into endpoints: +### Response Decorators + +Chain `.WithHeader()` and `.WithNoContent()` to customize successful responses: ```csharp -services.AddSingleton(); +// Add an ETag header +return commandResult.ToResult( x => x?.ToPresentation(), mapper ) + .WithHeader( "ETag", commandResult.Result?.VersionTag ); -app.MapPost( "/items", async ( - CreateItemRequest request, - ICommandFunction command, - IResultMapper resultMapper ) => +// Return 204 No Content for write operations +return commandResult.ToResult( mapper ).WithNoContent(); +``` + +### Inline Result Mapping + +Use a `Func, IResult?>` for one-off custom handling. +Return `null` to fall through to default handling. + +```csharp +var commandResult = await command.ExecuteAsync( request ); +return commandResult.ToResult( cr => { - var commandResult = await command.ExecuteAsync( request ); - return commandResult.ToResult( resultMapper ); + if ( cr.Context.IsError && cr.Context.Exception is DbUpdateConcurrencyException ) + return Results.Conflict( "Version conflict. Reload and retry." ); + return null; } ); ``` @@ -108,5 +126,7 @@ RFC 7807 Problem Details responses: | `NotFoundValidationFailure` | 404 Not Found | | `UnauthorizedValidationFailure` | 401 Unauthorized | | `ForbiddenValidationFailure` | 403 Forbidden | -| `ApplicationValidationFailure` | 400 Bad Request | -| `ValidationFailure` | 400 Bad Request | +| `ApplicationValidationFailure` | 422 Unprocessable Entity | +| `ValidationFailure` | 422 Unprocessable Entity | + +Override `GetStatusCode` on your `ResultMapper` subclass to customize these mappings. diff --git a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs new file mode 100644 index 0000000..6edb0db --- /dev/null +++ b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs @@ -0,0 +1,212 @@ +using System.Reflection; +using Hyperbee.Pipeline.Commands; +using Hyperbee.Pipeline.Context; +using Hyperbee.Pipeline.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace Hyperbee.Pipeline.AspNetCore; + +/// +/// Base class for mapping instances to . +/// Override virtual methods to customize error handling, status codes, and success responses. +/// +public class ResultMapper +{ + /// + /// Default mapper instance used when no custom mapper is provided. + /// + public static ResultMapper Default { get; } = new(); + + /// + /// Maps an exception to an . Return to rethrow + /// the exception as a . + /// + /// The exception from the pipeline context. + /// An for the error, or to rethrow. + public virtual IResult? MapException( Exception exception ) + { + return Results.Problem( + detail: "An unexpected error occurred.", + statusCode: StatusCodes.Status500InternalServerError, + title: ReasonPhrases.GetReasonPhrase( StatusCodes.Status500InternalServerError ) + ); + } + + /// + /// Returns the HTTP status code for a validation failure. Override to customize + /// status code mapping for specific failure types. + /// + /// The validation failure. + /// The HTTP status code to use. + public virtual int GetStatusCode( IValidationFailure failure ) => failure switch + { + NotFoundValidationFailure => StatusCodes.Status404NotFound, + ForbiddenValidationFailure => StatusCodes.Status403Forbidden, + UnauthorizedValidationFailure => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status422UnprocessableEntity + }; + + /// + /// Maps validation failures to an . Override to customize + /// the ProblemDetails structure or error response format. + /// + /// The validation failures. + /// The HTTP status code determined by . + /// An representing the validation error response. + public virtual IResult MapValidationFailures( IEnumerable failures, int statusCode ) + { + var errors = failures.Select( CreateErrorObject ).ToList(); + + return Results.Problem( + detail: "One or more validation errors occurred.", + statusCode: statusCode, + title: ReasonPhrases.GetReasonPhrase( statusCode ), + extensions: new Dictionary { ["errors"] = errors } + ); + + static Dictionary CreateErrorObject( IValidationFailure failure ) + { + var errorDict = new Dictionary(); + + if ( !string.IsNullOrWhiteSpace( failure.PropertyName ) ) + errorDict["propertyName"] = failure.PropertyName; + + if ( !string.IsNullOrWhiteSpace( failure.ErrorMessage ) ) + errorDict["errorMessage"] = failure.ErrorMessage; + + if ( !string.IsNullOrWhiteSpace( failure.ErrorCode ) ) + errorDict["errorCode"] = failure.ErrorCode; + + if ( failure.AttemptedValue != null ) + errorDict["attemptedValue"] = failure.AttemptedValue.ToString() ?? string.Empty; + + return errorDict; + } + } + + /// + /// Maps a cancellation to an . Return + /// to fall through to success handling. + /// + /// The cancellation value from the pipeline context. + /// An for the cancellation, or . + public virtual IResult? MapCancellation( object? cancellationValue ) + { + return cancellationValue as IResult; + } + + /// + /// Maps a successful result to an . Override to customize + /// the success response format. + /// + /// The type of the result value. + /// The result value. + /// An representing the success response. + public virtual IResult MapSuccess( T result ) + { + return result is null ? Results.NotFound() : Results.Ok( result ); + } + + /// + /// Creates a from lambda functions. Any lambda not provided + /// falls back to the default behavior. Useful for one-off customizations without subclassing. + /// + public static ResultMapper Create( + Func? mapException = null, + Func? getStatusCode = null, + Func, int, IResult>? mapValidationFailures = null, + Func? mapCancellation = null + ) + { + return new LambdaResultMapper( mapException, getStatusCode, mapValidationFailures, mapCancellation ); + } + + /// + /// Orchestrates the mapping of a through the error handling + /// pipeline, calling the appropriate virtual methods. This method is called by the + /// ToResult extension methods. + /// + internal IResult HandleCommand( IPipelineContext context, MemberInfo commandType, Func onSuccess ) + { + // 1. Validation failures + if ( !context.IsValid() ) + { + var failures = context.ValidationFailures().ToList(); + + // Determine status code using priority: most specific wins + var statusCode = GetPriorityStatusCode( failures ); + + return MapValidationFailures( failures, statusCode ); + } + + // 2. Cancellation + if ( context.IsCanceled ) + { + var cancellationResult = MapCancellation( context.CancellationValue ); + + if ( cancellationResult != null ) + { + context.Logger?.LogDebug( + "Command [name={Name}, action={Action}, cancellationResult={@CancellationResult}]", + commandType.Name, + "Canceled", + cancellationResult + ); + return cancellationResult; + } + } + + // 3. Exceptions + if ( context.IsError ) + { + var exceptionResult = MapException( context.Exception ); + + if ( exceptionResult is null ) + throw new CommandException( $"Command {commandType.Name} failed", context.Exception ); + + return exceptionResult; + } + + // 4. Success + return onSuccess(); + } + + private int GetPriorityStatusCode( List failures ) + { + // Priority order matches the original waterfall: 404 > 403 > 401 > default + var statusCodes = failures.Select( GetStatusCode ).Distinct().ToList(); + + if ( statusCodes.Contains( StatusCodes.Status404NotFound ) ) + return StatusCodes.Status404NotFound; + + if ( statusCodes.Contains( StatusCodes.Status403Forbidden ) ) + return StatusCodes.Status403Forbidden; + + if ( statusCodes.Contains( StatusCodes.Status401Unauthorized ) ) + return StatusCodes.Status401Unauthorized; + + return statusCodes.FirstOrDefault( StatusCodes.Status422UnprocessableEntity ); + } + + private sealed class LambdaResultMapper( + Func? mapException, + Func? getStatusCode, + Func, int, IResult>? mapValidationFailures, + Func? mapCancellation + ) : ResultMapper + { + public override IResult? MapException( Exception exception ) + => mapException != null ? mapException( exception ) : base.MapException( exception ); + + public override int GetStatusCode( IValidationFailure failure ) + => getStatusCode != null ? getStatusCode( failure ) : base.GetStatusCode( failure ); + + public override IResult MapValidationFailures( IEnumerable failures, int statusCode ) + => mapValidationFailures != null ? mapValidationFailures( failures, statusCode ) : base.MapValidationFailures( failures, statusCode ); + + public override IResult? MapCancellation( object? cancellationValue ) + => mapCancellation != null ? mapCancellation( cancellationValue ) : base.MapCancellation( cancellationValue ); + } +} diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs index 7b6724e..3d5c487 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs @@ -1,4 +1,5 @@ -using Hyperbee.Pipeline.AspNetCore.Extensions; +using Hyperbee.Pipeline.AspNetCore; +using Hyperbee.Pipeline.AspNetCore.Extensions; using Hyperbee.Pipeline.Commands; using Hyperbee.Pipeline.Context; using Hyperbee.Pipeline.Validation; @@ -71,6 +72,16 @@ public void ToResult_should_return_ok_for_valid_result() Assert.IsInstanceOfType( result, typeof( Ok ) ); } + [TestMethod] + public void ToResult_should_return_not_found_for_null_result() + { + var commandResult = CreateSuccessResult( null ); + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( NotFound ) ); + } + [TestMethod] public void ToResult_should_return_404_for_not_found_failure() { @@ -114,7 +125,7 @@ public void ToResult_should_return_401_for_unauthorized_failure() } [TestMethod] - public void ToResult_should_return_400_for_application_failure() + public void ToResult_should_return_422_for_application_failure() { var commandResult = CreateFailureResult( new ApplicationValidationFailure( "Field", "Invalid." ) @@ -124,11 +135,11 @@ public void ToResult_should_return_400_for_application_failure() Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); var problem = (ProblemHttpResult) result; - Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); } [TestMethod] - public void ToResult_should_return_400_for_general_validation_failure() + public void ToResult_should_return_422_for_general_validation_failure() { var commandResult = CreateFailureResult( new ValidationFailure( "Name", "Name is required." ) @@ -138,13 +149,13 @@ public void ToResult_should_return_400_for_general_validation_failure() Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); var problem = (ProblemHttpResult) result; - Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); } - // ToResult with selector tests + // ToResult with contentSelector tests [TestMethod] - public void ToResult_with_selector_should_transform_result() + public void ToResult_with_contentSelector_should_transform_result() { var commandResult = CreateSuccessResult( "hello" ); @@ -155,7 +166,7 @@ public void ToResult_with_selector_should_transform_result() } [TestMethod] - public void ToResult_with_selector_should_return_not_found_for_null() + public void ToResult_with_contentSelector_should_return_not_found_for_null() { var commandResult = CreateSuccessResult( "hello" ); @@ -165,7 +176,7 @@ public void ToResult_with_selector_should_return_not_found_for_null() } [TestMethod] - public void ToResult_with_selector_should_return_error_for_invalid() + public void ToResult_with_contentSelector_should_return_error_for_invalid() { var commandResult = CreateFailureResult( new NotFoundValidationFailure( "Item", "Not found." ) @@ -178,6 +189,16 @@ public void ToResult_with_selector_should_return_error_for_invalid() Assert.AreEqual( StatusCodes.Status404NotFound, problem.StatusCode ); } + [TestMethod] + public void ToResult_with_contentSelector_returning_IResult_should_passthrough() + { + var commandResult = CreateSuccessResult( "hello" ); + + var result = commandResult.ToResult( _ => Results.NoContent() ); + + Assert.IsInstanceOfType( result, typeof( NoContent ) ); + } + // ToResult non-generic tests [TestMethod] @@ -205,7 +226,7 @@ public void ToResult_non_generic_should_return_404_for_not_found() } [TestMethod] - public void ToResult_non_generic_should_return_400_for_validation_failure() + public void ToResult_non_generic_should_return_422_for_validation_failure() { var commandResult = CreateNonGenericFailureResult( new ValidationFailure( "Name", "Required." ) @@ -215,7 +236,7 @@ public void ToResult_non_generic_should_return_400_for_validation_failure() Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); var problem = (ProblemHttpResult) result; - Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); } // ToFileResult tests @@ -263,8 +284,6 @@ public void ToFileResult_should_return_404_for_not_found_failure() [TestMethod] public void ToResult_should_return_error_details_for_adapter_validation_failures() { - // Arrange - simulate failures from a validation adapter (e.g., FluentValidationFailureAdapter) - // that implements IValidationFailure but does NOT inherit from ValidationFailure. var commandResult = CreateAdapterFailureResult( new AdapterValidationFailure( "BasePrice", "Base price must be greater than zero." ) { @@ -274,13 +293,11 @@ public void ToResult_should_return_error_details_for_adapter_validation_failures new AdapterValidationFailure( "RatePlanConfiguration.RatePlans", "Rate plans must contain at least one tier starting at 0." ) ); - // Act var result = commandResult.ToResult(); - // Assert Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); var problem = (ProblemHttpResult) result; - Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode ); Assert.AreEqual( "One or more validation errors occurred.", problem.ProblemDetails.Detail ); Assert.IsTrue( @@ -293,6 +310,34 @@ public void ToResult_should_return_error_details_for_adapter_validation_failures Assert.HasCount( 2, errors ); } + // Custom mapper tests + + [TestMethod] + public void ToResult_with_custom_mapper_should_use_custom_status_code() + { + var customMapper = new Custom422To400Mapper(); + var commandResult = CreateFailureResult( + new ApplicationValidationFailure( "Field", "Invalid." ) + ); + + var result = commandResult.ToResult( customMapper ); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); + } + + [TestMethod] + public void ToResult_with_contentSelector_and_mapper_should_compose() + { + var commandResult = CreateSuccessResult( "hello" ); + + var result = commandResult.ToResult( s => new { Value = s.ToUpper() }, ResultMapper.Default ); + + Assert.IsTrue( result.GetType().IsGenericType ); + Assert.AreEqual( "Ok`1", result.GetType().GetGenericTypeDefinition().Name ); + } + private static CommandResult CreateAdapterFailureResult( params IValidationFailure[] failures ) { var context = new PipelineContext(); @@ -307,10 +352,6 @@ private static CommandResult CreateAdapterFailureResult( params IValidatio }; } - /// - /// Simulates a validation adapter failure (like FluentValidationFailureAdapter) - /// that implements IValidationFailure without inheriting from ValidationFailure. - /// private class AdapterValidationFailure( string propertyName, string errorMessage ) : IValidationFailure { public string PropertyName { get; set; } = propertyName; @@ -318,4 +359,15 @@ private class AdapterValidationFailure( string propertyName, string errorMessage public string? ErrorCode { get; set; } public object? AttemptedValue { get; set; } } + + private class Custom422To400Mapper : ResultMapper + { + public override int GetStatusCode( IValidationFailure failure ) => failure switch + { + NotFoundValidationFailure => StatusCodes.Status404NotFound, + ForbiddenValidationFailure => StatusCodes.Status403Forbidden, + UnauthorizedValidationFailure => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs new file mode 100644 index 0000000..16f2247 --- /dev/null +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs @@ -0,0 +1,96 @@ +using Hyperbee.Pipeline.AspNetCore.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; + +namespace Hyperbee.Pipeline.AspNetCore.Tests; + +[TestClass] +public class ResultExtensionsTests +{ + // WithHeader tests + + [TestMethod] + public async Task WithHeader_should_set_response_header() + { + var inner = Results.Ok( "hello" ); + var result = inner.WithHeader( "ETag", "\"abc123\"" ); + + var httpContext = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( "\"abc123\"", httpContext.Response.Headers["ETag"].ToString() ); + } + + [TestMethod] + public void WithHeader_should_return_original_result_for_null_value() + { + var inner = Results.Ok( "hello" ); + var result = inner.WithHeader( "ETag", null ); + + Assert.AreSame( inner, result ); + } + + [TestMethod] + public void WithHeader_should_return_original_result_for_empty_value() + { + var inner = Results.Ok( "hello" ); + var result = inner.WithHeader( "ETag", "" ); + + Assert.AreSame( inner, result ); + } + + [TestMethod] + public async Task WithHeader_should_chain_multiple_headers() + { + var result = Results.Ok( "hello" ) + .WithHeader( "ETag", "\"abc\"" ) + .WithHeader( "X-Custom", "test" ); + + var httpContext = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( "\"abc\"", httpContext.Response.Headers["ETag"].ToString() ); + Assert.AreEqual( "test", httpContext.Response.Headers["X-Custom"].ToString() ); + } + + // WithNoContent tests + + [TestMethod] + public void WithNoContent_should_return_no_content_result() + { + var inner = Results.Ok( "hello" ); + var result = inner.WithNoContent(); + + Assert.IsInstanceOfType( result, typeof( NoContent ) ); + } + + [TestMethod] + public async Task WithNoContent_should_preserve_headers_from_prior_decorator() + { + var result = Results.Ok( "hello" ) + .WithHeader( "X-Trace", "abc123" ) + .WithNoContent(); + + var httpContext = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( "abc123", httpContext.Response.Headers["X-Trace"].ToString() ); + Assert.AreEqual( StatusCodes.Status204NoContent, httpContext.Response.StatusCode ); + } +} diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs index 785570c..8ff91b8 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs @@ -1,6 +1,8 @@ -using Hyperbee.Pipeline.AspNetCore.Extensions; +using Hyperbee.Pipeline.AspNetCore; +using Hyperbee.Pipeline.AspNetCore.Extensions; using Hyperbee.Pipeline.Commands; using Hyperbee.Pipeline.Context; +using Hyperbee.Pipeline.Validation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -40,10 +42,119 @@ private static CommandResult CreateSuccessResult( T value ) }; } - // Generic ToResult with resultMapper + // ResultMapper.Default behavior [TestMethod] - public void ToResult_with_resultMapper_should_return_mapped_result_on_error() + public void Default_MapException_should_return_problem_details_500() + { + var commandResult = CreateErrorResult( + new InvalidOperationException( "something broke" ) + ); + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status500InternalServerError, problem.StatusCode ); + } + + [TestMethod] + public void Custom_MapException_returning_null_should_throw_CommandException() + { + var mapper = new RethrowMapper(); + var commandResult = CreateErrorResult( + new InvalidOperationException( "something broke" ) + ); + + Assert.ThrowsExactly( () => commandResult.ToResult( mapper ) ); + } + + [TestMethod] + public void Custom_MapException_should_handle_specific_exceptions() + { + var mapper = new ConflictMapper(); + var commandResult = CreateErrorResult( + new InvalidOperationException( "version conflict detected" ) + ); + + var result = commandResult.ToResult( mapper ); + + Assert.IsInstanceOfType( result, typeof( Conflict ) ); + } + + [TestMethod] + public void Custom_MapException_should_fall_through_to_base_for_unhandled() + { + var mapper = new ConflictMapper(); + var commandResult = CreateErrorResult( + new ArgumentException( "bad arg" ) + ); + + var result = commandResult.ToResult( mapper ); + + // Falls through to base.MapException which returns ProblemDetails 500 + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status500InternalServerError, problem.StatusCode ); + } + + // GetStatusCode defaults + + [TestMethod] + public void Default_GetStatusCode_should_return_404_for_NotFoundValidationFailure() + { + var failure = new NotFoundValidationFailure( "Item", "Not found." ); + Assert.AreEqual( StatusCodes.Status404NotFound, ResultMapper.Default.GetStatusCode( failure ) ); + } + + [TestMethod] + public void Default_GetStatusCode_should_return_403_for_ForbiddenValidationFailure() + { + var failure = new ForbiddenValidationFailure( "Resource", "Denied." ); + Assert.AreEqual( StatusCodes.Status403Forbidden, ResultMapper.Default.GetStatusCode( failure ) ); + } + + [TestMethod] + public void Default_GetStatusCode_should_return_401_for_UnauthorizedValidationFailure() + { + var failure = new UnauthorizedValidationFailure( "User", "Unauthenticated." ); + Assert.AreEqual( StatusCodes.Status401Unauthorized, ResultMapper.Default.GetStatusCode( failure ) ); + } + + [TestMethod] + public void Default_GetStatusCode_should_return_422_for_ApplicationValidationFailure() + { + var failure = new ApplicationValidationFailure( "Field", "Invalid." ); + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, ResultMapper.Default.GetStatusCode( failure ) ); + } + + [TestMethod] + public void Default_GetStatusCode_should_return_422_for_base_ValidationFailure() + { + var failure = new ValidationFailure( "Field", "Required." ); + Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, ResultMapper.Default.GetStatusCode( failure ) ); + } + + // MapSuccess defaults + + [TestMethod] + public void Default_MapSuccess_should_return_ok_for_non_null() + { + var result = ResultMapper.Default.MapSuccess( "hello" ); + Assert.IsInstanceOfType( result, typeof( Ok ) ); + } + + [TestMethod] + public void Default_MapSuccess_should_return_not_found_for_null() + { + var result = ResultMapper.Default.MapSuccess( null ); + Assert.IsInstanceOfType( result, typeof( NotFound ) ); + } + + // Generic ToResult with Func resultMapper + + [TestMethod] + public void ToResult_with_func_resultMapper_should_return_mapped_result_on_error() { var commandResult = CreateErrorResult( new InvalidOperationException( "version conflict detected" ) @@ -61,7 +172,7 @@ public void ToResult_with_resultMapper_should_return_mapped_result_on_error() } [TestMethod] - public void ToResult_with_resultMapper_should_fall_through_to_default_on_success() + public void ToResult_with_func_resultMapper_should_fall_through_to_default_on_success() { var commandResult = CreateSuccessResult( "hello" ); @@ -71,7 +182,7 @@ public void ToResult_with_resultMapper_should_fall_through_to_default_on_success } [TestMethod] - public void ToResult_with_resultMapper_should_allow_success_override() + public void ToResult_with_func_resultMapper_should_allow_success_override() { var commandResult = CreateSuccessResult( "hello" ); @@ -85,152 +196,252 @@ public void ToResult_with_resultMapper_should_allow_success_override() Assert.IsInstanceOfType( result, typeof( NoContent ) ); } + // Non-generic ToResult with Func resultMapper + [TestMethod] - public void ToResult_with_resultMapper_should_handle_combined_error_and_success() + public void ToResult_non_generic_with_func_resultMapper_should_return_mapped_result() { - var successResult = CreateSuccessResult( "hello" ); - var errorResult = CreateErrorResult( + var commandResult = CreateNonGenericErrorResult( new InvalidOperationException( "version conflict" ) ); - IResult? Mapper( CommandResult cr ) + var result = commandResult.ToResult( cr => { if ( cr.Context.IsError && cr.Context.Exception is InvalidOperationException ) return Results.Conflict( "Version conflict." ); - if ( cr.Context.Success ) - return Results.NoContent(); return null; - } + } ); + + Assert.IsInstanceOfType( result, typeof( Conflict ) ); + } - var successIResult = successResult.ToResult( Mapper ); - var errorIResult = errorResult.ToResult( Mapper ); + [TestMethod] + public void ToResult_non_generic_with_func_resultMapper_should_fall_through_on_success() + { + var commandResult = new CommandResult + { + Context = new PipelineContext(), + CommandType = typeof( ResultMapperTests ) + }; - Assert.IsInstanceOfType( successIResult, typeof( NoContent ) ); - Assert.IsInstanceOfType( errorIResult, typeof( Conflict ) ); + var result = commandResult.ToResult( cr => null ); + + Assert.IsInstanceOfType( result, typeof( Ok ) ); } + // ResultMapper subclass tests + [TestMethod] - public void ToResult_with_resultMapper_should_fall_through_to_default_error_handling() + public void ToResult_with_ResultMapper_should_return_mapped_result_on_error() { var commandResult = CreateErrorResult( - new InvalidOperationException( "some other error" ) + new InvalidOperationException( "version conflict detected" ) ); + var mapper = new ConflictMapper(); - // Mapper doesn't handle this error type — returns null, so default handling throws - try - { - commandResult.ToResult( cr => - { - if ( cr.Context.Exception is ArgumentException ) - return Results.Conflict( "Handled." ); - return null; - } ); - Assert.Fail( "Expected CommandException to be thrown." ); - } - catch ( CommandException ) - { - // Expected - } + var result = commandResult.ToResult( mapper ); + + Assert.IsInstanceOfType( result, typeof( Conflict ) ); } - // Non-generic ToResult with resultMapper + [TestMethod] + public void ToResult_with_ResultMapper_should_fall_through_to_default_on_success() + { + var commandResult = CreateSuccessResult( "hello" ); + var mapper = new ConflictMapper(); + + var result = commandResult.ToResult( mapper ); + + Assert.IsInstanceOfType( result, typeof( Ok ) ); + } [TestMethod] - public void ToResult_non_generic_with_resultMapper_should_return_mapped_result() + public void ToResult_non_generic_with_ResultMapper_should_return_mapped_result() { var commandResult = CreateNonGenericErrorResult( - new InvalidOperationException( "version conflict" ) + new InvalidOperationException( "version conflict detected" ) ); + var mapper = new ConflictMapper(); - var result = commandResult.ToResult( cr => - { - if ( cr.Context.IsError && cr.Context.Exception is InvalidOperationException ) - return Results.Conflict( "Version conflict." ); - return null; - } ); + var result = commandResult.ToResult( mapper ); Assert.IsInstanceOfType( result, typeof( Conflict ) ); } [TestMethod] - public void ToResult_non_generic_with_resultMapper_should_fall_through_on_success() + public void ToResult_non_generic_with_ResultMapper_should_fall_through_on_success() { var commandResult = new CommandResult { Context = new PipelineContext(), CommandType = typeof( ResultMapperTests ) }; + var mapper = new ConflictMapper(); - var result = commandResult.ToResult( cr => null ); + var result = commandResult.ToResult( mapper ); Assert.IsInstanceOfType( result, typeof( Ok ) ); } - // IResultMapper interface tests + // Cancellation tests - private class ConflictResultMapper : IResultMapper + [TestMethod] + public void ToResult_should_return_cancellation_value_when_canceled_with_IResult() { - public IResult? Map( CommandResult commandResult ) + var context = new PipelineContext(); + context.CancelAfter( Results.StatusCode( StatusCodes.Status408RequestTimeout ) ); + var commandResult = new CommandResult { - if ( commandResult.Context.IsError && - commandResult.Context.Exception is InvalidOperationException ex && - ex.Message.Contains( "version conflict" ) ) - { - return Results.Conflict( "Version conflict. Reload and retry." ); - } + Context = context, + Result = "hello", + CommandType = typeof( ResultMapperTests ) + }; - return null; - } + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( StatusCodeHttpResult ) ); } [TestMethod] - public void ToResult_with_IResultMapper_should_return_mapped_result_on_error() + public void ToResult_should_fall_through_to_success_when_canceled_without_IResult() { - var commandResult = CreateErrorResult( - new InvalidOperationException( "version conflict detected" ) - ); - var mapper = new ConflictResultMapper(); + var context = new PipelineContext(); + context.CancelAfter( "not an IResult" ); + var commandResult = new CommandResult + { + Context = context, + Result = "hello", + CommandType = typeof( ResultMapperTests ) + }; + + // MapCancellation returns null for non-IResult, falls through to success + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( Ok ) ); + } + + [TestMethod] + public void Custom_MapCancellation_should_override_default() + { + var mapper = new TimeoutCancellationMapper(); + var context = new PipelineContext(); + context.CancelAfter(); + var commandResult = new CommandResult + { + Context = context, + Result = "hello", + CommandType = typeof( ResultMapperTests ) + }; var result = commandResult.ToResult( mapper ); - Assert.IsInstanceOfType( result, typeof( Conflict ) ); + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status408RequestTimeout, problem.StatusCode ); } + // Validation priority tests + [TestMethod] - public void ToResult_with_IResultMapper_should_fall_through_to_default_on_success() + public void ToResult_should_use_highest_priority_status_code_for_mixed_failures() { - var commandResult = CreateSuccessResult( "hello" ); - var mapper = new ConflictResultMapper(); + var context = new PipelineContext(); + var failures = new List + { + new ApplicationValidationFailure( "Field", "Invalid." ), + new NotFoundValidationFailure( "Item", "Not found." ) + }; + context.SetValidationResult( + (IReadOnlyList) failures, + ValidationAction.CancelAfter + ); + var commandResult = new CommandResult + { + Context = context, + CommandType = typeof( ResultMapperTests ) + }; + + var result = commandResult.ToResult(); + + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status404NotFound, problem.StatusCode ); + } + + // Custom MapValidationFailures override + + [TestMethod] + public void Custom_MapValidationFailures_should_override_problem_details_format() + { + var mapper = new CustomValidationMapper(); + var context = new PipelineContext(); + context.SetValidationResult( + (IReadOnlyList) new List + { + new( "Name", "Required." ) + }, + ValidationAction.CancelAfter + ); + var commandResult = new CommandResult + { + Context = context, + CommandType = typeof( ResultMapperTests ) + }; var result = commandResult.ToResult( mapper ); - Assert.IsInstanceOfType( result, typeof( Ok ) ); + Assert.IsInstanceOfType( result, typeof( JsonHttpResult ) ); } + // ContentSelector + mapper on error path + [TestMethod] - public void ToResult_with_IResultMapper_should_fall_through_on_unhandled_error() + public void ToResult_with_contentSelector_and_mapper_should_use_mapper_on_error() { - var commandResult = CreateErrorResult( new ArgumentException( "bad arg" ) ); - var mapper = new ConflictResultMapper(); + var mapper = new ConflictMapper(); + var commandResult = CreateErrorResult( + new InvalidOperationException( "version conflict detected" ) + ); + + var result = commandResult.ToResult( s => new { Value = s }, mapper ); + + Assert.IsInstanceOfType( result, typeof( Conflict ) ); + } + + // ToFileResult with custom mapper - try + [TestMethod] + public void ToFileResult_with_custom_mapper_should_use_mapper_on_error() + { + var mapper = new ConflictMapper(); + var context = new PipelineContext { - commandResult.ToResult( mapper ); - Assert.Fail( "Expected CommandException to be thrown." ); - } - catch ( CommandException ) + Exception = new InvalidOperationException( "version conflict detected" ) + }; + var commandResult = new CommandResult { - // Expected — mapper returned null, default handling throws - } + Context = context, + CommandType = typeof( ResultMapperTests ) + }; + + var result = commandResult.ToFileResult( "application/pdf", mapper ); + + Assert.IsInstanceOfType( result, typeof( Conflict ) ); } + // ResultMapper.Create lambda factory tests + [TestMethod] - public void ToResult_non_generic_with_IResultMapper_should_return_mapped_result() + public void Create_with_mapException_should_handle_specific_exception() { - var commandResult = CreateNonGenericErrorResult( - new InvalidOperationException( "version conflict detected" ) + var mapper = ResultMapper.Create( + mapException: ex => ex is InvalidOperationException + ? Results.Conflict( "Conflict." ) + : null + ); + var commandResult = CreateErrorResult( + new InvalidOperationException( "conflict" ) ); - var mapper = new ConflictResultMapper(); var result = commandResult.ToResult( mapper ); @@ -238,17 +449,89 @@ public void ToResult_non_generic_with_IResultMapper_should_return_mapped_result( } [TestMethod] - public void ToResult_non_generic_with_IResultMapper_should_fall_through_on_success() + public void Create_with_mapException_returning_null_should_rethrow() { - var commandResult = new CommandResult + var mapper = ResultMapper.Create( + mapException: _ => null + ); + var commandResult = CreateErrorResult( + new InvalidOperationException( "boom" ) + ); + + Assert.ThrowsExactly( () => commandResult.ToResult( mapper ) ); + } + + [TestMethod] + public void Create_with_no_lambdas_should_use_defaults() + { + var mapper = ResultMapper.Create(); + var commandResult = CreateSuccessResult( "hello" ); + + var result = commandResult.ToResult( mapper ); + + Assert.IsInstanceOfType( result, typeof( Ok ) ); + } + + [TestMethod] + public void Create_with_getStatusCode_should_override_status() + { + var mapper = ResultMapper.Create( + getStatusCode: _ => StatusCodes.Status400BadRequest + ); + var context = new PipelineContext(); + context.SetValidationResult( + (IReadOnlyList) new List + { + new( "Field", "Bad." ) + }, + ValidationAction.CancelAfter + ); + var commandResult = new CommandResult { - Context = new PipelineContext(), + Context = context, CommandType = typeof( ResultMapperTests ) }; - var mapper = new ConflictResultMapper(); var result = commandResult.ToResult( mapper ); - Assert.IsInstanceOfType( result, typeof( Ok ) ); + Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) ); + var problem = (ProblemHttpResult) result; + Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); + } + + // Test helpers + + private class TimeoutCancellationMapper : ResultMapper + { + public override IResult? MapCancellation( object? cancellationValue ) + { + return Results.Problem( + detail: "The operation was canceled.", + statusCode: StatusCodes.Status408RequestTimeout + ); + } + } + + private class CustomValidationMapper : ResultMapper + { + public override IResult MapValidationFailures( IEnumerable failures, int statusCode ) + { + return Results.Json( (object) new { errors = failures.Select( f => f.ErrorMessage ).ToList() }, statusCode: statusCode ); + } + } + + private class RethrowMapper : ResultMapper + { + public override IResult? MapException( Exception exception ) => null; + } + + private class ConflictMapper : ResultMapper + { + public override IResult? MapException( Exception exception ) => exception switch + { + InvalidOperationException ex when ex.Message.Contains( "version conflict" ) + => Results.Conflict( "Version conflict. Reload and retry." ), + _ => base.MapException( exception ) + }; } } From a48a8bdd15cbf7a6de4afb47dfc9e72640fe9d2f Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 19 Mar 2026 17:52:36 +0000 Subject: [PATCH 2/5] chore: format code with dotnet format --- src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs | 2 +- .../Extensions/CommandResultExtensions.cs | 2 +- src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs | 2 +- src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs | 2 +- .../CommandResultExtensionsTests.cs | 2 +- .../Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs | 2 +- test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs index 78242fd..3c25f68 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; namespace Hyperbee.Pipeline.AspNetCore; diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs index 8614f3a..43d4133 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs @@ -1,4 +1,4 @@ -using Hyperbee.Pipeline.Commands; +using Hyperbee.Pipeline.Commands; using Microsoft.AspNetCore.Http; namespace Hyperbee.Pipeline.AspNetCore.Extensions; diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs index 1f6845f..f8c3a23 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; namespace Hyperbee.Pipeline.AspNetCore.Extensions; diff --git a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs index 6edb0db..99303a2 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using Hyperbee.Pipeline.Commands; using Hyperbee.Pipeline.Context; using Hyperbee.Pipeline.Validation; diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs index 3d5c487..6fcbeb6 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/CommandResultExtensionsTests.cs @@ -1,4 +1,4 @@ -using Hyperbee.Pipeline.AspNetCore; +using Hyperbee.Pipeline.AspNetCore; using Hyperbee.Pipeline.AspNetCore.Extensions; using Hyperbee.Pipeline.Commands; using Hyperbee.Pipeline.Context; diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs index 16f2247..11be174 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs @@ -1,4 +1,4 @@ -using Hyperbee.Pipeline.AspNetCore.Extensions; +using Hyperbee.Pipeline.AspNetCore.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.DependencyInjection; diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs index 8ff91b8..c0626f1 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs @@ -1,4 +1,4 @@ -using Hyperbee.Pipeline.AspNetCore; +using Hyperbee.Pipeline.AspNetCore; using Hyperbee.Pipeline.AspNetCore.Extensions; using Hyperbee.Pipeline.Commands; using Hyperbee.Pipeline.Context; From fe42895be75bff81c357e8b206a3bfb55fdf9103 Mon Sep 17 00:00:00 2001 From: Brenton Farmer Date: Thu, 19 Mar 2026 10:57:59 -0700 Subject: [PATCH 3/5] fix: production-grade quality improvements to ResultMapper - DecoratedResult now flattens decorator chains so WithHeader can be called any number of times without nesting - WithNoContent preserves all previously chained headers, not just the last one - GetPriorityStatusCode is now public virtual and respects custom status codes from overridden GetStatusCode (uses Min for unknown codes) - Non-generic ToResult now delegates to virtual MapSuccess() for consistency with generic overloads - Added 68 AspNetCore tests covering all code paths Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DecoratedResult.cs | 36 ++++-- .../Extensions/CommandResultExtensions.cs | 2 +- .../Extensions/ResultExtensions.cs | 9 +- .../ResultMapper.cs | 24 +++- .../ResultExtensionsTests.cs | 112 ++++++++++++++---- .../ResultMapperTests.cs | 101 ++++++++++++++++ 6 files changed, 244 insertions(+), 40 deletions(-) diff --git a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs index 3c25f68..a3d87f3 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs @@ -3,18 +3,40 @@ namespace Hyperbee.Pipeline.AspNetCore; /// -/// Wraps an with an action that decorates the -/// before the inner result executes. Used by and -/// to chain response customizations. +/// Wraps an with a list of actions that decorate the +/// before the inner result executes. Supports composing multiple decorators via chaining. /// -internal sealed class DecoratedResult( IResult inner, Action decorator ) : IResult +internal sealed class DecoratedResult : IResult { - internal IResult Inner { get; } = inner; - internal Action Decorator { get; } = decorator; + internal IResult Inner { get; } + internal List> Decorators { get; } + + internal DecoratedResult( IResult inner, Action decorator ) + { + // Flatten: if inner is already a DecoratedResult, absorb its decorators + if ( inner is DecoratedResult existing ) + { + Inner = existing.Inner; + Decorators = [..existing.Decorators, decorator]; + } + else + { + Inner = inner; + Decorators = [decorator]; + } + } + + internal DecoratedResult( IResult inner, List> decorators ) + { + Inner = inner; + Decorators = decorators; + } public async Task ExecuteAsync( HttpContext httpContext ) { - Decorator( httpContext ); + foreach ( var decorator in Decorators ) + decorator( httpContext ); + await Inner.ExecuteAsync( httpContext ); } } diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs index 43d4133..68eb62e 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs @@ -92,7 +92,7 @@ public static IResult ToResult( this CommandResult commandResult, ResultMapper? return mapper.HandleCommand( commandResult.Context, commandResult.CommandType, - () => Results.Ok() + mapper.MapSuccess ); } diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs index f8c3a23..1366b0d 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/ResultExtensions.cs @@ -9,7 +9,7 @@ public static class ResultExtensions { /// /// Adds an HTTP header to the response. If the value is null or whitespace, the - /// header is not added. + /// header is not added. Multiple headers can be chained. /// /// The result to decorate. /// The header name. @@ -24,16 +24,15 @@ public static IResult WithHeader( this IResult result, string name, string? valu } /// - /// Replaces the response with a 204 No Content result while preserving any + /// Replaces the response with a 204 No Content result while preserving all /// previously chained decorators (e.g., headers). /// /// The result to replace. - /// A 204 No Content with any prior decorators preserved. + /// A 204 No Content with all prior decorators preserved. public static IResult WithNoContent( this IResult result ) { - // If there are existing decorators, preserve them on top of NoContent if ( result is DecoratedResult decorated ) - return new DecoratedResult( Results.NoContent(), decorated.Decorator ); + return new DecoratedResult( Results.NoContent(), decorated.Decorators ); return Results.NoContent(); } diff --git a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs index 99303a2..94205a5 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs @@ -109,6 +109,16 @@ public virtual IResult MapSuccess( T result ) return result is null ? Results.NotFound() : Results.Ok( result ); } + /// + /// Maps a successful command with no result value to an . + /// Used by the non-generic ToResult overload. + /// + /// An representing the success response. + public virtual IResult MapSuccess() + { + return Results.Ok(); + } + /// /// Creates a from lambda functions. Any lambda not provided /// falls back to the default behavior. Useful for one-off customizations without subclassing. @@ -173,11 +183,18 @@ internal IResult HandleCommand( IPipelineContext context, MemberInfo commandType return onSuccess(); } - private int GetPriorityStatusCode( List failures ) + /// + /// Determines the single status code to use when multiple validation failures + /// produce different status codes. Override to change the priority logic. + /// The default priority is: 404 > 403 > 401 > lowest remaining code. + /// + /// The validation failures. + /// The status code to use for the response. + public virtual int GetPriorityStatusCode( IEnumerable failures ) { - // Priority order matches the original waterfall: 404 > 403 > 401 > default var statusCodes = failures.Select( GetStatusCode ).Distinct().ToList(); + // Well-known priority order if ( statusCodes.Contains( StatusCodes.Status404NotFound ) ) return StatusCodes.Status404NotFound; @@ -187,7 +204,8 @@ private int GetPriorityStatusCode( List failures ) if ( statusCodes.Contains( StatusCodes.Status401Unauthorized ) ) return StatusCodes.Status401Unauthorized; - return statusCodes.FirstOrDefault( StatusCodes.Status422UnprocessableEntity ); + // For any other codes (including custom ones), use the lowest + return statusCodes.DefaultIfEmpty( StatusCodes.Status422UnprocessableEntity ).Min(); } private sealed class LambdaResultMapper( diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs index 11be174..06b816a 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultExtensionsTests.cs @@ -8,20 +8,25 @@ namespace Hyperbee.Pipeline.AspNetCore.Tests; [TestClass] public class ResultExtensionsTests { - // WithHeader tests - - [TestMethod] - public async Task WithHeader_should_set_response_header() + private static HttpContext CreateHttpContext() { - var inner = Results.Ok( "hello" ); - var result = inner.WithHeader( "ETag", "\"abc123\"" ); - - var httpContext = new DefaultHttpContext + return new DefaultHttpContext { RequestServices = new ServiceCollection() .AddLogging() .BuildServiceProvider() }; + } + + // WithHeader tests + + [TestMethod] + public async Task WithHeader_should_set_response_header() + { + var result = Results.Ok( "hello" ) + .WithHeader( "ETag", "\"abc123\"" ); + + var httpContext = CreateHttpContext(); await result.ExecuteAsync( httpContext ); Assert.AreEqual( "\"abc123\"", httpContext.Response.Headers["ETag"].ToString() ); @@ -46,51 +51,110 @@ public void WithHeader_should_return_original_result_for_empty_value() } [TestMethod] - public async Task WithHeader_should_chain_multiple_headers() + public void WithHeader_should_return_original_result_for_whitespace_value() + { + var inner = Results.Ok( "hello" ); + var result = inner.WithHeader( "ETag", " " ); + + Assert.AreSame( inner, result ); + } + + [TestMethod] + public async Task WithHeader_should_chain_two_headers() { var result = Results.Ok( "hello" ) .WithHeader( "ETag", "\"abc\"" ) .WithHeader( "X-Custom", "test" ); - var httpContext = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider() - }; + var httpContext = CreateHttpContext(); await result.ExecuteAsync( httpContext ); Assert.AreEqual( "\"abc\"", httpContext.Response.Headers["ETag"].ToString() ); Assert.AreEqual( "test", httpContext.Response.Headers["X-Custom"].ToString() ); } + [TestMethod] + public async Task WithHeader_should_chain_three_headers() + { + var result = Results.Ok( "hello" ) + .WithHeader( "ETag", "\"v1\"" ) + .WithHeader( "X-Request-Id", "req-123" ) + .WithHeader( "Cache-Control", "no-cache" ); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( "\"v1\"", httpContext.Response.Headers["ETag"].ToString() ); + Assert.AreEqual( "req-123", httpContext.Response.Headers["X-Request-Id"].ToString() ); + Assert.AreEqual( "no-cache", httpContext.Response.Headers["Cache-Control"].ToString() ); + } + + [TestMethod] + public async Task WithHeader_should_skip_null_values_in_chain() + { + var result = Results.Ok( "hello" ) + .WithHeader( "ETag", "\"abc\"" ) + .WithHeader( "X-Skip", null ) + .WithHeader( "X-Custom", "kept" ); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( "\"abc\"", httpContext.Response.Headers["ETag"].ToString() ); + Assert.AreEqual( "", httpContext.Response.Headers["X-Skip"].ToString() ); + Assert.AreEqual( "kept", httpContext.Response.Headers["X-Custom"].ToString() ); + } + // WithNoContent tests [TestMethod] public void WithNoContent_should_return_no_content_result() { - var inner = Results.Ok( "hello" ); - var result = inner.WithNoContent(); + var result = Results.Ok( "hello" ).WithNoContent(); Assert.IsInstanceOfType( result, typeof( NoContent ) ); } [TestMethod] - public async Task WithNoContent_should_preserve_headers_from_prior_decorator() + public async Task WithNoContent_should_preserve_single_header() { var result = Results.Ok( "hello" ) .WithHeader( "X-Trace", "abc123" ) .WithNoContent(); - var httpContext = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider() - }; + var httpContext = CreateHttpContext(); await result.ExecuteAsync( httpContext ); Assert.AreEqual( "abc123", httpContext.Response.Headers["X-Trace"].ToString() ); Assert.AreEqual( StatusCodes.Status204NoContent, httpContext.Response.StatusCode ); } + + [TestMethod] + public async Task WithNoContent_should_preserve_all_chained_headers() + { + var result = Results.Ok( "hello" ) + .WithHeader( "ETag", "\"v1\"" ) + .WithHeader( "X-Request-Id", "req-456" ) + .WithHeader( "X-Correlation", "corr-789" ) + .WithNoContent(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( "\"v1\"", httpContext.Response.Headers["ETag"].ToString() ); + Assert.AreEqual( "req-456", httpContext.Response.Headers["X-Request-Id"].ToString() ); + Assert.AreEqual( "corr-789", httpContext.Response.Headers["X-Correlation"].ToString() ); + Assert.AreEqual( StatusCodes.Status204NoContent, httpContext.Response.StatusCode ); + } + + [TestMethod] + public async Task WithNoContent_on_plain_result_should_return_204() + { + var result = Results.Ok( "hello" ).WithNoContent(); + + var httpContext = CreateHttpContext(); + await result.ExecuteAsync( httpContext ); + + Assert.AreEqual( StatusCodes.Status204NoContent, httpContext.Response.StatusCode ); + } } diff --git a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs index c0626f1..7297d22 100644 --- a/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs +++ b/test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs @@ -499,8 +499,109 @@ public void Create_with_getStatusCode_should_override_status() Assert.AreEqual( StatusCodes.Status400BadRequest, problem.StatusCode ); } + // GetPriorityStatusCode tests + + [TestMethod] + public void GetPriorityStatusCode_should_pick_404_over_422() + { + var failures = new IValidationFailure[] + { + new ApplicationValidationFailure( "Field", "Invalid." ), + new NotFoundValidationFailure( "Item", "Not found." ) + }; + + var statusCode = ResultMapper.Default.GetPriorityStatusCode( failures ); + + Assert.AreEqual( StatusCodes.Status404NotFound, statusCode ); + } + + [TestMethod] + public void GetPriorityStatusCode_should_pick_403_over_401() + { + var failures = new IValidationFailure[] + { + new UnauthorizedValidationFailure( "User", "Unauthenticated." ), + new ForbiddenValidationFailure( "Resource", "Denied." ) + }; + + var statusCode = ResultMapper.Default.GetPriorityStatusCode( failures ); + + Assert.AreEqual( StatusCodes.Status403Forbidden, statusCode ); + } + + [TestMethod] + public void GetPriorityStatusCode_with_custom_mapper_should_respect_custom_codes() + { + var mapper = new Custom409Mapper(); + var failures = new IValidationFailure[] + { + new ApplicationValidationFailure( "Field", "Conflict." ) + }; + + var statusCode = mapper.GetPriorityStatusCode( failures ); + + Assert.AreEqual( StatusCodes.Status409Conflict, statusCode ); + } + + [TestMethod] + public void GetPriorityStatusCode_should_use_lowest_for_unknown_custom_codes() + { + var mapper = new Custom409Mapper(); + var failures = new IValidationFailure[] + { + new ApplicationValidationFailure( "A", "Conflict." ), + new ValidationFailure( "B", "Also conflict." ) + }; + + // Both map to 409 via Custom409Mapper + var statusCode = mapper.GetPriorityStatusCode( failures ); + + Assert.AreEqual( StatusCodes.Status409Conflict, statusCode ); + } + + // Non-generic MapSuccess override tests + + [TestMethod] + public void Non_generic_ToResult_should_use_MapSuccess() + { + var mapper = new NoContentSuccessMapper(); + var commandResult = new CommandResult + { + Context = new PipelineContext(), + CommandType = typeof( ResultMapperTests ) + }; + + var result = commandResult.ToResult( mapper ); + + Assert.IsInstanceOfType( result, typeof( NoContent ) ); + } + + [TestMethod] + public void Default_non_generic_MapSuccess_should_return_ok() + { + var result = ResultMapper.Default.MapSuccess(); + + Assert.IsInstanceOfType( result, typeof( Ok ) ); + } + // Test helpers + private class Custom409Mapper : ResultMapper + { + public override int GetStatusCode( IValidationFailure failure ) => failure switch + { + NotFoundValidationFailure => StatusCodes.Status404NotFound, + ForbiddenValidationFailure => StatusCodes.Status403Forbidden, + UnauthorizedValidationFailure => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status409Conflict + }; + } + + private class NoContentSuccessMapper : ResultMapper + { + public override IResult MapSuccess() => Results.NoContent(); + } + private class TimeoutCancellationMapper : ResultMapper { public override IResult? MapCancellation( object? cancellationValue ) From 344bac8531feb7452f3246203cc2160eb936a351 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 19 Mar 2026 17:59:48 +0000 Subject: [PATCH 4/5] chore: format code with dotnet format --- src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs index a3d87f3..b9cd7f8 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/DecoratedResult.cs @@ -17,7 +17,7 @@ internal DecoratedResult( IResult inner, Action decorator ) if ( inner is DecoratedResult existing ) { Inner = existing.Inner; - Decorators = [..existing.Decorators, decorator]; + Decorators = [.. existing.Decorators, decorator]; } else { From 74f3f080b4294fbd452d052415be92af37bfc3fd Mon Sep 17 00:00:00 2001 From: Brenton Farmer Date: Thu, 19 Mar 2026 11:02:09 -0700 Subject: [PATCH 5/5] refactor: remove unnecessary class constraint from contentSelector overload Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Extensions/CommandResultExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs index 68eb62e..b86649d 100644 --- a/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs +++ b/src/Hyperbee.Pipeline.AspNetCore/Extensions/CommandResultExtensions.cs @@ -61,7 +61,6 @@ public static IResult ToResult( Func contentSelector, ResultMapper? mapper = null ) - where TResult : class { mapper ??= ResultMapper.Default;