This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace Prefab.Handler.Decorators;
/// <summary>
/// Provides a decorator for handler execution that establishes and manages the current HandlerContext for each request,
/// supporting HTTP, Blazor Server, and background scenarios.
/// </summary>
/// <remarks>This decorator ensures that a HandlerContext is available for each handler invocation, propagating
/// correlation and request identifiers as appropriate for the execution environment. It supports HTTP requests, Blazor
/// Server interactive sessions, and background processing, automatically selecting the appropriate context source. If
/// an existing HandlerContext is present, it is reused with a new request identifier. This class is typically used in
/// middleware or pipeline scenarios to provide consistent context information for logging, tracing, or auditing
/// purposes.</remarks>
/// <param name="handlerAccessor">The handlerAccessor used to get or set the current HandlerContext for the executing request. Cannot be null.</param>
/// <param name="httpAccessor">The HTTP context handlerAccessor used to extract request and user information when handling HTTP requests. May be null if
/// HTTP context is not available.</param>
/// <param name="auth">The authentication state provider used to obtain user information in Blazor Server scenarios. May be null if
/// authentication state is not required.</param>
public class HandlerContextDecorator(
IHandlerContextAccessor handlerAccessor,
IHttpContextAccessor? httpAccessor,
AuthenticationStateProvider? auth = null) : IHandlerDecorator
{
public async Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next, TRequest request, CancellationToken cancellationToken)
{
// If a parent context exists, reuse correlation; mint a new request id
if (handlerAccessor.Current is { } parent)
{
var child = parent with { RequestId = NewId() };
using var scope = handlerAccessor.Set(child);
return await next(request, cancellationToken);
}
if (httpAccessor?.HttpContext is HttpContext httpContext)
{
var ctx = FromHttp(httpContext);
var stored = TryStoreHandlerContext(httpContext, ctx);
using var scope = handlerAccessor.Set(ctx);
try
{
return await next(request, cancellationToken);
}
finally
{
if (stored)
{
httpContext.Items.Remove(HandlerContextItems.HttpContextKey);
}
}
}
if (auth is not null)
{
var state = await auth.GetAuthenticationStateAsync();
var ctx = ForInteractive(state.User);
using var scope = handlerAccessor.Set(ctx);
return await next(request, cancellationToken);
}
// Background/default
{
var ctx = ForBackground();
using var scope = handlerAccessor.Set(ctx);
return await next(request, cancellationToken);
}
}
#region Helpers
private static HandlerContext FromHttp(HttpContext httpContext)
{
httpContext.Request.Headers.TryGetValue("Idempotency-Key", out var idem);
var userId = GetUserId(httpContext.User);
string FirstNonEmpty(string a, string b) => !string.IsNullOrWhiteSpace(a) ? a : b;
var corr = FirstNonEmpty(httpContext.Request.Headers["X-Correlation-Id"].ToString(), httpContext.TraceIdentifier);
var req = FirstNonEmpty(httpContext.Request.Headers["X-Request-Id"].ToString(), httpContext.TraceIdentifier);
return new HandlerContext(
CorrelationId: string.IsNullOrWhiteSpace(corr) ? NewId() : corr,
RequestId: string.IsNullOrWhiteSpace(req) ? NewId() : req,
IdempotencyKey: string.IsNullOrWhiteSpace(idem) ? null : idem.ToString(),
UserId: userId);
}
private static HandlerContext ForInteractive(ClaimsPrincipal? user) => new(
UserId: GetUserId(user), RequestId: NewId(), CorrelationId: NewId(), IdempotencyKey: null);
private static HandlerContext ForBackground(string? correlationId = null, string? userId = null) => new(
UserId: userId, RequestId: NewId(), CorrelationId: correlationId ?? NewId(), IdempotencyKey: null);
private static string? GetUserId(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true) return null;
return user.FindFirst("sub")?.Value
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.Identity!.Name;
}
private static string NewId() => Guid.NewGuid().ToString("N");
private static bool TryStoreHandlerContext(HttpContext httpContext, HandlerContext context)
{
if (httpContext.Items.ContainsKey(HandlerContextItems.HttpContextKey))
{
return false;
}
httpContext.Items[HandlerContextItems.HttpContextKey] = context;
return true;
}
#endregion
}

View File

@@ -0,0 +1,15 @@
namespace Prefab.Handler.Decorators;
/// <summary>
/// Defines a contract for decorating handler execution with additional behavior, such as logging, validation, or
/// exception handling.
/// </summary>
/// <remarks>Implementations of this interface can be used to add cross-cutting concerns around handler invocation
/// in a pipeline. Decorators can inspect or modify the request, context, or response, and may perform actions before or
/// after calling the next handler in the chain. This interface is typically used in scenarios where handler logic needs
/// to be extended without modifying the original handler implementation.</remarks>
public interface IHandlerDecorator
{
Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next,
TRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,161 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Prefab.Handler.Decorators;
public sealed class LoggingDecoratorOptions
{
/// <summary>Log at Warning when handler time >= this threshold (ms).</summary>
public int SlowThresholdInMilliseconds { get; init; } = 1000;
}
public sealed class LoggingDecorator(ILogger<LoggingDecorator> logger, IHandlerContextAccessor handlerContext, IOptions<LoggingDecoratorOptions>? options = null) : IHandlerDecorator
{
private readonly LoggingDecoratorOptions _options = options?.Value ?? new LoggingDecoratorOptions();
public async Task<TResponse> Invoke<TRequest, TResponse>(Func<TRequest, CancellationToken, Task<TResponse>> next,
TRequest request, CancellationToken cancellationToken)
{
// Friendly operation label
var requestType = DescribeMessageType(typeof(TRequest));
var responseType = DescribeMessageType(typeof(TResponse));
var operation = $"{requestType}->{responseType}";
var currentContext = handlerContext.Current ?? new HandlerContext(null, null, null, null);
using var scope = logger.BeginScope(new Dictionary<string, object?>
{
["Operation"] = operation,
["RequestType"] = requestType,
["ResponseType"] = responseType,
["CorrelationId"] = currentContext.CorrelationId,
["IdempotencyKey"] = currentContext.IdempotencyKey,
["RequestId"] = currentContext.RequestId,
["UserId"] = currentContext.UserId
});
var stopwatch = Stopwatch.StartNew();
// (Optional) OpenTelemetry tagging
var activity = Activity.Current;
activity?.SetTag("handler.operation", operation);
activity?.SetTag("handler.request_type", requestType);
activity?.SetTag("handler.response_type", responseType);
activity?.SetTag("handler.correlation_id", currentContext.CorrelationId);
activity?.SetTag("handler.request_id", currentContext.RequestId);
try
{
if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug("Handling {Operation}", operation);
var result = await next(request, cancellationToken).ConfigureAwait(false);
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Canceled {Operation}", operation);
activity?.SetStatus(ActivityStatusCode.Error, "canceled");
throw;
}
catch (Exception ex)
{
if (logger.IsEnabled(LogLevel.Error))
logger.LogError(ex, "Error handling {Operation}", operation);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
finally
{
stopwatch.Stop();
var ms = stopwatch.ElapsedMilliseconds;
activity?.SetTag("handler.duration_ms", ms);
if (ms >= _options.SlowThresholdInMilliseconds && logger.IsEnabled(LogLevel.Warning))
logger.LogWarning("Handled {Operation} in {ElapsedMs} ms (slow)", operation, ms);
else if (logger.IsEnabled(LogLevel.Information))
logger.LogInformation("Handled {Operation} in {ElapsedMs} ms", operation, ms);
}
}
// ---- Pretty type-name helpers with caching ----
private static string DescribeMessageType(Type type) =>
MessageTypeCache.GetOrAdd(type, static t =>
{
if (t == typeof(NoRequest))
{
return nameof(NoRequest);
}
if (t.FullName is { } fullName &&
fullName.StartsWith("Prefab.", StringComparison.Ordinal))
{
var formatted = FormatPrefabTypeName(fullName);
if (!string.IsNullOrEmpty(formatted))
{
return formatted;
}
}
return PrettyTypeName(t);
});
private static string FormatPrefabTypeName(string fullName)
{
const string prefix = "Prefab.";
var span = fullName.AsSpan(prefix.Length);
var buffer = new List<string>();
foreach (var segment in span.ToString().Replace('+', '.')
.Split('.', StringSplitOptions.RemoveEmptyEntries))
{
if (SegmentsToSkip.Contains(segment))
continue;
buffer.Add(segment);
}
if (buffer.Count == 0)
{
return string.Empty;
}
return string.Join('.', buffer);
}
/// <summary>
/// Returns a human-readable type name (e.g., <c>Dictionary&lt;string, List&lt;int&gt;&gt;</c>)
/// instead of framework names like <c>Dictionary`2</c>.
/// </summary>
private static string PrettyTypeName(Type type) =>
PrettyCache.GetOrAdd(type, static t => PrettyTypeNameUncached(t));
private static string PrettyTypeNameUncached(Type t)
{
if (!t.IsGenericType) return t.Name;
var name = t.Name;
var tick = name.IndexOf('`');
var baseName = tick >= 0 ? name[..tick] : name;
var args = t.GetGenericArguments().Select(PrettyTypeNameUncached);
return $"{baseName}<{string.Join(",", args)}>";
}
private static readonly ConcurrentDictionary<Type, string> MessageTypeCache = new();
private static readonly ConcurrentDictionary<Type, string> PrettyCache = new();
private static readonly HashSet<string> SegmentsToSkip = new(StringComparer.OrdinalIgnoreCase)
{
"Shared",
"App",
"Handler",
"Base"
};
}

View File

@@ -0,0 +1,65 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.DependencyInjection;
namespace Prefab.Handler.Decorators;
/// <summary>
/// Decorator that runs all registered FluentValidation validators for the current request type
/// before the handler executes.
/// </summary>
/// <remarks>
/// Validators are resolved on demand per request, allowing pages and modules to register request-specific
/// validators without touching handler implementations. When no validators are registered, the decorator
/// simply forwards execution to the next component in the pipeline.
/// </remarks>
public sealed class ValidationDecorator(IServiceProvider serviceProvider) : IHandlerDecorator
{
public async Task<TResponse> Invoke<TRequest, TResponse>(
Func<TRequest, CancellationToken, Task<TResponse>> next,
TRequest request,
CancellationToken cancellationToken)
{
var failures = await ValidateAsync(request, cancellationToken);
if (failures.Count != 0)
{
throw new ValidationException(failures);
}
return await next(request, cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyCollection<ValidationFailure>> ValidateAsync<TRequest>(
TRequest request,
CancellationToken cancellationToken)
{
if (request is null)
{
return [];
}
var validators = serviceProvider.GetServices<IValidator<TRequest>>();
var enumerable = validators as IValidator<TRequest>[] ?? validators.ToArray();
if (enumerable.Length == 0)
{
return [];
}
var context = new ValidationContext<TRequest>(request);
var failures = new List<ValidationFailure>();
foreach (var validator in enumerable)
{
var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false);
if (!result.IsValid)
{
failures.AddRange(result.Errors.Where(f => f is not null));
}
}
return failures;
}
}

View File

@@ -0,0 +1,15 @@
using Prefab.Handler.Decorators;
namespace Prefab.Handler;
/// <summary>
/// Provides a base class for handler implementations that require access to the current handler context.
/// </summary>
/// <param name="accessor">The accessor used to retrieve the current handler context. Cannot be null.</param>
public abstract class HandlerBase(IHandlerContextAccessor accessor)
{
protected IHandlerContextAccessor Accessor { get; } = accessor;
protected HandlerContext Context => Accessor.Current ??
throw new InvalidOperationException($"HandlerContext not set ({nameof(HandlerContextDecorator)}) must run first).");
}

View File

@@ -0,0 +1,20 @@
namespace Prefab.Handler;
/// <summary>
/// Represents contextual information about the current request and user for use in request handling and logging.
/// </summary>
/// <remarks>This record is typically used to pass request-scoped metadata through application layers, enabling
/// consistent logging, tracing, and idempotency handling. All properties are optional and may be null if the
/// corresponding information is unavailable.</remarks>
/// <param name="UserId">The unique identifier of the user associated with the request, or null if the user is unauthenticated.</param>
/// <param name="RequestId">The unique identifier for the current request, used for tracing and diagnostics. Can be null if not set.</param>
/// <param name="CorrelationId">The identifier used to correlate this request with related operations across services or components. Can be null if
/// not set.</param>
/// <param name="IdempotencyKey">A key that uniquely identifies the request for idempotency purposes, allowing safe retries. Can be null if
/// idempotency is not required.</param>
public sealed record HandlerContext(
string? UserId,
string? RequestId,
string? CorrelationId,
string? IdempotencyKey);

View File

@@ -0,0 +1,12 @@
namespace Prefab.Handler;
/// <summary>
/// Provides shared keys for storing handler context metadata in request-scoped containers.
/// </summary>
public static class HandlerContextItems
{
/// <summary>
/// Key used when storing <see cref="HandlerContext"/> in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
/// </summary>
public const string HttpContextKey = "__prefab.handler-context";
}

View File

@@ -0,0 +1,100 @@
using Microsoft.Extensions.DependencyInjection;
using Prefab.Handler.Decorators;
namespace Prefab.Handler;
/// <summary>
/// Provides a mechanism to execute handler pipelines with support for applying a sequence of handler decorators. This
/// class enables the composition of cross-cutting concerns around handler execution.
/// </summary>
/// <remarks>HandlerInvoker is typically used to execute handlers with additional behaviors, such as logging,
/// validation, or exception handling, by composing the provided decorators. The order of decorators affects the
/// execution flow: the first-registered decorator is invoked first and wraps subsequent decorators and the handler
/// itself. This class is thread-safe for concurrent handler executions.</remarks>
/// <param name="decorators">The collection of handler decorators to apply to each handler invocation. Decorators are applied in the order they
/// are provided, with the first decorator wrapping the outermost layer of the pipeline. Cannot be null.</param>
public sealed class HandlerInvoker(IEnumerable<IHandlerDecorator> decorators, IServiceProvider serviceProvider)
{
private readonly IHandlerDecorator[] _decorators = decorators.Reverse().ToArray();
public Task<TResponse> Execute<TResponse>(CancellationToken cancellationToken)
{
var inner = serviceProvider.GetRequiredService<IHandler<TResponse>>();
// No decorators? Call the handler directly.
if (_decorators.Length == 0)
return inner.Execute(cancellationToken);
var noRequest = new NoRequestAdapter<TResponse>(inner);
var pipeline = BuildPipeline(noRequest);
return pipeline(NoRequest.Instance, cancellationToken);
}
/// <summary>
/// Executes the specified handler for the given request within the provided context and cancellation token,
/// returning the handler's response asynchronously.
/// </summary>
/// <typeparam name="TRequest">The type of the request to be handled. Must not be null.</typeparam>
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
/// <param name="request">The request object to be processed by the handler.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response produced by the
/// handler.</returns>
public Task<TResponse> Execute<TRequest, TResponse>(TRequest request, CancellationToken cancellationToken) where TRequest : notnull
{
var handler = serviceProvider.GetRequiredService<IHandler<TRequest, TResponse>>();
// No decorators? Call the handler directly.
if (_decorators.Length == 0)
return handler.Execute(request, cancellationToken);
var pipeline = BuildPipeline(handler);
return pipeline(request, cancellationToken);
}
/// <summary>
/// Builds a delegate representing the request handling pipeline, applying all registered decorators to the
/// specified handler.
/// </summary>
/// <remarks>Decorators are applied in the order they were registered, with the first-registered decorator
/// wrapping the outermost layer of the pipeline. The returned delegate can be invoked with a request, handler
/// context, and cancellation token to execute the full pipeline.</remarks>
/// <typeparam name="TRequest">The type of the request message to be handled.</typeparam>
/// <typeparam name="TResponse">The type of the response message returned by the handler.</typeparam>
/// <param name="handler">The core handler to which decorators will be applied. Cannot be null.</param>
/// <returns>A delegate that processes a request through all registered decorators and the specified handler.</returns>
private Func<TRequest, CancellationToken, Task<TResponse>> BuildPipeline<TRequest, TResponse>(IHandler<TRequest, TResponse> handler)
{
// Start from the final operation: the handler itself.
Func<TRequest, CancellationToken, Task<TResponse>> pipeline = handler.Execute;
// Wrap in reverse registration order so the first-registered decorator runs outermost.
foreach (var decorator in _decorators)
{
var next = pipeline;
pipeline = (request, cancellationToken) =>
decorator.Invoke(next, request, cancellationToken);
}
return pipeline;
}
/// <summary>
/// Provides an adapter that allows an existing handler to be used where a handler for a request type of NoRequest
/// is required.
/// </summary>
/// <remarks>This class is useful for scenarios where a handler expects no input request data but must
/// conform to an interface requiring a request parameter. It delegates execution to the specified inner handler,
/// ignoring the NoRequest parameter.</remarks>
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
/// <param name="innerHandler">The handler that processes requests and produces a response of type TResponse.</param>
private sealed class NoRequestAdapter<TResponse>(IHandler<TResponse> innerHandler) : IHandler<NoRequest, TResponse>
{
public Task<TResponse> Execute(NoRequest _, CancellationToken cancellationToken)
=> innerHandler.Execute(cancellationToken);
}
}

View File

@@ -0,0 +1,25 @@
namespace Prefab.Handler;
/// <summary>
/// Defines a handler that processes a request and returns a response asynchronously.
/// </summary>
/// <remarks>Implementations of this interface encapsulate the logic required to handle a specific request and
/// produce a result. Handlers are typically used in command or query processing pipelines to separate request handling
/// logic from other application concerns.</remarks>
/// <typeparam name="TResponse">The type of the response returned by the handler.</typeparam>
public interface IHandler<TResponse>
{
Task<TResponse> Execute(CancellationToken cancellationToken);
}
/// <summary>
/// Defines a contract for handling a request and producing a response asynchronously.
/// </summary>
/// <remarks>Implementations of this interface should be thread-safe if they are intended to be used concurrently.
/// The interface is typically used in request/response or command/query processing pipelines.</remarks>
/// <typeparam name="TRequest">The type of the request to be handled.</typeparam>
/// <typeparam name="TResponse">The type of the response returned after handling the request.</typeparam>
public interface IHandler<in TRequest, TResponse>
{
Task<TResponse> Execute(TRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,66 @@
namespace Prefab.Handler;
/// <summary>
/// Provides access to the current handler context for the executing operation.
/// </summary>
/// <remarks>Implementations of this interface allow components to retrieve or temporarily override the current
/// HandlerContext within a given scope. This is typically used to flow contextual information, such as user or request
/// data, through asynchronous or nested operations.</remarks>
public interface IHandlerContextAccessor
{
/// <summary>
/// Gets the current handler context for the ongoing operation, if one is available.
/// </summary>
HandlerContext? Current { get; }
/// <summary>
/// Sets the specified handler context as the current context for the duration of the returned disposable object's
/// lifetime.
/// </summary>
/// <remarks>Use the returned IDisposable in a using statement to ensure the previous context is restored
/// even if an exception occurs.</remarks>
/// <param name="handlerContext">The handler context to set as current. Cannot be null.</param>
/// <returns>An IDisposable that, when disposed, restores the previous handler context.</returns>
IDisposable Set(HandlerContext handlerContext);
}
/// <inheritdoc cref="IHandlerContextAccessor"/>
public sealed class HandlerContextAccessor : IHandlerContextAccessor
{
private static readonly AsyncLocal<HandlerContext?> CurrentContext = new();
/// <inheritdoc cref="IHandlerContextAccessor"/>
public HandlerContext? Current => CurrentContext.Value;
/// <inheritdoc cref="IHandlerContextAccessor"/>
public IDisposable Set(HandlerContext handlerContext)
{
var previous = CurrentContext.Value;
CurrentContext.Value = handlerContext;
return new Scope(() => CurrentContext.Value = previous);
}
/// <summary>
/// Provides a mechanism for executing a specified delegate when the scope is disposed, typically used to restore
/// state or perform cleanup actions.
/// </summary>
/// <remarks>This type is intended for use with the 'using' statement to ensure that the specified
/// delegate is executed exactly once when the scope ends. The delegate is not invoked if Dispose is called more
/// than once.</remarks>
/// <param name="restore">The delegate to invoke when the scope is disposed. Cannot be null.</param>
private sealed class Scope(Action restore) : IDisposable
{
private bool _done;
public void Dispose()
{
if(_done)
return;
_done = true;
restore();
}
}
}

View File

@@ -0,0 +1,11 @@
namespace Prefab.Handler;
/// <summary>
/// Represents a sentinel value indicating the absence of a request.
/// </summary>
/// <remarks>Use this type when an operation or API requires a value to signify that no request is present. This
/// can be useful in scenarios where a method or handler expects a request type but no data is needed.</remarks>
public readonly record struct NoRequest
{
public static readonly NoRequest Instance = default;
}