From 9c98ed7a313dc0b3e2ead41eb2aef996fc3ee358 Mon Sep 17 00:00:00 2001 From: "Abdullah D." <48760103+abdebek@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:02:31 +0300 Subject: [PATCH] refactor: execution context and messaging options - Moved global error handling to the correct position in ApplicationBuilderExtensions. - Added ExecutionContextOptions for customizable claim mapping. - Introduced MessagingExecutionContext for handling message-specific execution context. - Updated MessagingOptions to include local queue name, queue prefix, and dead-letter expiration settings. - Enhanced IRepository interface with default implementations for AnyAsync, SingleOrDefaultAsync, and CountAsync methods. - Added tests for ExecutionContext and MessagingOptions to ensure correct behavior and configuration. - Updated AppDbContext to support execution context. --- CHANGELOG.md | 31 ++++ README.md | 1 + .../Data/ApplicationDbContext.cs | 105 +---------- .../Extensions/AuditExtensions.cs | 14 +- src/MinimalCleanArch.Audit/README.md | 4 + .../ExecutionContextAuditContextProvider.cs | 37 ++++ .../Services/IAuditContextProvider.cs | 2 +- .../DbContextBase.cs | 29 ++- .../IdentityDbContextBase.cs | 86 ++++++--- src/MinimalCleanArch.DataAccess/README.md | 17 ++ .../Execution/HttpExecutionContext.cs | 143 +++++++++++++++ .../ApplicationBuilderExtensions.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 5 + src/MinimalCleanArch.Extensions/README.md | 10 ++ .../Execution/MessagingExecutionContext.cs | 170 ++++++++++++++++++ .../Extensions/MessagingExtensions.cs | 40 ++++- .../Extensions/MessagingOptions.cs | 49 ++++- src/MinimalCleanArch.Messaging/README.md | 32 +++- .../Execution/ExecutionContextOptions.cs | 45 +++++ .../Execution/IExecutionContext.cs | 43 +++++ .../Execution/NullExecutionContext.cs | 40 +++++ .../Repositories/IRepository.cs | 70 ++++++-- .../MCA.Infrastructure/Data/AppDbContext.cs | 50 ++---- .../Infrastructure/Data/AppDbContext.cs | 47 ++--- templates/scripts/validate-templates.ps1 | 30 ++++ .../Execution/ExecutionContextTests.cs | 122 +++++++++++++ .../Messaging/MessagingOptionsTests.cs | 95 +++++++++- .../RepositoryDefaultInterfaceTests.cs | 139 ++++++++++++++ 28 files changed, 1240 insertions(+), 219 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/MinimalCleanArch.Audit/Services/ExecutionContextAuditContextProvider.cs create mode 100644 src/MinimalCleanArch.Extensions/Execution/HttpExecutionContext.cs create mode 100644 src/MinimalCleanArch.Messaging/Execution/MessagingExecutionContext.cs create mode 100644 src/MinimalCleanArch/Execution/ExecutionContextOptions.cs create mode 100644 src/MinimalCleanArch/Execution/IExecutionContext.cs create mode 100644 src/MinimalCleanArch/Execution/NullExecutionContext.cs create mode 100644 tests/MinimalCleanArch.UnitTests/Execution/ExecutionContextTests.cs create mode 100644 tests/MinimalCleanArch.UnitTests/Repositories/RepositoryDefaultInterfaceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f8e21d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog. + +## [Unreleased] + +### Added +- shared `IExecutionContext` in `MinimalCleanArch` +- `NullExecutionContext` fallback for non-HTTP and test scenarios +- HTTP-backed execution context registration in `MinimalCleanArch.Extensions` +- message-scope-aware execution context registration in `MinimalCleanArch.Messaging` +- execution-context-backed audit adapter in `MinimalCleanArch.Audit` +- non-breaking base DbContext constructor overloads that accept `IExecutionContext` +- `GetCurrentTenantId()` virtual hooks in DataAccess base DbContexts +- messaging options for local queue naming, queue prefixing, dead-letter expiration, and failure-policy hooks +- repository default interface implementations for `AnyAsync`, `SingleOrDefaultAsync`, and `CountAsync(ISpecification)` + +### Changed +- `AddAuditLogging()` now prefers `IExecutionContext` when one is registered, while preserving HTTP fallback behavior +- sample and generated DbContexts now use `DbContextBase` or `IdentityDbContextBase` as the preferred path for audit stamping and soft-delete filtering +- template and sample messaging setup now targets the updated MinimalCleanArch messaging defaults + +### Fixed +- `UseMinimalCleanArchApiDefaults()` now applies middleware in the correct order: correlation ID, security headers, error handling, then rate limiting +- `IAuditContextProvider.GetTenantId()` is now non-breaking for existing implementations through a default interface implementation +- `MessagingOptions.SchemaName` documentation now correctly states that it applies to both SQL Server and PostgreSQL persistence + +### Breaking Changes +- none intended in this batch; compatibility shims were added for the expanded repository and audit interfaces diff --git a/README.md b/README.md index 307b9b0..6328939 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Preferred defaults: - use `AddValidationFromAssemblyContaining()` for validator registration - use `AddMinimalCleanArchMessaging...` extensions instead of wiring Wolverine from scratch - use Data Protection-based encryption for new development +- use `IExecutionContext` as the shared source for user, tenant, and correlation data across HTTP and message-handler flows ## Packages | Package | Description | diff --git a/samples/MinimalCleanArch.Sample/Infrastructure/Data/ApplicationDbContext.cs b/samples/MinimalCleanArch.Sample/Infrastructure/Data/ApplicationDbContext.cs index cf6734f..c6b0a63 100644 --- a/samples/MinimalCleanArch.Sample/Infrastructure/Data/ApplicationDbContext.cs +++ b/samples/MinimalCleanArch.Sample/Infrastructure/Data/ApplicationDbContext.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalCleanArch.DataAccess; using MinimalCleanArch.Sample.Domain.Entities; +using MinimalCleanArch.Execution; using MinimalCleanArch.Security.Encryption; using MinimalCleanArch.Security.EntityEncryption; -using System.Security.Claims; using System.Linq.Expressions; using MinimalCleanArch.Domain.Entities; using MinimalCleanArch.Audit.Configuration; @@ -17,11 +17,10 @@ namespace MinimalCleanArch.Sample.Infrastructure.Data; /// /// Application DbContext with Identity API endpoints and MinimalCleanArch features /// -public class ApplicationDbContext : IdentityDbContext +public class ApplicationDbContext : IdentityDbContextBase { private readonly IEncryptionService _encryptionService; private readonly IServiceProvider _serviceProvider; - private readonly IHttpContextAccessor? _httpContextAccessor; private readonly AuditOptions? _auditOptions; /// @@ -31,13 +30,12 @@ public ApplicationDbContext( DbContextOptions options, IEncryptionService encryptionService, IServiceProvider serviceProvider, - IHttpContextAccessor? httpContextAccessor = null, + IExecutionContext? executionContext = null, AuditOptions? auditOptions = null) - : base(options) + : base(options, executionContext) { _encryptionService = encryptionService; _serviceProvider = serviceProvider; - _httpContextAccessor = httpContextAccessor; _auditOptions = auditOptions; } @@ -54,9 +52,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // Apply soft delete query filters - ApplySoftDeleteQueryFilters(modelBuilder); - // Configure Identity entities with custom table names ConfigureIdentityEntities(modelBuilder); @@ -73,25 +68,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } - /// - /// Applies soft delete query filters to all entities that implement ISoftDelete - /// - private static void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder) - { - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - if (typeof(ISoftDelete).IsAssignableFrom(entityType.ClrType)) - { - var parameter = Expression.Parameter(entityType.ClrType, "p"); - var property = Expression.Property(parameter, nameof(ISoftDelete.IsDeleted)); - var condition = Expression.Equal(property, Expression.Constant(false)); - var lambda = Expression.Lambda(condition, parameter); - - modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda); - } - } - } - private void ConfigureIdentityEntities(ModelBuilder modelBuilder) { // Configure the User entity @@ -160,74 +136,7 @@ private void ConfigureIdentityEntities(ModelBuilder modelBuilder) }); } - /// - /// Saves all changes made in this context to the database with audit information - /// - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - ApplyAuditInfo(); - return await base.SaveChangesAsync(cancellationToken); - } - - /// - /// Saves all changes made in this context to the database with audit information - /// - public override int SaveChanges() - { - ApplyAuditInfo(); - return base.SaveChanges(); - } - - /// - /// Applies audit information to entities that implement IAuditableEntity - /// - private void ApplyAuditInfo() - { - var entries = ChangeTracker.Entries() - .Where(e => e.Entity is IAuditableEntity && - (e.State == EntityState.Added || e.State == EntityState.Modified)) - .ToList(); - - if (!entries.Any()) - return; - - string? userId = GetCurrentUserId(); - DateTime now = DateTime.UtcNow; - - foreach (var entityEntry in entries) - { - if (entityEntry.Entity is IAuditableEntity auditableEntity) - { - if (entityEntry.State == EntityState.Added) - { - auditableEntity.CreatedAt = now; - auditableEntity.CreatedBy = userId; - } - else if (entityEntry.State == EntityState.Modified) - { - Entry(auditableEntity).Property(x => x.CreatedAt).IsModified = false; - Entry(auditableEntity).Property(x => x.CreatedBy).IsModified = false; - } - - auditableEntity.LastModifiedAt = now; - auditableEntity.LastModifiedBy = userId; - } - } - } - - /// - /// Gets the current user ID from the HTTP context - /// - private string? GetCurrentUserId() - { - var user = _httpContextAccessor?.HttpContext?.User; - if (user?.Identity?.IsAuthenticated == true) - { - return user.FindFirst(ClaimTypes.NameIdentifier)?.Value; - } - - return "system"; - } + protected override string? GetCurrentUserId() => base.GetCurrentUserId() ?? "system"; } /// @@ -282,4 +191,4 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(t => t.CreatedBy); builder.HasIndex(t => t.CreatedAt); } -} \ No newline at end of file +} diff --git a/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs b/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs index d27b6ae..8580e4c 100644 --- a/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs +++ b/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs @@ -4,6 +4,7 @@ using MinimalCleanArch.Audit.Entities; using MinimalCleanArch.Audit.Interceptors; using MinimalCleanArch.Audit.Services; +using MinimalCleanArch.Execution; namespace MinimalCleanArch.Audit.Extensions; @@ -27,7 +28,18 @@ public static IServiceCollection AddAuditLogging( configure?.Invoke(options); services.AddSingleton(options); - services.AddScoped(); + services.AddHttpContextAccessor(); + services.AddScoped(sp => + { + var executionContext = sp.GetService(); + if (executionContext is not null) + { + return new ExecutionContextAuditContextProvider(executionContext); + } + + return new HttpContextAuditContextProvider( + sp.GetRequiredService()); + }); services.AddScoped(); return services; diff --git a/src/MinimalCleanArch.Audit/README.md b/src/MinimalCleanArch.Audit/README.md index 8ad5542..2fd1b6d 100644 --- a/src/MinimalCleanArch.Audit/README.md +++ b/src/MinimalCleanArch.Audit/README.md @@ -10,6 +10,7 @@ Audit logging components for MinimalCleanArch. - DI extensions to plug audit logging into your MinimalCleanArch app. - Tenant-aware audit context support through `IAuditContextProvider`. - Audit query service support for user, tenant, correlation ID, and flexible search queries. +- default audit-context bridging from `IExecutionContext` when available ## Usage ```bash @@ -23,6 +24,8 @@ builder.Services.AddAuditLogging(); builder.Services.AddAuditLogService(); ``` +If `IExecutionContext` is registered, `AddAuditLogging()` uses it automatically. Existing `IAuditContextProvider` customizations still work. + Configure the DbContext: ```csharp @@ -61,5 +64,6 @@ public sealed class AppAuditContextProvider : IAuditContextProvider Notes: - `TenantId` is a first-class field on `AuditLog`, so tenant filtering does not need to be pushed into metadata. - `IAuditLogService` supports tenant-aware queries in addition to user and correlation-based lookups. +- `IAuditContextProvider.GetTenantId()` now defaults to `null`, so existing custom providers do not need to implement tenant support immediately. - When using a local feed, add a `nuget.config` pointing to your local packages folder and keep `nuget.org` available unless your feed mirrors all external dependencies. diff --git a/src/MinimalCleanArch.Audit/Services/ExecutionContextAuditContextProvider.cs b/src/MinimalCleanArch.Audit/Services/ExecutionContextAuditContextProvider.cs new file mode 100644 index 0000000..12a9285 --- /dev/null +++ b/src/MinimalCleanArch.Audit/Services/ExecutionContextAuditContextProvider.cs @@ -0,0 +1,37 @@ +using MinimalCleanArch.Execution; + +namespace MinimalCleanArch.Audit.Services; + +internal sealed class ExecutionContextAuditContextProvider : IAuditContextProvider +{ + private readonly IExecutionContext _executionContext; + + public ExecutionContextAuditContextProvider(IExecutionContext executionContext) + { + _executionContext = executionContext; + } + + public string? GetUserId() => _executionContext.UserId; + + public string? GetUserName() => _executionContext.UserName; + + public string? GetTenantId() => _executionContext.TenantId; + + public string? GetCorrelationId() => _executionContext.CorrelationId; + + public string? GetClientIpAddress() => _executionContext.ClientIpAddress; + + public string? GetUserAgent() => _executionContext.UserAgent; + + public IDictionary? GetMetadata() + { + if (_executionContext.Metadata.Count == 0) + { + return null; + } + + return _executionContext.Metadata.ToDictionary( + kvp => kvp.Key, + kvp => (object)kvp.Value); + } +} diff --git a/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs b/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs index cc133a7..7de0bad 100644 --- a/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs +++ b/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs @@ -19,7 +19,7 @@ public interface IAuditContextProvider /// /// Gets the current tenant or organization identifier. /// - string? GetTenantId(); + string? GetTenantId() => null; /// /// Gets the current correlation ID for request tracing. diff --git a/src/MinimalCleanArch.DataAccess/DbContextBase.cs b/src/MinimalCleanArch.DataAccess/DbContextBase.cs index b1368c4..00e0f6e 100644 --- a/src/MinimalCleanArch.DataAccess/DbContextBase.cs +++ b/src/MinimalCleanArch.DataAccess/DbContextBase.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using MinimalCleanArch.Domain.Entities; +using MinimalCleanArch.Execution; namespace MinimalCleanArch.DataAccess; @@ -10,13 +11,26 @@ namespace MinimalCleanArch.DataAccess; /// public abstract class DbContextBase : DbContext { + private readonly IExecutionContext? _executionContext; + /// /// Initializes a new instance of the class /// /// The options to be used by the DbContext - protected DbContextBase(DbContextOptions options) + protected DbContextBase(DbContextOptions options) + : this(options, null) + { + } + + /// + /// Initializes a new instance of the class + /// + /// The options to be used by the DbContext + /// The execution context for audit stamping. + protected DbContextBase(DbContextOptions options, IExecutionContext? executionContext) : base(options) { + _executionContext = executionContext; } /// @@ -97,10 +111,11 @@ private void ApplyAuditInfo() /// Gets the current user ID from the application context /// /// The current user ID - protected virtual string? GetCurrentUserId() - { - // This should be overridden in derived classes to get the actual user ID - // from the application's authentication system - return null; - } + protected virtual string? GetCurrentUserId() => _executionContext?.UserId; + + /// + /// Gets the current tenant ID from the application context + /// + /// The current tenant ID + protected virtual string? GetCurrentTenantId() => _executionContext?.TenantId; } diff --git a/src/MinimalCleanArch.DataAccess/IdentityDbContextBase.cs b/src/MinimalCleanArch.DataAccess/IdentityDbContextBase.cs index b7af471..04254ff 100644 --- a/src/MinimalCleanArch.DataAccess/IdentityDbContextBase.cs +++ b/src/MinimalCleanArch.DataAccess/IdentityDbContextBase.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using MinimalCleanArch.Domain.Entities; +using MinimalCleanArch.Execution; namespace MinimalCleanArch.DataAccess; @@ -15,13 +16,26 @@ namespace MinimalCleanArch.DataAccess; public abstract class IdentityDbContextBase : IdentityDbContext where TUser : IdentityUser { + private readonly IExecutionContext? _executionContext; + /// /// Initializes a new instance of the class /// /// The options to be used by the DbContext - protected IdentityDbContextBase(DbContextOptions options) + protected IdentityDbContextBase(DbContextOptions options) + : this(options, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The options to be used by the DbContext. + /// The execution context for audit stamping. + protected IdentityDbContextBase(DbContextOptions options, IExecutionContext? executionContext) : base(options) { + _executionContext = executionContext; } /// @@ -151,12 +165,13 @@ private void ApplyAuditInfo() /// Gets the current user ID from the application context /// /// The current user ID - protected virtual string? GetCurrentUserId() - { - // This should be overridden in derived classes to get the actual user ID - // from the application's authentication system - return null; - } + protected virtual string? GetCurrentUserId() => _executionContext?.UserId; + + /// + /// Gets the current tenant ID from the application context. + /// + /// The current tenant ID. + protected virtual string? GetCurrentTenantId() => _executionContext?.TenantId; } /// @@ -171,13 +186,26 @@ public abstract class IdentityDbContextBase : IdentityDbCont where TRole : IdentityRole where TKey : IEquatable { + private readonly IExecutionContext? _executionContext; + /// /// Initializes a new instance of the class /// /// The options to be used by the DbContext - protected IdentityDbContextBase(DbContextOptions options) + protected IdentityDbContextBase(DbContextOptions options) + : this(options, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The options to be used by the DbContext. + /// The execution context for audit stamping. + protected IdentityDbContextBase(DbContextOptions options, IExecutionContext? executionContext) : base(options) { + _executionContext = executionContext; } /// @@ -279,23 +307,36 @@ private void ConfigureIdentityEntitiesIfNeeded(ModelBuilder modelBuilder) /// Configures Identity tables with performance optimizations /// /// The model builder - private static void ConfigureIdentityTablesOptimized(ModelBuilder modelBuilder) + private void ConfigureIdentityTablesOptimized(ModelBuilder modelBuilder) { - // Configure additional indexes for performance + var useSqlServerFilters = string.Equals(Database.ProviderName, "Microsoft.EntityFrameworkCore.SqlServer", StringComparison.Ordinal); + modelBuilder.Entity(entity => { - entity.HasIndex(e => e.Email).IsUnique().HasFilter("[Email] IS NOT NULL"); - entity.HasIndex(e => e.UserName).IsUnique().HasFilter("[UserName] IS NOT NULL"); - entity.HasIndex(e => e.NormalizedEmail).HasFilter("[NormalizedEmail] IS NOT NULL"); - entity.HasIndex(e => e.NormalizedUserName).IsUnique().HasFilter("[NormalizedUserName] IS NOT NULL"); + var emailIndex = entity.HasIndex(e => e.Email).IsUnique(); + var userNameIndex = entity.HasIndex(e => e.UserName).IsUnique(); + var normalizedEmailIndex = entity.HasIndex(e => e.NormalizedEmail); + var normalizedUserNameIndex = entity.HasIndex(e => e.NormalizedUserName).IsUnique(); + + if (useSqlServerFilters) + { + emailIndex.HasFilter("[Email] IS NOT NULL"); + userNameIndex.HasFilter("[UserName] IS NOT NULL"); + normalizedEmailIndex.HasFilter("[NormalizedEmail] IS NOT NULL"); + normalizedUserNameIndex.HasFilter("[NormalizedUserName] IS NOT NULL"); + } }); modelBuilder.Entity(entity => { - entity.HasIndex(e => e.NormalizedName).IsUnique().HasFilter("[NormalizedName] IS NOT NULL"); + var normalizedNameIndex = entity.HasIndex(e => e.NormalizedName).IsUnique(); + + if (useSqlServerFilters) + { + normalizedNameIndex.HasFilter("[NormalizedName] IS NOT NULL"); + } }); - // Configure relationship tables with indexes modelBuilder.Entity>(entity => { entity.HasIndex(e => e.UserId); @@ -389,8 +430,11 @@ private void ApplyAuditInfo() /// Gets the current user ID from the application context /// /// The current user ID - protected virtual string? GetCurrentUserId() - { - return null; - } -} \ No newline at end of file + protected virtual string? GetCurrentUserId() => _executionContext?.UserId; + + /// + /// Gets the current tenant ID from the application context + /// + /// The current tenant ID + protected virtual string? GetCurrentTenantId() => _executionContext?.TenantId; +} diff --git a/src/MinimalCleanArch.DataAccess/README.md b/src/MinimalCleanArch.DataAccess/README.md index e602785..b7ad29c 100644 --- a/src/MinimalCleanArch.DataAccess/README.md +++ b/src/MinimalCleanArch.DataAccess/README.md @@ -11,6 +11,7 @@ Entity Framework Core implementation for MinimalCleanArch (repositories, unit of - `SpecificationEvaluator` to translate specifications (including composed `And/Or/Not`) to EF Core queries and honor `IsCountOnly`, `AsSplitQuery`, and `IgnoreQueryFilters`. - DI extensions to register repositories/unit of work. - Common repository query methods such as `AnyAsync`, `SingleOrDefaultAsync`, and `CountAsync(ISpecification)`. +- optional execution-context-aware base constructors for user and tenant-aware stamping ## Usage ```csharp @@ -29,6 +30,22 @@ builder.Services.AddMinimalCleanArch((sp, options) = }); ``` +Recommended DbContext base usage: + +```csharp +public sealed class AppDbContext : DbContextBase +{ + public AppDbContext( + DbContextOptions options, + IExecutionContext? executionContext = null) + : base(options, executionContext) + { + } +} +``` + +Use the constructor overload that accepts `IExecutionContext` when you want audit stamping to flow from the current HTTP request or message-handler scope without overriding `GetCurrentUserId()`. + ### Recommended specification usage ```csharp public sealed class IncompleteHighPrioritySpec : BaseSpecification diff --git a/src/MinimalCleanArch.Extensions/Execution/HttpExecutionContext.cs b/src/MinimalCleanArch.Extensions/Execution/HttpExecutionContext.cs new file mode 100644 index 0000000..6ade25e --- /dev/null +++ b/src/MinimalCleanArch.Extensions/Execution/HttpExecutionContext.cs @@ -0,0 +1,143 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using MinimalCleanArch.Execution; + +namespace MinimalCleanArch.Extensions.Execution; + +internal sealed class HttpExecutionContext : IExecutionContext +{ + private static readonly IReadOnlyDictionary EmptyMetadata = + new Dictionary(); + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ExecutionContextOptions _options; + private IReadOnlyDictionary? _metadata; + + public HttpExecutionContext( + IHttpContextAccessor httpContextAccessor, + IOptions? options = null) + { + _httpContextAccessor = httpContextAccessor; + _options = options?.Value ?? new ExecutionContextOptions(); + } + + public string? UserId + { + get + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return FindClaimValue(user, _options.UserIdClaimTypes); + } + } + + public string? UserName + { + get + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return FindClaimValue(user, _options.UserNameClaimTypes) + ?? user.Identity?.Name + ?? FindClaimValue(user, _options.UserNameFallbackClaimTypes); + } + } + + public string? TenantId + { + get + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return FindClaimValue(user, _options.TenantIdClaimTypes); + } + } + + public string? CorrelationId + { + get + { + var context = _httpContextAccessor.HttpContext; + if (context is null) + { + return null; + } + + if (context.Items.TryGetValue("CorrelationId", out var correlationId)) + { + return correlationId?.ToString(); + } + + return context.TraceIdentifier; + } + } + + public string? ClientIpAddress + { + get + { + var context = _httpContextAccessor.HttpContext; + if (context is null) + { + return null; + } + + var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(forwardedFor)) + { + return forwardedFor.Split(',').FirstOrDefault()?.Trim(); + } + + return context.Connection.RemoteIpAddress?.ToString(); + } + } + + public string? UserAgent => _httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString(); + + public IReadOnlyDictionary Metadata + { + get => _metadata ??= BuildMetadata(); + } + + private IReadOnlyDictionary BuildMetadata() + { + var context = _httpContextAccessor.HttpContext; + if (context is null) + { + return EmptyMetadata; + } + + return new Dictionary + { + ["RequestPath"] = context.Request.Path.ToString(), + ["RequestMethod"] = context.Request.Method + }; + } + + private static string? FindClaimValue(ClaimsPrincipal user, IEnumerable claimTypes) + { + foreach (var claimType in claimTypes) + { + var value = user.FindFirst(claimType)?.Value; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } +} diff --git a/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs b/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs index 98e63d1..f83d579 100644 --- a/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs +++ b/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs @@ -104,7 +104,6 @@ public static WebApplication UseMinimalCleanArchApiDefaults( configure?.Invoke(options); app.UseCorrelationId(options.CorrelationHeaderName); - app.UseGlobalErrorHandling(); if (options.UseApiSecurityHeaders) { @@ -115,6 +114,8 @@ public static WebApplication UseMinimalCleanArchApiDefaults( app.UseSecurityHeaders(options.SecurityHeadersOptions); } + app.UseGlobalErrorHandling(); + if (options.UseRateLimiting) { app.UseMinimalCleanArchRateLimiting(); diff --git a/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs b/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs index 7f4fca2..3151b5a 100644 --- a/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs +++ b/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using FluentValidation; using System.Reflection; +using MinimalCleanArch.Execution; +using MinimalCleanArch.Extensions.Execution; using MinimalCleanArch.Extensions.HealthChecks; using MinimalCleanArch.Extensions.Middlewares; using MinimalCleanArch.Extensions.RateLimiting; @@ -25,7 +28,9 @@ public static IServiceCollection AddMinimalCleanArchExtensions(this IServiceColl // Register correlation ID accessor services.AddHttpContextAccessor(); + services.AddOptions(); services.AddScoped(); + services.TryAddScoped(); // Register startup health check as singleton so it can be marked complete services.AddSingleton(); diff --git a/src/MinimalCleanArch.Extensions/README.md b/src/MinimalCleanArch.Extensions/README.md index cc0976f..c89b5a9 100644 --- a/src/MinimalCleanArch.Extensions/README.md +++ b/src/MinimalCleanArch.Extensions/README.md @@ -44,3 +44,13 @@ app.UseMinimalCleanArchApiDefaults(options => `AddMinimalCleanArchApi(...)` is the preferred entry point when you want a single bootstrap method. Use the explicit registrations when you need tighter control over the service graph. +Execution-context claim mapping can be customized without replacing `IExecutionContext`: + +```csharp +builder.Services.Configure(options => +{ + options.TenantIdClaimTypes.Clear(); + options.TenantIdClaimTypes.Add("business_id"); +}); +``` + diff --git a/src/MinimalCleanArch.Messaging/Execution/MessagingExecutionContext.cs b/src/MinimalCleanArch.Messaging/Execution/MessagingExecutionContext.cs new file mode 100644 index 0000000..5eebc39 --- /dev/null +++ b/src/MinimalCleanArch.Messaging/Execution/MessagingExecutionContext.cs @@ -0,0 +1,170 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using MinimalCleanArch.Execution; +using Wolverine; + +namespace MinimalCleanArch.Messaging.Execution; + +internal sealed class MessagingExecutionContext : IExecutionContext +{ + private const string UserIdHeader = "mca-user-id"; + private const string UserNameHeader = "mca-user-name"; + private const string TenantIdHeader = "mca-tenant-id"; + + private static readonly IReadOnlyDictionary EmptyMetadata = + new Dictionary(); + + private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly IMessageContext? _messageContext; + private readonly ExecutionContextOptions _options; + private IReadOnlyDictionary? _metadata; + + public MessagingExecutionContext( + IHttpContextAccessor? httpContextAccessor = null, + IMessageContext? messageContext = null, + IOptions? options = null) + { + _httpContextAccessor = httpContextAccessor; + _messageContext = messageContext; + _options = options?.Value ?? new ExecutionContextOptions(); + } + + public string? UserId => GetHeader(UserIdHeader) ?? GetHttpUserId(); + + public string? UserName => GetHeader(UserNameHeader) ?? GetHttpUserName(); + + public string? TenantId => GetMessageEnvelope()?.TenantId ?? GetHeader(TenantIdHeader) ?? GetHttpTenantId(); + + public string? CorrelationId => GetMessageEnvelope()?.CorrelationId ?? GetHttpCorrelationId(); + + public string? ClientIpAddress => GetHttpClientIpAddress(); + + public string? UserAgent => _httpContextAccessor?.HttpContext?.Request.Headers.UserAgent.ToString(); + + public IReadOnlyDictionary Metadata + { + get => _metadata ??= BuildMetadata(); + } + + private string? GetHeader(string key) + { + var envelope = GetMessageEnvelope(); + if (envelope?.Headers is null) + { + return null; + } + + return envelope.Headers.TryGetValue(key, out var value) ? value : null; + } + + private Envelope? GetMessageEnvelope() => _messageContext?.Envelope; + + private string? GetHttpUserId() + { + var user = _httpContextAccessor?.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return FindClaimValue(user, _options.UserIdClaimTypes); + } + + private string? GetHttpUserName() + { + var user = _httpContextAccessor?.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return FindClaimValue(user, _options.UserNameClaimTypes) + ?? user.Identity?.Name + ?? FindClaimValue(user, _options.UserNameFallbackClaimTypes); + } + + private string? GetHttpTenantId() + { + var user = _httpContextAccessor?.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return FindClaimValue(user, _options.TenantIdClaimTypes); + } + + private string? GetHttpCorrelationId() + { + var context = _httpContextAccessor?.HttpContext; + if (context is null) + { + return null; + } + + if (context.Items.TryGetValue("CorrelationId", out var correlationId)) + { + return correlationId?.ToString(); + } + + return context.TraceIdentifier; + } + + private string? GetHttpClientIpAddress() + { + var context = _httpContextAccessor?.HttpContext; + if (context is null) + { + return null; + } + + var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(forwardedFor)) + { + return forwardedFor.Split(',').FirstOrDefault()?.Trim(); + } + + return context.Connection.RemoteIpAddress?.ToString(); + } + + private IReadOnlyDictionary BuildMetadata() + { + var metadata = new Dictionary(); + + var envelope = GetMessageEnvelope(); + if (envelope?.Headers is { Count: > 0 } headers) + { + foreach (var header in headers) + { + if (header.Value is not null) + { + metadata[header.Key] = header.Value; + } + } + } + + var context = _httpContextAccessor?.HttpContext; + if (context is not null) + { + metadata["RequestPath"] = context.Request.Path.ToString(); + metadata["RequestMethod"] = context.Request.Method; + } + + return metadata.Count == 0 ? EmptyMetadata : metadata; + } + + private static string? FindClaimValue(ClaimsPrincipal user, IEnumerable claimTypes) + { + foreach (var claimType in claimTypes) + { + var value = user.FindFirst(claimType)?.Value; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } +} diff --git a/src/MinimalCleanArch.Messaging/Extensions/MessagingExtensions.cs b/src/MinimalCleanArch.Messaging/Extensions/MessagingExtensions.cs index 8160d8d..a22061b 100644 --- a/src/MinimalCleanArch.Messaging/Extensions/MessagingExtensions.cs +++ b/src/MinimalCleanArch.Messaging/Extensions/MessagingExtensions.cs @@ -1,6 +1,10 @@ using System.Reflection; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using MinimalCleanArch.Execution; +using MinimalCleanArch.Messaging.Execution; using Wolverine; using Wolverine.EntityFrameworkCore; using Wolverine.SqlServer; @@ -27,7 +31,7 @@ public static IHostApplicationBuilder AddMinimalCleanArchMessaging( var options = new MessagingOptions(); configure?.Invoke(options); - builder.Services.AddSingleton(options); + RegisterMessagingServices(builder.Services, options); var serviceName = options.ServiceName ?? Assembly.GetEntryAssembly()?.GetName().Name @@ -63,7 +67,7 @@ public static IHostApplicationBuilder AddMinimalCleanArchMessagingWithSqlServer( var options = new MessagingOptions(); configure?.Invoke(options); - builder.Services.AddSingleton(options); + RegisterMessagingServices(builder.Services, options); var serviceName = options.ServiceName ?? Assembly.GetEntryAssembly()?.GetName().Name @@ -82,6 +86,7 @@ public static IHostApplicationBuilder AddMinimalCleanArchMessagingWithSqlServer( // Durable mode for SQL Server - enables outbox pattern opts.Durability.Mode = DurabilityMode.Balanced; opts.Durability.ScheduledJobPollingTime = options.DurabilityPollingInterval; + ApplyDurabilityOptions(opts, options); }); // Register domain event services @@ -106,7 +111,7 @@ public static IHostApplicationBuilder AddMinimalCleanArchMessagingWithPostgres( var options = new MessagingOptions(); configure?.Invoke(options); - builder.Services.AddSingleton(options); + RegisterMessagingServices(builder.Services, options); var serviceName = options.ServiceName ?? Assembly.GetEntryAssembly()?.GetName().Name @@ -125,6 +130,7 @@ public static IHostApplicationBuilder AddMinimalCleanArchMessagingWithPostgres( // Durable mode for PostgreSQL - enables outbox pattern opts.Durability.Mode = DurabilityMode.Balanced; opts.Durability.ScheduledJobPollingTime = options.DurabilityPollingInterval; + ApplyDurabilityOptions(opts, options); }); // Register domain event services @@ -148,7 +154,7 @@ public static IHostApplicationBuilder AddMinimalCleanArchMessaging( var options = new MessagingOptions(); configureOptions(options); - builder.Services.AddSingleton(options); + RegisterMessagingServices(builder.Services, options); var serviceName = options.ServiceName ?? Assembly.GetEntryAssembly()?.GetName().Name @@ -193,10 +199,34 @@ private static void ConfigureWolverineBase( } // Configure local queue for domain events with parallelism - opts.LocalQueue("domain-events") + opts.LocalQueue(options.GetEffectiveLocalQueueName()) .MaximumParallelMessages(options.LocalQueueParallelism); // Auto-apply transactions opts.Policies.AutoApplyTransactions(); + + options.ConfigurePolicies?.Invoke(opts.Policies); + options.ConfigureFailurePolicies?.Invoke(opts); + } + + private static void RegisterMessagingServices( + IServiceCollection services, + MessagingOptions options) + { + services.AddHttpContextAccessor(); + services.AddOptions(); + services.AddSingleton(options); + services.Replace(ServiceDescriptor.Scoped()); + } + + private static void ApplyDurabilityOptions( + WolverineOptions opts, + MessagingOptions options) + { + opts.Durability.DeadLetterQueueExpirationEnabled = options.DeadLetterQueueExpirationEnabled; + if (options.DeadLetterQueueExpiration.HasValue) + { + opts.Durability.DeadLetterQueueExpiration = options.DeadLetterQueueExpiration.Value; + } } } diff --git a/src/MinimalCleanArch.Messaging/Extensions/MessagingOptions.cs b/src/MinimalCleanArch.Messaging/Extensions/MessagingOptions.cs index dc9ba22..e51137b 100644 --- a/src/MinimalCleanArch.Messaging/Extensions/MessagingOptions.cs +++ b/src/MinimalCleanArch.Messaging/Extensions/MessagingOptions.cs @@ -1,4 +1,6 @@ using System.Reflection; +using Wolverine.ErrorHandling; +using Wolverine; namespace MinimalCleanArch.Messaging.Extensions; @@ -15,11 +17,23 @@ public class MessagingOptions /// /// Gets or sets the database schema name for message persistence tables. - /// Only used with SQL Server persistence. + /// Used with SQL Server and PostgreSQL persistence. /// Default: "wolverine". /// public string SchemaName { get; set; } = "wolverine"; + /// + /// Gets or sets the base local queue name used for in-process domain event handling. + /// Default: "domain-events". + /// + public string LocalQueueName { get; set; } = "domain-events"; + + /// + /// Gets or sets an optional prefix applied to generated local queue names. + /// Default: no prefix. + /// + public string? QueuePrefix { get; set; } + /// /// Gets or sets the number of parallel listeners for the local queue. /// Default: Environment.ProcessorCount. @@ -32,6 +46,28 @@ public class MessagingOptions /// public TimeSpan DurabilityPollingInterval { get; set; } = TimeSpan.FromSeconds(5); + /// + /// Gets or sets a value indicating whether persisted dead-letter messages expire automatically. + /// Default: false. + /// + public bool DeadLetterQueueExpirationEnabled { get; set; } + + /// + /// Gets or sets how long persisted dead-letter messages are retained when expiration is enabled. + /// Default: Wolverine default. + /// + public TimeSpan? DeadLetterQueueExpiration { get; set; } + + /// + /// Gets or sets an optional hook for configuring message failure policies. + /// + public Action? ConfigureFailurePolicies { get; set; } + + /// + /// Gets or sets an optional hook for configuring Wolverine policies without dropping to the full options callback. + /// + public Action? ConfigurePolicies { get; set; } + /// /// Gets or sets assemblies to scan for message handlers. /// If empty, scans the entry assembly. @@ -59,4 +95,15 @@ public MessagingOptions IncludeAssemblyContaining() HandlerAssemblies.Add(typeof(T).Assembly); return this; } + + /// + /// Gets the effective local queue name after applying the optional prefix. + /// + /// The effective queue name. + public string GetEffectiveLocalQueueName() + { + return string.IsNullOrWhiteSpace(QueuePrefix) + ? LocalQueueName + : $"{QueuePrefix}{LocalQueueName}"; + } } diff --git a/src/MinimalCleanArch.Messaging/README.md b/src/MinimalCleanArch.Messaging/README.md index 85335db..8224c0d 100644 --- a/src/MinimalCleanArch.Messaging/README.md +++ b/src/MinimalCleanArch.Messaging/README.md @@ -22,6 +22,7 @@ builder.AddMinimalCleanArchMessaging(options => { options.IncludeAssembly(typeof(AssemblyReference).Assembly); options.ServiceName = "MyApp"; + options.QueuePrefix = "myapp-"; }); ``` @@ -32,6 +33,23 @@ builder.AddMinimalCleanArchMessagingWithPostgres(connectionString, options => { options.IncludeAssembly(typeof(AssemblyReference).Assembly); options.ServiceName = "MyApp"; + options.SchemaName = "messaging"; + options.DeadLetterQueueExpirationEnabled = true; + options.DeadLetterQueueExpiration = TimeSpan.FromDays(7); +}); +``` + +Failure policies: + +```csharp +builder.AddMinimalCleanArchMessaging(options => +{ + options.ConfigureFailurePolicies = policies => + { + policies.OnException().RetryWithCooldown( + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5)); + }; }); ``` @@ -42,8 +60,20 @@ Use this package when: Preferred guidance: - use the `AddMinimalCleanArchMessaging...` extensions as the entry point -- keep app-specific queue, retry, and transport tuning in the provided callback +- use `QueuePrefix`, `LocalQueueName`, dead-letter expiration settings, and failure-policy hooks for the common cases +- keep transport-specific edge cases in the provided raw Wolverine callback when needed - avoid duplicating domain event publishing logic in application DbContexts +- rely on `IExecutionContext` for correlation and tenant data inside message handlers + +Claim resolution for the built-in execution-context implementations can be customized with `ExecutionContextOptions`: + +```csharp +builder.Services.Configure(options => +{ + options.UserNameClaimTypes.Clear(); + options.UserNameClaimTypes.Add("preferred_username"); +}); +``` When using a local feed, add a `nuget.config` pointing to your local packages folder and keep `nuget.org` available unless your feed mirrors all external dependencies. diff --git a/src/MinimalCleanArch/Execution/ExecutionContextOptions.cs b/src/MinimalCleanArch/Execution/ExecutionContextOptions.cs new file mode 100644 index 0000000..d452bc7 --- /dev/null +++ b/src/MinimalCleanArch/Execution/ExecutionContextOptions.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; + +namespace MinimalCleanArch.Execution; + +/// +/// Configures claim resolution for built-in execution-context implementations. +/// +public sealed class ExecutionContextOptions +{ + /// + /// Gets the claim types used to resolve the current user identifier. + /// + public IList UserIdClaimTypes { get; } = + [ + ClaimTypes.NameIdentifier, + "sub" + ]; + + /// + /// Gets the claim types used to resolve the current user name before . + /// + public IList UserNameClaimTypes { get; } = + [ + ClaimTypes.Name, + "name", + "preferred_username" + ]; + + /// + /// Gets the claim types used as a final fallback for the current user name. + /// + public IList UserNameFallbackClaimTypes { get; } = + [ + ClaimTypes.Email, + "email" + ]; + + /// + /// Gets the claim types used to resolve the current tenant identifier. + /// + public IList TenantIdClaimTypes { get; } = + [ + "tenant_id" + ]; +} diff --git a/src/MinimalCleanArch/Execution/IExecutionContext.cs b/src/MinimalCleanArch/Execution/IExecutionContext.cs new file mode 100644 index 0000000..f243cbf --- /dev/null +++ b/src/MinimalCleanArch/Execution/IExecutionContext.cs @@ -0,0 +1,43 @@ +namespace MinimalCleanArch.Execution; + +/// +/// Read-only access to user, tenant, correlation, and request or message metadata +/// for the current execution scope. +/// +public interface IExecutionContext +{ + /// + /// Gets the current user identifier. + /// + string? UserId { get; } + + /// + /// Gets the current user display name or principal name. + /// + string? UserName { get; } + + /// + /// Gets the current tenant identifier. + /// + string? TenantId { get; } + + /// + /// Gets the current correlation identifier. + /// + string? CorrelationId { get; } + + /// + /// Gets the originating client IP address when available. + /// + string? ClientIpAddress { get; } + + /// + /// Gets the originating user agent when available. + /// + string? UserAgent { get; } + + /// + /// Gets additional execution metadata for the current scope. + /// + IReadOnlyDictionary Metadata { get; } +} diff --git a/src/MinimalCleanArch/Execution/NullExecutionContext.cs b/src/MinimalCleanArch/Execution/NullExecutionContext.cs new file mode 100644 index 0000000..b89e66c --- /dev/null +++ b/src/MinimalCleanArch/Execution/NullExecutionContext.cs @@ -0,0 +1,40 @@ +namespace MinimalCleanArch.Execution; + +/// +/// Empty execution context used when no request or message scope data is available. +/// +public sealed class NullExecutionContext : IExecutionContext +{ + /// + /// Gets the shared empty execution-context instance. + /// + public static NullExecutionContext Instance { get; } = new(); + + private static readonly IReadOnlyDictionary EmptyMetadata = + new Dictionary(); + + private NullExecutionContext() + { + } + + /// + public string? UserId => null; + + /// + public string? UserName => null; + + /// + public string? TenantId => null; + + /// + public string? CorrelationId => null; + + /// + public string? ClientIpAddress => null; + + /// + public string? UserAgent => null; + + /// + public IReadOnlyDictionary Metadata => EmptyMetadata; +} diff --git a/src/MinimalCleanArch/Repositories/IRepository.cs b/src/MinimalCleanArch/Repositories/IRepository.cs index 062d3bb..d3a2d56 100644 --- a/src/MinimalCleanArch/Repositories/IRepository.cs +++ b/src/MinimalCleanArch/Repositories/IRepository.cs @@ -45,20 +45,34 @@ Task> GetAsync( /// /// The filter expression. /// A token to observe while waiting for the task to complete. + /// + /// This default implementation is a compatibility fallback for direct repository implementors. + /// Database-backed repositories should override it with an efficient translated query. + /// /// A task that represents the asynchronous operation. - Task AnyAsync( + async Task AnyAsync( Expression> filter, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + { + return await GetFirstAsync(filter, cancellationToken).ConfigureAwait(false) is not null; + } /// /// Determines whether any entity matches the specified specification. /// /// The specification. /// A token to observe while waiting for the task to complete. + /// + /// This default implementation is a compatibility fallback for direct repository implementors. + /// Database-backed repositories should override it with an efficient translated query. + /// /// A task that represents the asynchronous operation. - Task AnyAsync( + async Task AnyAsync( ISpecification specification, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + { + return await GetFirstAsync(specification, cancellationToken).ConfigureAwait(false) is not null; + } /// /// Gets a single entity by its key @@ -94,10 +108,23 @@ Task AnyAsync( /// /// The filter expression. /// A token to observe while waiting for the task to complete. + /// + /// This default implementation is a compatibility fallback for direct repository implementors. + /// Database-backed repositories should override it with an efficient translated query. + /// /// A task that represents the asynchronous operation. - Task SingleOrDefaultAsync( + async Task SingleOrDefaultAsync( Expression> filter, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + { + var results = await GetAsync(filter, cancellationToken).ConfigureAwait(false); + return results.Count switch + { + 0 => default, + 1 => results[0], + _ => throw new InvalidOperationException("Sequence contains more than one matching element.") + }; + } /// /// Gets a single entity that matches the specified specification, or null if none exists. @@ -105,10 +132,23 @@ Task AnyAsync( /// /// The specification. /// A token to observe while waiting for the task to complete. + /// + /// This default implementation is a compatibility fallback for direct repository implementors. + /// Database-backed repositories should override it with an efficient translated query. + /// /// A task that represents the asynchronous operation. - Task SingleOrDefaultAsync( + async Task SingleOrDefaultAsync( ISpecification specification, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + { + var results = await GetAsync(specification, cancellationToken).ConfigureAwait(false); + return results.Count switch + { + 0 => default, + 1 => results[0], + _ => throw new InvalidOperationException("Sequence contains more than one matching element.") + }; + } /// /// Counts entities that match the filter @@ -125,10 +165,18 @@ Task CountAsync( /// /// The specification. /// A token to observe while waiting for the task to complete. + /// + /// This default implementation is a compatibility fallback for direct repository implementors. + /// Database-backed repositories should override it with an efficient translated query. + /// /// A task that represents the asynchronous operation. - Task CountAsync( + async Task CountAsync( ISpecification specification, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default) + { + var results = await GetAsync(specification, cancellationToken).ConfigureAwait(false); + return results.Count; + } /// /// Adds an entity (does not save to database until SaveChanges is called) @@ -194,4 +242,4 @@ Task CountAsync( public interface IRepository : IRepository where TEntity : IEntity { -} \ No newline at end of file +} diff --git a/templates/mca/multi/MCA.Infrastructure/Data/AppDbContext.cs b/templates/mca/multi/MCA.Infrastructure/Data/AppDbContext.cs index c325044..3f8d42a 100644 --- a/templates/mca/multi/MCA.Infrastructure/Data/AppDbContext.cs +++ b/templates/mca/multi/MCA.Infrastructure/Data/AppDbContext.cs @@ -1,5 +1,7 @@ using MCA.Domain.Entities; using Microsoft.EntityFrameworkCore; +using MinimalCleanArch.DataAccess; +using MinimalCleanArch.Execution; #if (UseSecurity) using MinimalCleanArch.Security.Encryption; #endif @@ -19,9 +21,9 @@ namespace MCA.Infrastructure.Data; /// Application database context. /// #if (UseAuth) -public class AppDbContext : IdentityDbContext, Guid> +public class AppDbContext : IdentityDbContextBase, Guid> #else -public class AppDbContext : DbContext +public class AppDbContext : DbContextBase #endif { #if (UseSecurity) @@ -34,14 +36,19 @@ public class AppDbContext : DbContext #endif #if (UseSecurity) - public AppDbContext(DbContextOptions options, IEncryptionService? encryptionService = null) - : base(options) + public AppDbContext( + DbContextOptions options, + IEncryptionService? encryptionService = null, + IExecutionContext? executionContext = null) + : base(options, executionContext) { _encryptionService = encryptionService; } #else - public AppDbContext(DbContextOptions options) - : base(options) + public AppDbContext( + DbContextOptions options, + IExecutionContext? executionContext = null) + : base(options, executionContext) { } #endif @@ -68,41 +75,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id); entity.Property(e => e.Title).HasMaxLength(200).IsRequired(); entity.Property(e => e.Description).HasMaxLength(1000); - entity.HasQueryFilter(e => !e.IsDeleted); }); #if (UseAudit) modelBuilder.UseAuditLog(); #endif } - - public override int SaveChanges() - { - UpdateTimestamps(); - return base.SaveChanges(); - } - - public override Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - UpdateTimestamps(); - return base.SaveChangesAsync(cancellationToken); - } - - private void UpdateTimestamps() - { - var entries = ChangeTracker.Entries() - .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified); - - foreach (var entry in entries) - { - if (entry.Entity is IAuditableEntity entity) - { - if (entry.State == EntityState.Added) - { - entity.CreatedAt = DateTime.UtcNow; - } - entity.LastModifiedAt = DateTime.UtcNow; - } - } - } } diff --git a/templates/mca/single/Infrastructure/Data/AppDbContext.cs b/templates/mca/single/Infrastructure/Data/AppDbContext.cs index 6995b9c..02cc29f 100644 --- a/templates/mca/single/Infrastructure/Data/AppDbContext.cs +++ b/templates/mca/single/Infrastructure/Data/AppDbContext.cs @@ -1,5 +1,7 @@ using MCA.Domain.Entities; using Microsoft.EntityFrameworkCore; +using MinimalCleanArch.DataAccess; +using MinimalCleanArch.Execution; #if (UseSecurity) using MinimalCleanArch.Security.Encryption; #endif @@ -18,9 +20,9 @@ namespace MCA.Infrastructure.Data; /// Application database context. /// #if (UseAuth) -public class AppDbContext : IdentityDbContext, Guid> +public class AppDbContext : IdentityDbContextBase, Guid> #else -public class AppDbContext : DbContext +public class AppDbContext : DbContextBase #endif { #if (UseSecurity) @@ -33,14 +35,19 @@ public class AppDbContext : DbContext #endif #if (UseSecurity) - public AppDbContext(DbContextOptions options, IEncryptionService? encryptionService = null) - : base(options) + public AppDbContext( + DbContextOptions options, + IEncryptionService? encryptionService = null, + IExecutionContext? executionContext = null) + : base(options, executionContext) { _encryptionService = encryptionService; } #else - public AppDbContext(DbContextOptions options) - : base(options) + public AppDbContext( + DbContextOptions options, + IExecutionContext? executionContext = null) + : base(options, executionContext) { } #endif @@ -66,38 +73,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(e => e.Id); entity.Property(e => e.Title).HasMaxLength(200).IsRequired(); entity.Property(e => e.Description).HasMaxLength(1000); - entity.HasQueryFilter(e => !e.IsDeleted); }); #if (UseAudit) modelBuilder.UseAuditLog(); #endif } - - public override int SaveChanges() - { - UpdateTimestamps(); - return base.SaveChanges(); - } - - public override Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - UpdateTimestamps(); - return base.SaveChangesAsync(cancellationToken); - } - - private void UpdateTimestamps() - { - var entries = ChangeTracker.Entries() - .Where(e => e.State == EntityState.Added || e.State == EntityState.Modified); - - foreach (var entry in entries) - { - if (entry.State == EntityState.Added) - { - entry.Entity.CreatedAt = DateTime.UtcNow; - } - entry.Entity.LastModifiedAt = DateTime.UtcNow; - } - } } diff --git a/templates/scripts/validate-templates.ps1 b/templates/scripts/validate-templates.ps1 index 889be58..cd93dac 100644 --- a/templates/scripts/validate-templates.ps1 +++ b/templates/scripts/validate-templates.ps1 @@ -53,6 +53,34 @@ $($sources -join "`n") Set-Content -Path $ConfigPath -Value $configContent -Encoding UTF8 } +function Clear-LocalMinimalCleanArchPackageCache { + param( + [string]$Version + ) + + if ([string]::IsNullOrWhiteSpace($Version)) { + return + } + + $globalPackages = if (-not [string]::IsNullOrWhiteSpace($env:NUGET_PACKAGES)) { + $env:NUGET_PACKAGES + } else { + Join-Path $env:USERPROFILE ".nuget/packages" + } + + if (-not (Test-Path -Path $globalPackages -PathType Container)) { + return + } + + Get-ChildItem -Path $globalPackages -Directory -Filter "minimalcleanarch*" -ErrorAction SilentlyContinue | + ForEach-Object { + $versionPath = Join-Path $_.FullName $Version + if (Test-Path -Path $versionPath -PathType Container) { + Remove-Item -Path $versionPath -Recurse -Force + } + } +} + $resolvedFeed = Resolve-Path -Path $LocalFeedPath -ErrorAction SilentlyContinue if (-not $resolvedFeed) { throw "Local feed path not found: $LocalFeedPath" @@ -87,6 +115,8 @@ if ($IncludeNugetOrg) { Write-Host "==> Using nuget.org in restore sources" } +Clear-LocalMinimalCleanArchPackageCache -Version $McaVersion + # Clean template cache and uninstall any installed package Invoke-Checked -Description "Uninstalling previous template package (if installed)" -AllowFailure -Action { dotnet new uninstall MinimalCleanArch.Templates | Out-Null diff --git a/tests/MinimalCleanArch.UnitTests/Execution/ExecutionContextTests.cs b/tests/MinimalCleanArch.UnitTests/Execution/ExecutionContextTests.cs new file mode 100644 index 0000000..ac06ed5 --- /dev/null +++ b/tests/MinimalCleanArch.UnitTests/Execution/ExecutionContextTests.cs @@ -0,0 +1,122 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalCleanArch.Execution; +using MinimalCleanArch.Extensions.Extensions; +using MinimalCleanArch.Messaging.Extensions; + +namespace MinimalCleanArch.UnitTests.Execution; + +public class ExecutionContextTests +{ + [Fact] + public void HttpExecutionContext_PrefersNameOverEmail() + { + var provider = BuildServiceProvider(); + var accessor = provider.GetRequiredService(); + accessor.HttpContext = CreateHttpContext( + new Claim(ClaimTypes.Name, "Bob Smith"), + new Claim(ClaimTypes.Email, "bob@example.com")); + + var executionContext = provider.GetRequiredService(); + + executionContext.UserName.Should().Be("Bob Smith"); + } + + [Fact] + public void HttpExecutionContext_UsesConfiguredTenantClaimType() + { + var provider = BuildServiceProvider(options => + { + options.TenantIdClaimTypes.Clear(); + options.TenantIdClaimTypes.Add("business_id"); + }); + + var accessor = provider.GetRequiredService(); + accessor.HttpContext = CreateHttpContext(new Claim("business_id", "tenant-42")); + + var executionContext = provider.GetRequiredService(); + + executionContext.TenantId.Should().Be("tenant-42"); + } + + [Fact] + public void HttpExecutionContext_Metadata_IsCachedPerScope() + { + var provider = BuildServiceProvider(); + var accessor = provider.GetRequiredService(); + accessor.HttpContext = CreateHttpContext(); + + var executionContext = provider.GetRequiredService(); + + executionContext.Metadata.Should().BeSameAs(executionContext.Metadata); + } + + [Fact] + public void MessagingRegistration_ReplacesDefaultExecutionContextRegistration() + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddMinimalCleanArchExtensions(); + + builder.AddMinimalCleanArchMessaging(); + + var executionContextRegistrations = builder.Services + .Where(x => x.ServiceType == typeof(IExecutionContext)) + .ToList(); + + executionContextRegistrations.Should().ContainSingle(); + executionContextRegistrations[0].ImplementationType.Should().NotBeNull(); + executionContextRegistrations[0].ImplementationType!.Name.Should().Be("MessagingExecutionContext"); + } + + [Fact] + public void MessagingExecutionContext_UsesHttpFallbackWithConfiguredClaims() + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.Configure(options => + { + options.TenantIdClaimTypes.Clear(); + options.TenantIdClaimTypes.Add("business_id"); + }); + + builder.AddMinimalCleanArchMessaging(); + + using var host = builder.Build(); + using var scope = host.Services.CreateScope(); + var accessor = scope.ServiceProvider.GetRequiredService(); + accessor.HttpContext = CreateHttpContext( + new Claim(ClaimTypes.Name, "Bob Smith"), + new Claim(ClaimTypes.Email, "bob@example.com"), + new Claim("business_id", "tenant-42")); + + var executionContext = scope.ServiceProvider.GetRequiredService(); + + executionContext.UserName.Should().Be("Bob Smith"); + executionContext.TenantId.Should().Be("tenant-42"); + } + + private static ServiceProvider BuildServiceProvider(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddMinimalCleanArchExtensions(); + + if (configure is not null) + { + services.Configure(configure); + } + + return services.BuildServiceProvider(); + } + + private static HttpContext CreateHttpContext(params Claim[] claims) + { + var context = new DefaultHttpContext(); + var identity = new ClaimsIdentity(claims, authenticationType: "Test"); + context.User = new ClaimsPrincipal(identity); + context.Request.Path = "/todos"; + context.Request.Method = HttpMethods.Get; + return context; + } +} diff --git a/tests/MinimalCleanArch.UnitTests/Messaging/MessagingOptionsTests.cs b/tests/MinimalCleanArch.UnitTests/Messaging/MessagingOptionsTests.cs index 43daecf..77401af 100644 --- a/tests/MinimalCleanArch.UnitTests/Messaging/MessagingOptionsTests.cs +++ b/tests/MinimalCleanArch.UnitTests/Messaging/MessagingOptionsTests.cs @@ -28,6 +28,16 @@ public void MessagingOptions_ShouldHaveDefaultSchemaName() options.SchemaName.Should().Be("wolverine"); } + [Fact] + public void MessagingOptions_ShouldHaveDefaultLocalQueueName() + { + // Arrange & Act + var options = new MessagingOptions(); + + // Assert + options.LocalQueueName.Should().Be("domain-events"); + } + [Fact] public void MessagingOptions_ShouldHaveDefaultLocalQueueParallelism() { @@ -48,6 +58,17 @@ public void MessagingOptions_ShouldHaveDefaultDurabilityPollingInterval() options.DurabilityPollingInterval.Should().Be(TimeSpan.FromSeconds(5)); } + [Fact] + public void MessagingOptions_ShouldHaveDeadLetterExpirationDisabledByDefault() + { + // Arrange & Act + var options = new MessagingOptions(); + + // Assert + options.DeadLetterQueueExpirationEnabled.Should().BeFalse(); + options.DeadLetterQueueExpiration.Should().BeNull(); + } + [Fact] public void MessagingOptions_ShouldHaveEmptyHandlerAssembliesByDefault() { @@ -88,6 +109,32 @@ public void MessagingOptions_SchemaName_ShouldBeSettable() options.SchemaName.Should().Be("custom_schema"); } + [Fact] + public void MessagingOptions_LocalQueueName_ShouldBeSettable() + { + // Arrange + var options = new MessagingOptions(); + + // Act + options.LocalQueueName = "orders"; + + // Assert + options.LocalQueueName.Should().Be("orders"); + } + + [Fact] + public void MessagingOptions_QueuePrefix_ShouldBeSettable() + { + // Arrange + var options = new MessagingOptions(); + + // Act + options.QueuePrefix = "sales-"; + + // Assert + options.QueuePrefix.Should().Be("sales-"); + } + [Fact] public void MessagingOptions_LocalQueueParallelism_ShouldBeSettable() { @@ -114,6 +161,21 @@ public void MessagingOptions_DurabilityPollingInterval_ShouldBeSettable() options.DurabilityPollingInterval.Should().Be(TimeSpan.FromSeconds(30)); } + [Fact] + public void MessagingOptions_DeadLetterExpiration_ShouldBeSettable() + { + // Arrange + var options = new MessagingOptions(); + + // Act + options.DeadLetterQueueExpirationEnabled = true; + options.DeadLetterQueueExpiration = TimeSpan.FromDays(7); + + // Assert + options.DeadLetterQueueExpirationEnabled.Should().BeTrue(); + options.DeadLetterQueueExpiration.Should().Be(TimeSpan.FromDays(7)); + } + #endregion #region IncludeAssembly Tests @@ -254,8 +316,12 @@ public void MessagingOptions_TypicalConfiguration_ShouldWork() { ServiceName = "OrderService", SchemaName = "orders", + LocalQueueName = "orders-events", + QueuePrefix = "svc-", LocalQueueParallelism = 8, - DurabilityPollingInterval = TimeSpan.FromSeconds(10) + DurabilityPollingInterval = TimeSpan.FromSeconds(10), + DeadLetterQueueExpirationEnabled = true, + DeadLetterQueueExpiration = TimeSpan.FromDays(5) }; options.IncludeAssemblyContaining(); @@ -263,11 +329,38 @@ public void MessagingOptions_TypicalConfiguration_ShouldWork() // Assert options.ServiceName.Should().Be("OrderService"); options.SchemaName.Should().Be("orders"); + options.LocalQueueName.Should().Be("orders-events"); + options.QueuePrefix.Should().Be("svc-"); options.LocalQueueParallelism.Should().Be(8); options.DurabilityPollingInterval.Should().Be(TimeSpan.FromSeconds(10)); + options.DeadLetterQueueExpirationEnabled.Should().BeTrue(); + options.DeadLetterQueueExpiration.Should().Be(TimeSpan.FromDays(5)); options.HandlerAssemblies.Should().HaveCount(1); } + [Fact] + public void GetEffectiveLocalQueueName_ShouldApplyPrefix_WhenPresent() + { + var options = new MessagingOptions + { + QueuePrefix = "svc-", + LocalQueueName = "domain-events" + }; + + options.GetEffectiveLocalQueueName().Should().Be("svc-domain-events"); + } + + [Fact] + public void GetEffectiveLocalQueueName_ShouldReturnBaseName_WhenPrefixMissing() + { + var options = new MessagingOptions + { + LocalQueueName = "domain-events" + }; + + options.GetEffectiveLocalQueueName().Should().Be("domain-events"); + } + [Fact] public void MessagingOptions_ActionConfiguration_ShouldWork() { diff --git a/tests/MinimalCleanArch.UnitTests/Repositories/RepositoryDefaultInterfaceTests.cs b/tests/MinimalCleanArch.UnitTests/Repositories/RepositoryDefaultInterfaceTests.cs new file mode 100644 index 0000000..1f64c01 --- /dev/null +++ b/tests/MinimalCleanArch.UnitTests/Repositories/RepositoryDefaultInterfaceTests.cs @@ -0,0 +1,139 @@ +using System.Linq.Expressions; +using FluentAssertions; +using MinimalCleanArch.Domain.Entities; +using MinimalCleanArch.Repositories; +using MinimalCleanArch.Specifications; + +namespace MinimalCleanArch.UnitTests.Repositories; + +public class RepositoryDefaultInterfaceTests +{ + [Fact] + public async Task AnyAsync_UsesDefaultImplementation_ForDirectImplementors() + { + IRepository repository = new InMemoryTodoRepository(); + + var result = await repository.AnyAsync(todo => todo.Title == "Second"); + + result.Should().BeTrue(); + } + + [Fact] + public async Task SingleOrDefaultAsync_UsesDefaultImplementation_ForDirectImplementors() + { + IRepository repository = new InMemoryTodoRepository(); + + var result = await repository.SingleOrDefaultAsync(todo => todo.Title == "First"); + + result.Should().NotBeNull(); + result!.Title.Should().Be("First"); + } + + [Fact] + public async Task CountAsync_WithSpecification_UsesDefaultImplementation_ForDirectImplementors() + { + IRepository repository = new InMemoryTodoRepository(); + var specification = new TodoTitleSpecification("Second"); + + var result = await repository.CountAsync(specification); + + result.Should().Be(1); + } + + private sealed class InMemoryTodoRepository : IRepository + { + private readonly List _items = + [ + new TestTodo(1, "First"), + new TestTodo(2, "Second") + ]; + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(_items); + + public Task> GetAsync(Expression> filter, CancellationToken cancellationToken = default) + => Task.FromResult>(_items.AsQueryable().Where(filter).ToList()); + + public Task> GetAsync(ISpecification specification, CancellationToken cancellationToken = default) + => Task.FromResult>(InMemorySpecificationEvaluator.Evaluate(_items, specification).ToList()); + + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + => Task.FromResult(_items.SingleOrDefault(x => x.Id == id)); + + public Task GetFirstAsync(Expression> filter, CancellationToken cancellationToken = default) + => Task.FromResult(_items.AsQueryable().FirstOrDefault(filter)); + + public Task GetFirstAsync(ISpecification specification, CancellationToken cancellationToken = default) + => Task.FromResult(InMemorySpecificationEvaluator.Evaluate(_items, specification).FirstOrDefault()); + + public Task CountAsync(Expression>? filter = null, CancellationToken cancellationToken = default) + => Task.FromResult(filter is null ? _items.Count : _items.AsQueryable().Count(filter)); + + public Task AddAsync(TestTodo entity, CancellationToken cancellationToken = default) + { + _items.Add(entity); + return Task.FromResult(entity); + } + + public Task> AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var materialized = entities.ToList(); + _items.AddRange(materialized); + return Task.FromResult>(materialized); + } + + public Task UpdateAsync(TestTodo entity, CancellationToken cancellationToken = default) + => Task.FromResult(entity); + + public Task> UpdateRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + => Task.FromResult(entities); + + public Task DeleteAsync(TestTodo entity, CancellationToken cancellationToken = default) + { + _items.Remove(entity); + return Task.CompletedTask; + } + + public Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var entity = _items.SingleOrDefault(x => x.Id == id); + if (entity is not null) + { + _items.Remove(entity); + } + + return Task.CompletedTask; + } + + public Task DeleteRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + foreach (var entity in entities.ToList()) + { + _items.Remove(entity); + } + + return Task.CompletedTask; + } + } + + private sealed class TodoTitleSpecification : BaseSpecification + { + public TodoTitleSpecification(string title) + : base(todo => todo.Title == title) + { + } + } + + private sealed class TestTodo : IEntity + { + public TestTodo(int id, string title) + { + Id = id; + Title = title; + } + + public int Id { get; set; } + + public string Title { get; } + } +}