Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ Some key features are:
* Dependency injection
* Early returns and cancellation
* Child pipelines
* Declarative and imperative validation, with a FluentValidation adapter
* Command pattern with assembly scanning and DI registration
* ASP.NET Core integration with RFC 7807 result mapping (`ToResult()`)

## Why Use Pipelines

Expand All @@ -33,7 +36,7 @@ pipelines are not only robust and maintainable but also highly adaptable to chan

## Getting Started

To get started with Hyperbee.Json, refer to the [documentation](https://stillpoint-software.github.io/hyperbee.pipeline) for
To get started with Hyperbee.Pipeline, refer to the [documentation site](https://stillpoint-software.github.io/hyperbee.pipeline) for
detailed instructions and examples.

Install via NuGet:
Expand Down Expand Up @@ -139,6 +142,37 @@ These methods provide powerful functionality for manipulating data as it passes
- Reduce
- Parallel execution

## Validation and Error Handling

Pipelines validate their inputs declaratively, and commands surface failure state as data —
a command returns a result plus diagnostics instead of throwing:

```csharp
var pipeline = PipelineFactory
.Start<CreateUserRequest>()
.ValidateAsync() // FluentValidation or custom validators
.PipeAsync( ( ctx, request ) => CreateUser( request ) )
.Build();
```

Project the diagnostics at your boundary: `ToResult()` maps validation failures to RFC 7807
responses (422/404/403/401) at the HTTP edge, while `context.ThrowIfError()` and
`context.ThrowIfInvalid()` surface errors and validation failures to in-process callers. See the
[Validation documentation](https://stillpoint-software.github.io/hyperbee.pipeline/validation.html)
for details.

## Packages

| Package | Description |
|---------|-------------|
| `Hyperbee.Pipeline` | Core pipeline builders, commands, middleware, and context |
| `Hyperbee.Pipeline.Validation` | Framework-agnostic validation: `ValidateAsync`, failure types, `ThrowIfInvalid` |
| `Hyperbee.Pipeline.Validation.FluentValidation` | FluentValidation adapter with assembly scanning |
| `Hyperbee.Pipeline.AspNetCore` | `ToResult()` command-result mapping for minimal APIs, with customizable `ResultMapper` |
| `Hyperbee.Pipeline.Auth` | Claims-based pipeline steps (`WithAuth`, `PipeIfClaim`) |
| `Hyperbee.Pipeline.Caching` | Memory and distributed cache pipeline steps |
| `Hyperbee.Pipeline.TestHelpers` | Test fixtures and `CommandResult` helpers |

## Credits

Hyperbee.Pipeline is built upon the great work of several open-source projects. Special thanks to:
Expand All @@ -156,7 +190,3 @@ for more details.
| Branch | Action |
|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `main` | [![Build status](https://github.com/Stillpoint-Software/Hyperbee.Pipeline/actions/workflows/pack_publish.yml/badge.svg)](https://github.com/Stillpoint-Software/Hyperbee.Pipeline/actions/workflows/pack_publish.yml) |

# Help
See [Todo](https://github.com/Stillpoint-Software/Hyperbee.Pipeline/blob/main/docs/todo.md)

9 changes: 8 additions & 1 deletion docs/site/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ nav_order: 1

## Documentation Structure

- [Syntax](syntax.md): The full builder syntax — `Pipe`, `Call`, conditional flow, iterators, reduce, and parallel execution.
- [Conventions](conventions.md): Guidelines for creating builders, binders, and middleware.
- [Validation](validation.md): How to validate pipeline inputs using FluentValidation or custom validators.
- [Command Pattern](command-pattern.md): Commands as injectable pipeline units — `CommandResult`, boundaries, and composition.
- [Dependency Injection](dependency-injection.md): Registering pipelines and exposing container services.
- [Validation](validation.md): Validating pipeline inputs, failure types, and consuming validation results inside and outside HTTP.
- [Middleware](middleware.md): How to use and implement middleware in pipelines.
- [Child Pipelines](child-pipeline.md): Composing pipelines from other pipelines.
- [Extending Pipelines](extending.md): How to add new steps, middleware, or binders.
- [Advanced Patterns](advanced-patterns.md): Real-world examples combining extension methods, custom binders, and middleware.

Expand All @@ -32,6 +36,9 @@ Some key features are:
- Dependency injection
- Early returns and cancellation
- Child pipelines
- Declarative and imperative validation, with a FluentValidation adapter
- Command pattern with assembly scanning and DI registration
- ASP.NET Core integration with RFC 7807 result mapping (`ToResult()`)

## Why Use Pipelines

Expand Down
59 changes: 54 additions & 5 deletions docs/site/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,47 @@ Any lambda not provided falls back to the default behavior.
See the [AspNetCore README](../src/Hyperbee.Pipeline.AspNetCore/README.md) for full details on
`ResultMapper` and all `ToResult` overloads.

## Consuming Commands Outside HTTP

`ToResult()` projects validation state at the HTTP edge. When a command is consumed in-process —
from a background job, a workflow activity, or another service — use the throw helpers to surface
failure state instead:

```csharp
using Hyperbee.Pipeline.Validation;

var commandResult = await _calculateCommand.ExecuteAsync(input);

commandResult.Context.ThrowIfError(); // rethrows context.Exception with its original stack trace
commandResult.Context.ThrowIfInvalid(); // throws PipelineValidationException on validation failures

return commandResult.Result;
```

`ThrowIfInvalid()` is the validation counterpart to `ThrowIfError()`. It throws a
`PipelineValidationException` that carries the `IValidationResult`, so callers can inspect
individual failures — including `ErrorCode` — from the exception:

```csharp
catch (PipelineValidationException ex)
{
foreach (var failure in ex.ValidationResult.Errors)
_logger.LogWarning("{Property}: {Message}", failure.PropertyName, failure.ErrorMessage);
}
```

Two things `ThrowIfInvalid()` deliberately does not do:

- **Cancellation is not a failure.** A context canceled without validation failures (for example
by `Cancel()` or `CancelWith()`) does not throw; cancellation values surface through the
pipeline result as usual.
- **It does not replace `ThrowIfError()`.** Exceptions and validation failures are independent
axes of the context; call both to surface all failure state.

If a `PipelineValidationException` reaches a pipeline boundary — for example, a parent pipeline
step calls `ThrowIfInvalid()` on a nested command's result — `ToResult()` recognizes it and maps
the carried failures as a validation response (422/404/403/401) rather than a 500 error.

## Advanced Scenarios

### Custom Validators
Expand Down Expand Up @@ -484,20 +525,28 @@ services.AddPipelineValidation(config =>

## Exception Handling

Validation integrates with pipeline exception handling:
The `WithExceptionHandling` middleware maps configured exception types to validation failures,
so expected exceptions surface as validation state instead of errors:

```csharp
using Hyperbee.Pipeline.Middleware;

var command = PipelineFactory
.Start<Order>()
.WithExceptionHandling(config => config
.AddException<TimeoutException>(errorcode: 504)
.AddException<OrderProcessingException>()
)
.ValidateAsync()
.PipeAsync(async (ctx, order) => await ProcessOrder(order))
.HandleExceptionsAsync(async (ctx, exception) => {
// Log exception
ctx.FailAfter(new ValidationFailure("System", exception.Message));
})
.Build();
```

A matched exception is recorded as a validation failure (`"{ExceptionType}: {message}"` with the
configured error code) and the pipeline cancels; unmatched exceptions are re-thrown.
`WithExceptionHandling` is a hook and must be chained on the start builder, before the
pipeline's steps.

## Testing

The `Hyperbee.Pipeline.TestHelpers` package provides utilities for testing validated pipelines:
Expand Down
9 changes: 9 additions & 0 deletions src/Hyperbee.Pipeline.AspNetCore/ResultMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ internal IResult HandleCommand( IPipelineContext context, MemberInfo commandType
// 3. Exceptions
if ( context.IsError )
{
// A PipelineValidationException carries validation failures as data (typically thrown by
// ThrowIfInvalid on a nested command); map it as a validation response, not a server error.
if ( context.Exception is PipelineValidationException validationException )
{
var carriedFailures = validationException.ValidationResult.Errors;

return MapValidationFailures( carriedFailures, GetPriorityStatusCode( carriedFailures ) );
}

var exceptionResult = MapException( context.Exception );

if ( exceptionResult is null )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,43 @@ public static IEnumerable<IValidationFailure> ValidationFailures( this IPipeline
return context.GetValidationResult()?.Errors ?? Enumerable.Empty<IValidationFailure>();
}

/// <summary>
/// Throws a <see cref="PipelineValidationException"/> if the pipeline context contains validation failures.
/// </summary>
/// <remarks>
/// <para>
/// This is the validation counterpart to <see cref="IPipelineContext.ThrowIfError"/>, which considers
/// only <see cref="IPipelineContext.Exception"/>. Use both to surface all failure state when consuming
/// a command outside of HTTP, where <c>ToResult()</c> mapping does not apply.
/// </para>
/// <para>
/// Cancellation is not considered a failure; a context that was canceled without validation failures
/// (for example by <c>Cancel()</c> or <c>CancelWith()</c>) does not throw.
/// </para>
/// </remarks>
/// <param name="context">The pipeline context.</param>
/// <exception cref="PipelineValidationException">
/// Thrown when the context contains a validation result with one or more failures. The exception
/// carries the <see cref="IValidationResult"/>.
/// </exception>
/// <example>
/// <code>
/// var commandResult = await command.ExecuteAsync(input);
///
/// commandResult.Context.ThrowIfError();
/// commandResult.Context.ThrowIfInvalid();
///
/// return commandResult.Result;
/// </code>
/// </example>
public static void ThrowIfInvalid( this IPipelineContext context )
{
var validationResult = context.GetValidationResult();

if ( validationResult is { IsValid: false } )
throw new PipelineValidationException( validationResult );
}

// pipeline builder extensions

/// <summary>
Expand Down
47 changes: 47 additions & 0 deletions src/Hyperbee.Pipeline.Validation/PipelineValidationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Hyperbee.Pipeline.Validation;

/// <summary>
/// The exception thrown by <see cref="PipelineValidationExtensions.ThrowIfInvalid"/> when a
/// pipeline context contains validation failures.
/// </summary>
/// <remarks>
/// Carries the <see cref="IValidationResult"/> so callers can inspect the individual
/// <see cref="IValidationFailure"/> entries, including <see cref="IValidationFailure.ErrorCode"/>.
/// </remarks>
[Serializable]
public class PipelineValidationException : Exception
{
/// <summary>
/// The validation result containing the failures that caused the exception.
/// </summary>
public IValidationResult ValidationResult { get; }

/// <summary>
/// Initializes a new instance with a message composed from the failure messages.
/// </summary>
/// <param name="validationResult">The validation result containing the failures.</param>
public PipelineValidationException( IValidationResult validationResult )
: this( CreateMessage( validationResult ), validationResult )
{
}

/// <summary>
/// Initializes a new instance with a custom message.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="validationResult">The validation result containing the failures.</param>
public PipelineValidationException( string message, IValidationResult validationResult )
: base( message )
{
ArgumentNullException.ThrowIfNull( validationResult );

ValidationResult = validationResult;
}

private static string CreateMessage( IValidationResult validationResult )
{
ArgumentNullException.ThrowIfNull( validationResult );

return $"Validation failed: {string.Join( "; ", validationResult.Errors.Select( failure => failure.ErrorMessage ) )}";
}
}
16 changes: 15 additions & 1 deletion src/Hyperbee.Pipeline.Validation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The `Hyperbee.Pipeline.Validation` library provides base validation implementati

- **Base Implementations**: Concrete `ValidationFailure` and `ValidationResult` classes implementing the abstractions
- **Pipeline Extensions**: `ValidateAsync`, `IfValidAsync`, `ValidateAndCancelOnFailureAsync` for declarative validation
- **Context Extensions**: Methods to store and retrieve validation results from pipeline contexts
- **Context Extensions**: Methods to store, retrieve, and throw on validation results from pipeline contexts
- **Domain Failure Types**: `ApplicationValidationFailure`, `NotFoundValidationFailure`, `UnauthorizedValidationFailure`, `ForbiddenValidationFailure`
- **Exception Handling**: Middleware to map exceptions to validation failures

Expand Down Expand Up @@ -95,6 +95,20 @@ if (!context.IsValid())
}
```

### Throwing on Validation Failures

`ThrowIfInvalid` is the validation counterpart to `ThrowIfError` — use both when consuming a
command outside of HTTP:

```csharp
var commandResult = await command.ExecuteAsync(input);

commandResult.Context.ThrowIfError();
commandResult.Context.ThrowIfInvalid(); // throws PipelineValidationException carrying the IValidationResult

return commandResult.Result;
```

### Exception Handling Middleware

```csharp
Expand Down
81 changes: 81 additions & 0 deletions test/Hyperbee.Pipeline.AspNetCore.Tests/ResultMapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,87 @@ public void Custom_MapValidationFailures_should_override_problem_details_format(
Assert.IsInstanceOfType( result, typeof( JsonHttpResult<object> ) );
}

// PipelineValidationException mapping

[TestMethod]
public void ToResult_should_map_PipelineValidationException_as_validation_response()
{
var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } );
var commandResult = CreateErrorResult<string>( new PipelineValidationException( validationResult ) );

var result = commandResult.ToResult();

Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) );
var problem = (ProblemHttpResult) result;
Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode );
}

[TestMethod]
public void ToResult_should_use_priority_status_code_for_PipelineValidationException()
{
var validationResult = new ValidationResult( new IValidationFailure[]
{
new ApplicationValidationFailure( "Field", "Invalid." ),
new NotFoundValidationFailure( "Item", "Not found." )
} );
var commandResult = CreateErrorResult<string>( new PipelineValidationException( validationResult ) );

var result = commandResult.ToResult();

Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) );
var problem = (ProblemHttpResult) result;
Assert.AreEqual( StatusCodes.Status404NotFound, problem.StatusCode );
}

[TestMethod]
public void ToResult_should_map_PipelineValidationException_when_context_is_also_canceled()
{
// halt-on-error leaves an errored context canceled as well; the cancellation
// branch falls through (no IResult cancellation value) to the exception branch
var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } );
var context = new PipelineContext { Exception = new PipelineValidationException( validationResult ) };
context.CancelAfter();
var commandResult = new CommandResult<string>
{
Context = context,
CommandType = typeof( ResultMapperTests )
};

var result = commandResult.ToResult();

Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) );
var problem = (ProblemHttpResult) result;
Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode );
}

[TestMethod]
public void ToResult_non_generic_should_map_PipelineValidationException_as_validation_response()
{
var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } );
var commandResult = CreateNonGenericErrorResult( new PipelineValidationException( validationResult ) );

var result = commandResult.ToResult();

Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) );
var problem = (ProblemHttpResult) result;
Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode );
}

[TestMethod]
public void Custom_MapException_should_not_intercept_PipelineValidationException()
{
// Carried validation failures are mapped before MapException is consulted
var mapper = new RethrowMapper();
var validationResult = new ValidationResult( new[] { new ValidationFailure( "Name", "Required." ) } );
var commandResult = CreateErrorResult<string>( new PipelineValidationException( validationResult ) );

var result = commandResult.ToResult( mapper );

Assert.IsInstanceOfType( result, typeof( ProblemHttpResult ) );
var problem = (ProblemHttpResult) result;
Assert.AreEqual( StatusCodes.Status422UnprocessableEntity, problem.StatusCode );
}

// ContentSelector + mapper on error path

[TestMethod]
Expand Down
Loading
Loading