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;
}
}