Init
This commit is contained in:
123
Prefab/Handler/Decorators/HandlerContextDecorator.cs
Normal file
123
Prefab/Handler/Decorators/HandlerContextDecorator.cs
Normal 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
15
Prefab/Handler/Decorators/IHandlerDecorator.cs
Normal file
15
Prefab/Handler/Decorators/IHandlerDecorator.cs
Normal 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);
|
||||
}
|
||||
161
Prefab/Handler/Decorators/LoggingDecorator.cs
Normal file
161
Prefab/Handler/Decorators/LoggingDecorator.cs
Normal 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<string, List<int>></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"
|
||||
};
|
||||
}
|
||||
65
Prefab/Handler/Decorators/ValidationDecorator.cs
Normal file
65
Prefab/Handler/Decorators/ValidationDecorator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user