diff --git a/README.md b/README.md index 6787cde..307b9b0 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,24 @@ A Clean Architecture toolkit for Minimal APIs on .NET 9 and .NET 10. - Soft delete and auditing support - EF Core integration with specification evaluation +## Recommended Package Sets +- Core domain and repository abstractions: `MinimalCleanArch` +- EF Core repositories and specifications: `MinimalCleanArch.DataAccess` +- Minimal API bootstrap, error mapping, OpenAPI, rate limiting: `MinimalCleanArch.Extensions` +- FluentValidation registration: `MinimalCleanArch.Validation` +- Domain events and Wolverine integration: `MinimalCleanArch.Messaging` +- Audit interception and audit queries: `MinimalCleanArch.Audit` +- Encrypted EF properties and encryption services: `MinimalCleanArch.Security` +- Project scaffolding: `MinimalCleanArch.Templates` + ## Versions - Stable packages/templates: `0.1.14` -- Next preview line: `0.1.15-preview` +- Next preview line: `0.1.16-preview` + +## Local Validation +- Template validation uses two package sources by default: the local `MinimalCleanArch` feed and `nuget.org`. +- This is required because generated projects reference both `MinimalCleanArch.*` packages and pinned third-party packages. +- Use local-feed-only validation only if your feed mirrors every external dependency used by the templates. ## Try It Fast @@ -27,13 +42,28 @@ Then open `https://localhost:/scalar/v1`. For auth + OpenIddict + Scalar password flow: ```bash -dotnet new mca -n QuickAuth --single-project --auth --tests --mcaVersion 0.1.14 +dotnet new mca -n QuickAuth --single-project --auth --tests --mcaVersion 0.1.16-preview cd QuickAuth dotnet run ``` Use the auth walkthrough in [`templates/README.md`](templates/README.md). +## Preferred Integration Path +For new applications, the recommended order is: +1. Model entities, repository contracts, and specifications with `MinimalCleanArch`. +2. Add EF Core repositories and unit of work with `MinimalCleanArch.DataAccess`. +3. Add API bootstrap with `MinimalCleanArch.Extensions`. +4. Register application validators with `MinimalCleanArch.Validation`. +5. Add `MinimalCleanArch.Messaging`, `MinimalCleanArch.Audit`, and `MinimalCleanArch.Security` only when the app actually needs them. + +Preferred defaults: +- use specifications through `IRepository` +- use `AddMinimalCleanArchApi(...)` as the main API bootstrap method +- use `AddValidationFromAssemblyContaining()` for validator registration +- use `AddMinimalCleanArchMessaging...` extensions instead of wiring Wolverine from scratch +- use Data Protection-based encryption for new development + ## Packages | Package | Description | | :-- | :-- | diff --git a/samples/MinimalCleanArch.Sample/Program.cs b/samples/MinimalCleanArch.Sample/Program.cs index 92a7723..c84284b 100644 --- a/samples/MinimalCleanArch.Sample/Program.cs +++ b/samples/MinimalCleanArch.Sample/Program.cs @@ -21,6 +21,7 @@ using MinimalCleanArch.Sample.Infrastructure.Services; using MinimalCleanArch.Security.Configuration; using MinimalCleanArch.Security.Extensions; +using MinimalCleanArch.Validation.Extensions; using Scalar.AspNetCore; using Serilog; @@ -171,7 +172,7 @@ } // Add validation services - builder.Services.AddValidatorsFromAssemblyContaining(); + builder.Services.AddValidationFromAssemblyContaining(); // Add caching services (in-memory by default) builder.Services.AddMinimalCleanArchCaching(options => diff --git a/samples/MinimalCleanArch.Sample/README.md b/samples/MinimalCleanArch.Sample/README.md index 651ca3a..b8b57df 100644 --- a/samples/MinimalCleanArch.Sample/README.md +++ b/samples/MinimalCleanArch.Sample/README.md @@ -118,6 +118,13 @@ MinimalCleanArch.Sample/ 1. `Infrastructure/Specifications/` for filtering logic. 1. `Infrastructure/Seeders/` for startup data seeding. +## What the sample is meant to show +- specifications composed in application-facing query paths +- validators registered once and reused by endpoints +- result-to-HTTP mapping without controller-specific plumbing +- encrypted properties and audit support without leaking infrastructure into the domain model +- a concrete reference for how the packages fit together in one app + ## Notes - SQLite is used by default (`Data Source=minimalcleanarch.db`). diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7eeabc9..4d94eca 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -11,7 +11,7 @@ - 0.1.14 + 0.1.16-preview Abdullah D. Waanfeetan MinimalCleanArch diff --git a/src/MinimalCleanArch.Audit/Entities/AuditLog.cs b/src/MinimalCleanArch.Audit/Entities/AuditLog.cs index 66cf66b..b757144 100644 --- a/src/MinimalCleanArch.Audit/Entities/AuditLog.cs +++ b/src/MinimalCleanArch.Audit/Entities/AuditLog.cs @@ -35,6 +35,11 @@ public class AuditLog /// public string? UserName { get; set; } + /// + /// Optional tenant or organization identifier associated with the change. + /// + public string? TenantId { get; set; } + /// /// UTC timestamp when the change occurred. /// diff --git a/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs b/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs index 6fbaa5f..d27b6ae 100644 --- a/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs +++ b/src/MinimalCleanArch.Audit/Extensions/AuditExtensions.cs @@ -123,6 +123,9 @@ public static ModelBuilder UseAuditLog( entity.Property(e => e.UserName) .HasMaxLength(256); + entity.Property(e => e.TenantId) + .HasMaxLength(256); + entity.Property(e => e.CorrelationId) .HasMaxLength(64); @@ -136,6 +139,8 @@ public static ModelBuilder UseAuditLog( entity.HasIndex(e => e.EntityType); entity.HasIndex(e => new { e.EntityType, e.EntityId }); entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => new { e.TenantId, e.Timestamp }); entity.HasIndex(e => e.Timestamp); entity.HasIndex(e => e.CorrelationId); entity.HasIndex(e => e.Operation); diff --git a/src/MinimalCleanArch.Audit/Interceptors/AuditSaveChangesInterceptor.cs b/src/MinimalCleanArch.Audit/Interceptors/AuditSaveChangesInterceptor.cs index 7bc18a9..0914d3e 100644 --- a/src/MinimalCleanArch.Audit/Interceptors/AuditSaveChangesInterceptor.cs +++ b/src/MinimalCleanArch.Audit/Interceptors/AuditSaveChangesInterceptor.cs @@ -148,6 +148,7 @@ private bool ShouldSkipEntry(EntityEntry entry) Timestamp = timestamp, UserId = _contextProvider.GetUserId(), UserName = _contextProvider.GetUserName(), + TenantId = _contextProvider.GetTenantId(), CorrelationId = _contextProvider.GetCorrelationId() }; @@ -332,6 +333,7 @@ internal class AuditEntry public DateTime Timestamp { get; set; } public string? UserId { get; set; } public string? UserName { get; set; } + public string? TenantId { get; set; } public string? CorrelationId { get; set; } public string? ClientIpAddress { get; set; } public string? UserAgent { get; set; } @@ -365,6 +367,7 @@ public AuditLog ToAuditLog() Timestamp = Timestamp, UserId = UserId, UserName = UserName, + TenantId = TenantId, CorrelationId = CorrelationId, ClientIpAddress = ClientIpAddress, UserAgent = UserAgent, diff --git a/src/MinimalCleanArch.Audit/README.md b/src/MinimalCleanArch.Audit/README.md index 3bff8d1..8ad5542 100644 --- a/src/MinimalCleanArch.Audit/README.md +++ b/src/MinimalCleanArch.Audit/README.md @@ -3,16 +3,63 @@ Audit logging components for MinimalCleanArch. ## Version -- 0.1.14 (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.14 and companions. +- 0.1.16-preview (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.16-preview and companions. ## What's included - Audit logging services and helpers. - 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. ## Usage ```bash -dotnet add package MinimalCleanArch.Audit --version 0.1.14 +dotnet add package MinimalCleanArch.Audit --version 0.1.16-preview ``` -When using a local feed, add a `nuget.config` pointing to your local packages folder (e.g., `artifacts/nuget`) before restoring. +Register services: + +```csharp +builder.Services.AddAuditLogging(); +builder.Services.AddAuditLogService(); +``` + +Configure the DbContext: + +```csharp +builder.Services.AddDbContext((sp, options) => +{ + options.UseSqlite("Data Source=app.db"); + options.UseAuditInterceptor(sp); +}); +``` + +Configure the audit model: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + base.OnModelCreating(modelBuilder); + modelBuilder.UseAuditLog(); +} +``` + +Implement a custom context provider when you need tenant-aware auditing: + +```csharp +public sealed class AppAuditContextProvider : IAuditContextProvider +{ + public string? GetUserId() => "..."; + public string? GetUserName() => "..."; + public string? GetTenantId() => "..."; + public string? GetCorrelationId() => "..."; + public string? GetClientIpAddress() => "..."; + public string? GetUserAgent() => "..."; + public IDictionary? GetMetadata() => null; +} +``` + +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. +- 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/AuditLogService.cs b/src/MinimalCleanArch.Audit/Services/AuditLogService.cs index cc15b7f..69b4af1 100644 --- a/src/MinimalCleanArch.Audit/Services/AuditLogService.cs +++ b/src/MinimalCleanArch.Audit/Services/AuditLogService.cs @@ -65,6 +65,21 @@ public async Task> GetByUserAsync( .ToListAsync(cancellationToken); } + /// + public async Task> GetByTenantAsync( + string tenantId, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default) + { + return await AuditLogs + .Where(a => a.TenantId == tenantId) + .OrderByDescending(a => a.Timestamp) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + /// public async Task> GetByDateRangeAsync( DateTime from, @@ -157,6 +172,9 @@ private IQueryable BuildQuery(AuditLogQuery query) if (!string.IsNullOrEmpty(query.UserId)) queryable = queryable.Where(a => a.UserId == query.UserId); + if (!string.IsNullOrEmpty(query.TenantId)) + queryable = queryable.Where(a => a.TenantId == query.TenantId); + if (query.Operation.HasValue) queryable = queryable.Where(a => a.Operation == query.Operation.Value); diff --git a/src/MinimalCleanArch.Audit/Services/HttpContextAuditContextProvider.cs b/src/MinimalCleanArch.Audit/Services/HttpContextAuditContextProvider.cs index 976d782..7c5afe7 100644 --- a/src/MinimalCleanArch.Audit/Services/HttpContextAuditContextProvider.cs +++ b/src/MinimalCleanArch.Audit/Services/HttpContextAuditContextProvider.cs @@ -42,6 +42,18 @@ public HttpContextAuditContextProvider(IHttpContextAccessor httpContextAccessor) ?? user.Identity?.Name; } + /// + public string? GetTenantId() + { + var user = _httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + return null; + + return user.FindFirst("tenant_id")?.Value + ?? user.FindFirst("business_id")?.Value + ?? user.FindFirst("organization_id")?.Value; + } + /// public string? GetCorrelationId() { diff --git a/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs b/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs index f45c35f..cc133a7 100644 --- a/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs +++ b/src/MinimalCleanArch.Audit/Services/IAuditContextProvider.cs @@ -16,6 +16,11 @@ public interface IAuditContextProvider /// string? GetUserName(); + /// + /// Gets the current tenant or organization identifier. + /// + string? GetTenantId(); + /// /// Gets the current correlation ID for request tracing. /// diff --git a/src/MinimalCleanArch.Audit/Services/IAuditLogService.cs b/src/MinimalCleanArch.Audit/Services/IAuditLogService.cs index 5d21d62..40ee0cc 100644 --- a/src/MinimalCleanArch.Audit/Services/IAuditLogService.cs +++ b/src/MinimalCleanArch.Audit/Services/IAuditLogService.cs @@ -45,6 +45,20 @@ Task> GetByUserAsync( int take = 50, CancellationToken cancellationToken = default); + /// + /// Gets audit logs for a specific tenant or organization. + /// + /// The tenant or organization identifier. + /// Number of records to skip. + /// Number of records to take. + /// Cancellation token. + /// List of audit log entries for the tenant. + Task> GetByTenantAsync( + string tenantId, + int skip = 0, + int take = 50, + CancellationToken cancellationToken = default); + /// /// Gets audit logs within a date range. /// @@ -136,6 +150,11 @@ public class AuditLogQuery /// public string? UserId { get; set; } + /// + /// Filter by tenant or organization identifier. + /// + public string? TenantId { get; set; } + /// /// Filter by operation type. /// diff --git a/src/MinimalCleanArch.DataAccess/Extensions/ServiceCollectionExtensions.cs b/src/MinimalCleanArch.DataAccess/Extensions/ServiceCollectionExtensions.cs index 0d79db6..4264282 100644 --- a/src/MinimalCleanArch.DataAccess/Extensions/ServiceCollectionExtensions.cs +++ b/src/MinimalCleanArch.DataAccess/Extensions/ServiceCollectionExtensions.cs @@ -141,4 +141,28 @@ public static IServiceCollection AddMinimalCleanArch( return services; } -} \ No newline at end of file + + /// + /// Adds MinimalCleanArch services with a specific DbContext and custom Unit of Work implementation. + /// Use this overload when the DbContext configuration needs access to the service provider. + /// + /// The service collection. + /// The DbContext options action with IServiceProvider access. + /// The type of the DbContext. + /// The type of the Unit of Work implementation. + /// The service collection. + public static IServiceCollection AddMinimalCleanArch( + this IServiceCollection services, + Action optionsAction) + where TContext : DbContext + where TUnitOfWork : class, IUnitOfWork + { + services.AddDbContext((sp, options) => optionsAction(sp, options)); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); + services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>)); + services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + + return services; + } +} diff --git a/src/MinimalCleanArch.DataAccess/README.md b/src/MinimalCleanArch.DataAccess/README.md index dfb46f8..e602785 100644 --- a/src/MinimalCleanArch.DataAccess/README.md +++ b/src/MinimalCleanArch.DataAccess/README.md @@ -3,13 +3,14 @@ Entity Framework Core implementation for MinimalCleanArch (repositories, unit of work, specifications, DbContext helpers). ## Version -- 0.1.14 (net9.0, net10.0). Works with `MinimalCleanArch` 0.1.14 and companion packages. +- 0.1.16-preview (net9.0, net10.0). Works with `MinimalCleanArch` 0.1.16-preview and companion packages. ## What's included - `DbContextBase` and `IdentityDbContextBase` with auditing/soft-delete support. - `Repository` and `UnitOfWork` implementations. - `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)`. ## Usage ```csharp @@ -19,9 +20,17 @@ builder.Services.AddScoped(); builder.Services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>)); ``` -### Using specifications with EF Core +If you need a custom unit of work and access to `IServiceProvider` while configuring the DbContext: + +```csharp +builder.Services.AddMinimalCleanArch((sp, options) => +{ + options.UseSqlite("Data Source=app.db"); +}); +``` + +### Recommended specification usage ```csharp -// Define a spec public sealed class IncompleteHighPrioritySpec : BaseSpecification { public IncompleteHighPrioritySpec() @@ -36,9 +45,9 @@ public sealed class IncompleteHighPrioritySpec : BaseSpecification var dueToday = new DueTodaySpec(); var spec = new IncompleteHighPrioritySpec().And(dueToday); -var todos = await SpecificationEvaluator - .GetQuery(dbContext.Set(), spec) - .ToListAsync(cancellationToken); +var todos = await repository.GetAsync(spec, cancellationToken); +var hasAny = await repository.AnyAsync(spec, cancellationToken); +var total = await repository.CountAsync(spec, cancellationToken); public sealed class DueTodaySpec : BaseSpecification { @@ -48,5 +57,7 @@ public sealed class DueTodaySpec : BaseSpecification } ``` +Use specifications through `IRepository` in application code. Treat `SpecificationEvaluator` as infrastructure-level plumbing for repository implementations and advanced EF integration points. + When using a locally built package, add a `nuget.config` pointing to your local feed (e.g., `artifacts/nuget`) before restoring. diff --git a/src/MinimalCleanArch.DataAccess/Repositories/Repository.cs b/src/MinimalCleanArch.DataAccess/Repositories/Repository.cs index 862f4e8..37494f1 100644 --- a/src/MinimalCleanArch.DataAccess/Repositories/Repository.cs +++ b/src/MinimalCleanArch.DataAccess/Repositories/Repository.cs @@ -72,6 +72,22 @@ public virtual async Task> GetAsync( return await ApplySpecification(specification).ToListAsync(cancellationToken); } + /// + public virtual async Task AnyAsync( + Expression> filter, + CancellationToken cancellationToken = default) + { + return await DbSet.AnyAsync(filter, cancellationToken); + } + + /// + public virtual async Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification).AnyAsync(cancellationToken); + } + /// /// Gets a single entity by its key /// @@ -109,6 +125,22 @@ public virtual async Task> GetAsync( return await ApplySpecification(specification).FirstOrDefaultAsync(cancellationToken); } + /// + public virtual async Task SingleOrDefaultAsync( + Expression> filter, + CancellationToken cancellationToken = default) + { + return await DbSet.SingleOrDefaultAsync(filter, cancellationToken); + } + + /// + public virtual async Task SingleOrDefaultAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification).SingleOrDefaultAsync(cancellationToken); + } + /// /// Counts entities that match the filter /// @@ -124,6 +156,14 @@ public virtual async Task CountAsync( return await DbSet.CountAsync(filter, cancellationToken); } + /// + public virtual async Task CountAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return await ApplySpecification(specification).CountAsync(cancellationToken); + } + /// /// Adds an entity to the context (does not save to database) /// @@ -263,4 +303,4 @@ public class Repository : Repository, IRepository> GetAsync( return _innerRepository.GetAsync(specification, cancellationToken); } + /// + public Task AnyAsync( + Expression> filter, + CancellationToken cancellationToken = default) + { + return _innerRepository.AnyAsync(filter, cancellationToken); + } + + /// + public Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return _innerRepository.AnyAsync(specification, cancellationToken); + } + /// public async Task GetByIdAsync(TKey id, CancellationToken cancellationToken = default) { @@ -94,6 +110,22 @@ public Task> GetAsync( return _innerRepository.GetFirstAsync(specification, cancellationToken); } + /// + public Task SingleOrDefaultAsync( + Expression> filter, + CancellationToken cancellationToken = default) + { + return _innerRepository.SingleOrDefaultAsync(filter, cancellationToken); + } + + /// + public Task SingleOrDefaultAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return _innerRepository.SingleOrDefaultAsync(specification, cancellationToken); + } + /// public Task CountAsync( Expression>? filter = null, @@ -103,6 +135,14 @@ public Task CountAsync( return _innerRepository.CountAsync(filter, cancellationToken); } + /// + public Task CountAsync( + ISpecification specification, + CancellationToken cancellationToken = default) + { + return _innerRepository.CountAsync(specification, cancellationToken); + } + /// public async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) { diff --git a/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs b/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs index 95c5ded..98e63d1 100644 --- a/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs +++ b/src/MinimalCleanArch.Extensions/Extensions/ApplicationBuilderExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using MinimalCleanArch.Extensions.Middlewares; +using MinimalCleanArch.Extensions.RateLimiting; namespace MinimalCleanArch.Extensions.Extensions; @@ -87,6 +88,41 @@ public static WebApplication UseMinimalCleanArchDefaults( return app; } + /// + /// Adds the standard MinimalCleanArch API middleware pipeline with API-oriented defaults. + /// + /// The web application. + /// Optional pipeline configuration. + /// The web application for chaining. + public static WebApplication UseMinimalCleanArchApiDefaults( + this WebApplication app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + + var options = new MinimalCleanArchApiPipelineOptions(); + configure?.Invoke(options); + + app.UseCorrelationId(options.CorrelationHeaderName); + app.UseGlobalErrorHandling(); + + if (options.UseApiSecurityHeaders) + { + app.UseApiSecurityHeaders(); + } + else + { + app.UseSecurityHeaders(options.SecurityHeadersOptions); + } + + if (options.UseRateLimiting) + { + app.UseMinimalCleanArchRateLimiting(); + } + + return app; + } + /// /// Marks the application startup as complete for health checks. /// Call this after all startup operations are complete. @@ -100,3 +136,29 @@ public static WebApplication MarkStartupComplete(this WebApplication app) return app; } } + +/// +/// Options for the standard MinimalCleanArch API middleware pipeline. +/// +public sealed class MinimalCleanArchApiPipelineOptions +{ + /// + /// Gets or sets an optional custom correlation ID header name. + /// + public string? CorrelationHeaderName { get; set; } + + /// + /// Gets or sets whether API-specific security headers should be used. + /// + public bool UseApiSecurityHeaders { get; set; } = true; + + /// + /// Gets or sets optional custom security header options used when API defaults are disabled. + /// + public SecurityHeadersOptions? SecurityHeadersOptions { get; set; } + + /// + /// Gets or sets whether the standard rate limiting middleware should be enabled. + /// + public bool UseRateLimiting { get; set; } +} diff --git a/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs b/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs index 5d436a4..7f4fca2 100644 --- a/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs +++ b/src/MinimalCleanArch.Extensions/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using System.Reflection; using MinimalCleanArch.Extensions.HealthChecks; using MinimalCleanArch.Extensions.Middlewares; +using MinimalCleanArch.Extensions.RateLimiting; namespace MinimalCleanArch.Extensions.Extensions; @@ -32,6 +33,40 @@ public static IServiceCollection AddMinimalCleanArchExtensions(this IServiceColl return services; } + /// + /// Adds the standard MinimalCleanArch API service registrations with an opinionated default shape. + /// + /// The service collection. + /// Optional configuration for API defaults. + /// The service collection. + public static IServiceCollection AddMinimalCleanArchApi( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new MinimalCleanArchApiOptions(); + configure?.Invoke(options); + + services.AddMinimalCleanArchExtensions(); + + foreach (var assembly in options.ValidatorAssemblies.Distinct()) + { + services.AddValidatorsFromAssembly(assembly); + } + + if (options.ConfigureRateLimiting != null) + { + services.AddMinimalCleanArchRateLimiting(options.ConfigureRateLimiting); + } + else if (options.EnableRateLimiting) + { + services.AddMinimalCleanArchRateLimiting(); + } + + return services; + } + /// /// Adds validators from the specified assembly to the service collection /// @@ -82,4 +117,47 @@ public static IServiceCollection AddValidatorsFromAssemblyContaining( /// internal class ServiceCollectionExtensionsMarker { -} \ No newline at end of file +} + +/// +/// Options for the standard MinimalCleanArch API bootstrap registration. +/// +public sealed class MinimalCleanArchApiOptions +{ + /// + /// Gets a list of assemblies to scan for validators. + /// + public List ValidatorAssemblies { get; } = new(); + + /// + /// Gets or sets whether rate limiting should be added using default settings. + /// + public bool EnableRateLimiting { get; set; } + + /// + /// Gets or sets optional custom rate limiting configuration. + /// + public Action? ConfigureRateLimiting { get; set; } + + /// + /// Adds an assembly to scan for validators. + /// + /// The assembly to scan. + /// The options instance. + public MinimalCleanArchApiOptions AddValidatorsFromAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + ValidatorAssemblies.Add(assembly); + return this; + } + + /// + /// Adds the assembly containing the specified type to the validator scan list. + /// + /// A type from the assembly to scan. + /// The options instance. + public MinimalCleanArchApiOptions AddValidatorsFromAssemblyContaining() + { + return AddValidatorsFromAssembly(typeof(T).Assembly); + } +} diff --git a/src/MinimalCleanArch.Extensions/README.md b/src/MinimalCleanArch.Extensions/README.md index 7cbb00d..cc0976f 100644 --- a/src/MinimalCleanArch.Extensions/README.md +++ b/src/MinimalCleanArch.Extensions/README.md @@ -3,7 +3,7 @@ Minimal API extensions for MinimalCleanArch. ## Version -- 0.1.14 (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.14. +- 0.1.16-preview (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.16-preview. ## Overview - Validation: request/body validation helpers (e.g., `WithValidation()`). @@ -15,21 +15,32 @@ Minimal API extensions for MinimalCleanArch. ## Usage ```bash -dotnet add package MinimalCleanArch.Extensions --version 0.1.14 +dotnet add package MinimalCleanArch.Extensions --version 0.1.16-preview ``` -Register in your API: +Recommended API bootstrap: ```csharp -builder.Services.AddMinimalCleanArchExtensions(); -// Optionally add validators: -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddMinimalCleanArchApi(options => +{ + options.AddValidatorsFromAssemblyContaining(); + options.EnableRateLimiting = true; +}); +``` -// Optional rate limiting +Equivalent explicit registration: +```csharp +builder.Services.AddMinimalCleanArchExtensions(); +builder.Services.AddValidationFromAssemblyContaining(); builder.Services.AddMinimalCleanArchRateLimiting(); ``` -Then enable middleware: +Middleware: ```csharp -app.UseMinimalCleanArchRateLimiting(); +app.UseMinimalCleanArchApiDefaults(options => +{ + options.UseRateLimiting = true; +}); ``` +`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. + diff --git a/src/MinimalCleanArch.Messaging/README.md b/src/MinimalCleanArch.Messaging/README.md index 7c881c7..85335db 100644 --- a/src/MinimalCleanArch.Messaging/README.md +++ b/src/MinimalCleanArch.Messaging/README.md @@ -3,7 +3,7 @@ Messaging and domain event helpers for MinimalCleanArch (Wolverine integration). ## Version -- 0.1.14 (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.14 and companions. +- 0.1.16-preview (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.16-preview and companions. ## What's included - Domain event contracts and helpers. @@ -12,8 +12,38 @@ Messaging and domain event helpers for MinimalCleanArch (Wolverine integration). ## Usage ```bash -dotnet add package MinimalCleanArch.Messaging --version 0.1.14 +dotnet add package MinimalCleanArch.Messaging --version 0.1.16-preview ``` -When using a local feed, add a `nuget.config` pointing to your local packages folder (e.g., `artifacts/nuget`) before restoring. +Recommended bootstrap: + +```csharp +builder.AddMinimalCleanArchMessaging(options => +{ + options.IncludeAssembly(typeof(AssemblyReference).Assembly); + options.ServiceName = "MyApp"; +}); +``` + +Durable transports: + +```csharp +builder.AddMinimalCleanArchMessagingWithPostgres(connectionString, options => +{ + options.IncludeAssembly(typeof(AssemblyReference).Assembly); + options.ServiceName = "MyApp"; +}); +``` + +Use this package when: +- handlers and domain events are part of the application model +- you want domain event publishing wired through EF Core save operations +- you want an app-level entry point over raw Wolverine setup + +Preferred guidance: +- use the `AddMinimalCleanArchMessaging...` extensions as the entry point +- keep app-specific queue, retry, and transport tuning in the provided callback +- avoid duplicating domain event publishing logic in application DbContexts + +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.Security/README.md b/src/MinimalCleanArch.Security/README.md index 86103ff..154adc8 100644 --- a/src/MinimalCleanArch.Security/README.md +++ b/src/MinimalCleanArch.Security/README.md @@ -3,7 +3,7 @@ Security components for MinimalCleanArch. ## Version -- 0.1.14 (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.14. +- 0.1.16-preview (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.16-preview. ## Overview - Column-level encryption for EF Core. @@ -13,20 +13,44 @@ Security components for MinimalCleanArch. ## Usage ```bash -dotnet add package MinimalCleanArch.Security --version 0.1.14 +dotnet add package MinimalCleanArch.Security --version 0.1.16-preview ``` -In Program.cs: +Recommended service registration: ```csharp builder.Services.AddDataProtectionEncryptionForDevelopment("YourApp"); -// Or: builder.Services.AddEncryption(new EncryptionOptions { Key = "YOUR_SECURE_AES_KEY" }); ``` +Production-style registration: + +```csharp +builder.Services.AddEncryption(new EncryptionOptions +{ + Key = "YOUR_SECURE_AES_KEY" +}); +``` + +Model configuration: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + base.OnModelCreating(modelBuilder); + modelBuilder.UseEncryption(encryptionService); +} +``` + +Recommended guidance: +- use Data Protection-based encryption for new application development +- use `AddDataProtectionEncryptionForDevelopment(...)` only for local development +- use persistent key storage in production +- use the hybrid Data Protection and AES support when migrating legacy encrypted data +- keep encryption registration in infrastructure, not in domain or application layers + ## Key Components - EncryptedAttribute - Marks properties for encryption - IEncryptionService - Interface for encryption services - AesEncryptionService - AES implementation of IEncryptionService - EncryptedConverter - Value converter for encrypted properties -- ModelBuilderExtensions - Extensions for configuring encryption diff --git a/src/MinimalCleanArch.Validation/Extensions/ServiceCollectionExtensions.cs b/src/MinimalCleanArch.Validation/Extensions/ServiceCollectionExtensions.cs index 205fe5d..cb54e5d 100644 --- a/src/MinimalCleanArch.Validation/Extensions/ServiceCollectionExtensions.cs +++ b/src/MinimalCleanArch.Validation/Extensions/ServiceCollectionExtensions.cs @@ -52,4 +52,62 @@ public static IServiceCollection AddValidatorsFromAssemblyContaining( { return services.AddValidatorsFromAssembly(typeof(T).Assembly); } + + /// + /// Adds validation services and registers validators from the specified assemblies. + /// + /// The service collection. + /// Assemblies to scan for validators. + /// The service collection. + public static IServiceCollection AddValidation( + this IServiceCollection services, + params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(assemblies); + + foreach (var assembly in assemblies.Where(a => a != null).Distinct()) + { + services.AddValidatorsFromAssembly(assembly); + } + + return services; + } + + /// + /// Adds validation services and registers validators from the assembly containing the specified type. + /// + /// A type from the assembly to scan. + /// The service collection. + /// The service collection. + public static IServiceCollection AddValidationFromAssemblyContaining( + this IServiceCollection services) + { + return services.AddValidation(typeof(T).Assembly); + } + + /// + /// Adds MinimalCleanArch validation services and registers validators from the specified assemblies. + /// + /// The service collection. + /// Assemblies to scan for validators. + /// The service collection. + public static IServiceCollection AddMinimalCleanArchValidation( + this IServiceCollection services, + params Assembly[] assemblies) + { + return services.AddValidation(assemblies); + } + + /// + /// Adds MinimalCleanArch validation services and registers validators from the assembly containing the specified type. + /// + /// A type from the assembly to scan. + /// The service collection. + /// The service collection. + public static IServiceCollection AddMinimalCleanArchValidationFromAssemblyContaining( + this IServiceCollection services) + { + return services.AddValidationFromAssemblyContaining(); + } } diff --git a/src/MinimalCleanArch.Validation/README.md b/src/MinimalCleanArch.Validation/README.md index f83a0d5..d898efd 100644 --- a/src/MinimalCleanArch.Validation/README.md +++ b/src/MinimalCleanArch.Validation/README.md @@ -3,25 +3,35 @@ Validation components for MinimalCleanArch. ## Version -- 0.1.14 (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.14 and `MinimalCleanArch.Extensions`. +- 0.1.16-preview (net9.0, net10.0). Use with `MinimalCleanArch` 0.1.16-preview and `MinimalCleanArch.Extensions`. ## Overview - FluentValidation registration helpers. -- Integration with MinimalCleanArch.Extensions for endpoint validation. +- Integration with `MinimalCleanArch.Extensions` endpoint validation. +- Short registration methods for application validators. ## Usage ```bash -dotnet add package MinimalCleanArch.Validation --version 0.1.14 +dotnet add package MinimalCleanArch.Validation --version 0.1.16-preview ``` -In Program.cs: +Recommended registration: ```csharp -builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddMinimalCleanArchExtensions(); // to hook validation into Minimal APIs +builder.Services.AddValidationFromAssemblyContaining(); +builder.Services.AddMinimalCleanArchExtensions(); ``` -## Key Components +If you use the higher-level API bootstrap from `MinimalCleanArch.Extensions`, you can register validators there instead: -- ValidationExtensions - Extension methods for registering validators -- Integration with MinimalCleanArch.Extensions for endpoint validation +```csharp +builder.Services.AddMinimalCleanArchApi(options => +{ + options.AddValidatorsFromAssemblyContaining(); +}); +``` + +Recommended methods: +- `AddValidation(...)` registers validators from one or more assemblies. +- `AddValidationFromAssemblyContaining()` registers validators from the assembly containing `T`. +- `AddMinimalCleanArchValidation(...)` and `AddMinimalCleanArchValidationFromAssemblyContaining()` remain available as compatibility aliases. diff --git a/src/MinimalCleanArch/README.md b/src/MinimalCleanArch/README.md index 4bcd835..5581f84 100644 --- a/src/MinimalCleanArch/README.md +++ b/src/MinimalCleanArch/README.md @@ -3,7 +3,7 @@ Core primitives for Clean Architecture: entities, repositories, specifications, result pattern, and common types. ## Version -- 0.1.14 (net9.0, net10.0). Base dependency for all other MinimalCleanArch packages. +- 0.1.16-preview (net9.0, net10.0). Base dependency for all other MinimalCleanArch packages. ## Contents - Domain entities: `IEntity`, `BaseEntity`, `BaseAuditableEntity`, `BaseSoftDeleteEntity`, `IAuditableEntity`, `ISoftDelete`. @@ -11,9 +11,14 @@ Core primitives for Clean Architecture: entities, repositories, specifications, - Repositories: `IRepository`, `IUnitOfWork`. - Specifications: `ISpecification`, `BaseSpecification`, composable `And/Or/Not`, `InMemorySpecificationEvaluator`, and query flags (`AsNoTracking`, `AsSplitQuery`, `IgnoreQueryFilters`, `IsCountOnly`). +## When to use this package +- Use it in every MinimalCleanArch-based solution. +- Keep domain entities, repository contracts, result types, and specifications here. +- Do not put EF Core or HTTP concerns in projects that depend only on this package. + ## Usage ```bash -dotnet add package MinimalCleanArch --version 0.1.14 +dotnet add package MinimalCleanArch --version 0.1.16-preview ``` Use `BaseAuditableEntity`/`BaseSoftDeleteEntity` for entities that need auditing and soft delete. Use `Result`/`Result` for typed operation results. @@ -57,5 +62,11 @@ public sealed class DueTodaySpec : BaseSpecification } ``` +Guidance: +- keep specifications focused on business filters and query shape +- compose specifications at the application boundary instead of duplicating predicates +- keep repository interfaces in the domain layer and implementations in infrastructure +- use `InMemorySpecificationEvaluator` only for tests or in-memory execution paths + When consuming locally built nupkgs, add a `nuget.config` pointing to your local feed (e.g., `artifacts/nuget`) before restoring. diff --git a/src/MinimalCleanArch/Repositories/IRepository.cs b/src/MinimalCleanArch/Repositories/IRepository.cs index 9b66dc7..062d3bb 100644 --- a/src/MinimalCleanArch/Repositories/IRepository.cs +++ b/src/MinimalCleanArch/Repositories/IRepository.cs @@ -39,6 +39,26 @@ Task> GetAsync( Task> GetAsync( ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Determines whether any entity matches the specified filter. + /// + /// The filter expression. + /// A token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + Task AnyAsync( + Expression> filter, + CancellationToken cancellationToken = default); + + /// + /// Determines whether any entity matches the specified specification. + /// + /// The specification. + /// A token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + Task AnyAsync( + ISpecification specification, + CancellationToken cancellationToken = default); /// /// Gets a single entity by its key @@ -67,6 +87,28 @@ Task> GetAsync( Task GetFirstAsync( ISpecification specification, CancellationToken cancellationToken = default); + + /// + /// Gets a single entity that matches the specified filter, or null if none exists. + /// Throws if more than one entity matches. + /// + /// The filter expression. + /// A token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + Task SingleOrDefaultAsync( + Expression> filter, + CancellationToken cancellationToken = default); + + /// + /// Gets a single entity that matches the specified specification, or null if none exists. + /// Throws if more than one entity matches. + /// + /// The specification. + /// A token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + Task SingleOrDefaultAsync( + ISpecification specification, + CancellationToken cancellationToken = default); /// /// Counts entities that match the filter @@ -77,6 +119,16 @@ Task> GetAsync( Task CountAsync( Expression>? filter = null, CancellationToken cancellationToken = default); + + /// + /// Counts entities that match the specified specification. + /// + /// The specification. + /// A token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + Task CountAsync( + ISpecification specification, + CancellationToken cancellationToken = default); /// /// Adds an entity (does not save to database until SaveChanges is called) diff --git a/templates/MinimalCleanArch.Templates.csproj b/templates/MinimalCleanArch.Templates.csproj index 189904b..659df71 100644 --- a/templates/MinimalCleanArch.Templates.csproj +++ b/templates/MinimalCleanArch.Templates.csproj @@ -19,7 +19,7 @@ true true - 0.1.14 + 0.1.16-preview diff --git a/templates/README.md b/templates/README.md index d7107f7..5a728a8 100644 --- a/templates/README.md +++ b/templates/README.md @@ -44,7 +44,7 @@ This is the quickest way to validate OpenIddict + user auth + global Bearer reus 1. Scaffold and run: ```bash -dotnet new mca -n QuickAuth --single-project --auth --tests --mcaVersion 0.1.14 +dotnet new mca -n QuickAuth --single-project --auth --tests --mcaVersion 0.1.16-preview cd QuickAuth dotnet run ``` @@ -196,7 +196,7 @@ dotnet new mca -n DurableApp --all --db postgres --tests ### Versions | Option | Default | Description | |--------|---------|-------------| -| `--mcaVersion ` | 0.1.14 | MinimalCleanArch package version | +| `--mcaVersion ` | 0.1.16-preview | MinimalCleanArch package version | | `--framework ` | net10.0 | Target framework (`net9.0` or `net10.0`) | ## Common Examples @@ -333,11 +333,16 @@ dotnet add package AspNet.Security.OAuth.GitHub pwsh ./templates/scripts/validate-templates.ps1 ` -TemplatePackagePath ./artifacts/packages ` -LocalFeedPath ./artifacts/packages ` - -McaVersion 0.1.14 ` - -Framework net10.0 ` - -IncludeNugetOrg + -McaVersion 0.1.16-preview ` + -Framework net10.0 ``` +Validation behavior: +- The script uses the local feed for `MinimalCleanArch.*` packages and `nuget.org` for third-party packages by default. +- This keeps validation deterministic on clean machines and CI agents. +- Pass `-IncludeNugetOrg:$false` only if your local feed also contains every external package referenced by the generated templates. +- Pass `-RunDockerE2E` when you want durable SQL Server and PostgreSQL integration tests to run instead of being skipped. + ## Uninstall ```bash diff --git a/templates/mca/.template.config/template.json b/templates/mca/.template.config/template.json index 22a0e89..b3b913e 100644 --- a/templates/mca/.template.config/template.json +++ b/templates/mca/.template.config/template.json @@ -128,7 +128,7 @@ "mcaVersion": { "type": "parameter", "datatype": "string", - "defaultValue": "0.1.14", + "defaultValue": "0.1.16-preview", "description": "MinimalCleanArch package version to reference", "replaces": "__MCA_VERSION__" }, @@ -265,8 +265,9 @@ { "condition": "(!UseDocker)", "exclude": ["**/Dockerfile", "**/docker-compose.yml", "**/.dockerignore"] }, { "condition": "(!UseTests)", "exclude": ["tests/**"] }, { "condition": "(!UseAuth)", "exclude": ["tests/**/AuthEndpointTests.cs", "tests/**/Auth/*.cs"] }, + { "condition": "(!UseDurableMessaging)", "exclude": ["tests/**/TodoEndpointDurableTests.cs"] }, { "condition": "(!UseRateLimiting)", "exclude": ["tests/**/RateLimitingEndpointTests.cs"] }, - { "condition": "(UseDurableMessaging)", "exclude": ["tests/**/AuthEndpointTests.cs", "tests/**/RateLimitingEndpointTests.cs"] }, + { "condition": "(UseDurableMessaging)", "exclude": ["tests/**/AuthEndpointTests.cs", "tests/**/RateLimitingEndpointTests.cs", "tests/**/TodoEndpointTests.cs"] }, { "condition": "(SingleProject)", "exclude": ["MCA.sln", "Dockerfile", "docker-compose.yml", ".dockerignore"] }, { "exclude": ["single/**", "multi/**"] } ] diff --git a/templates/mca/multi/MCA.Api/Program.cs b/templates/mca/multi/MCA.Api/Program.cs index cf60a4c..5ea87c8 100644 --- a/templates/mca/multi/MCA.Api/Program.cs +++ b/templates/mca/multi/MCA.Api/Program.cs @@ -17,8 +17,8 @@ using MinimalCleanArch.DataAccess.Repositories; using MinimalCleanArch.Repositories; #if (UseValidation) -using FluentValidation; using MCA.Application.Validation; +using MinimalCleanArch.Validation.Extensions; #endif #if (UseHealthChecks) using HealthChecks.UI.Client; @@ -160,8 +160,7 @@ string BuildConnectionString(string databaseName) builder.Services.AddHttpContextAccessor(); #endif #if (UseValidation) -// Validators (used by endpoints and Wolverine handlers) -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddValidationFromAssemblyContaining(); #endif #if (UseSecurity) diff --git a/templates/mca/single/Program.cs b/templates/mca/single/Program.cs index 8e20e16..67465b2 100644 --- a/templates/mca/single/Program.cs +++ b/templates/mca/single/Program.cs @@ -16,8 +16,8 @@ using MinimalCleanArch.DataAccess.Repositories; using MinimalCleanArch.Repositories; #if (UseValidation) -using FluentValidation; using MCA.Application.Validation; +using MinimalCleanArch.Validation.Extensions; #endif #if (UseHealthChecks) using HealthChecks.UI.Client; @@ -157,8 +157,7 @@ string BuildConnectionString(string databaseName) builder.Services.AddHttpContextAccessor(); #endif #if (UseValidation) -// Validators (used by endpoints and Wolverine handlers) -builder.Services.AddValidatorsFromAssemblyContaining(); +builder.Services.AddValidationFromAssemblyContaining(); #endif #if (UseSecurity) diff --git a/templates/mca/tests/MCA.IntegrationTests/TodoEndpointDurableTests.cs b/templates/mca/tests/MCA.IntegrationTests/TodoEndpointDurableTests.cs index b2b7aba..1cf8f08 100644 --- a/templates/mca/tests/MCA.IntegrationTests/TodoEndpointDurableTests.cs +++ b/templates/mca/tests/MCA.IntegrationTests/TodoEndpointDurableTests.cs @@ -2,20 +2,20 @@ using System.Net.Http.Json; using FluentAssertions; using MCA.Application.DTOs; -using MCA.Infrastructure.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; +#if (UsePostgres) using Testcontainers.PostgreSql; +#endif +#if (UseSqlServer) +using Microsoft.Data.SqlClient; using Testcontainers.MsSql; +#endif using Xunit; namespace MCA.IntegrationTests; -// These tests are opt-in: set RUN_DOCKER_E2E=1 to enable. public class TodoEndpointDurableTests : IClassFixture, IAsyncLifetime { private readonly HttpClient _client; @@ -38,111 +38,97 @@ public Task DisposeAsync() } [SkippableFact] - public async Task CreateTodo_Persists_WithSqlServerOutbox() + public async Task ScalarUi_LoadsInDevelopment() { Skip.IfNot(_factory.Enabled, "RUN_DOCKER_E2E not set"); - Skip.IfNot(_factory.DatabaseKind == "sqlserver", "Not running SQL Server variant"); - var request = new CreateTodoRequest("durable-sql", null, 1, null); + var response = await _client.GetAsync("/scalar/v1"); - var createResponse = await _client.PostAsJsonAsync("/api/todos", request); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } - var created = await createResponse.Content.ReadFromJsonAsync(); - created.Should().NotBeNull(); + [SkippableFact] + public async Task GetTodos_InitiallyEmpty_ReturnsOkAndEmptyList() + { + Skip.IfNot(_factory.Enabled, "RUN_DOCKER_E2E not set"); - var getResponse = await _client.GetAsync($"/api/todos/{created!.Id}"); - getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + await using var isolatedFactory = new DurableApiFactory(); + await isolatedFactory.InitializeAsync(); + using var isolatedClient = isolatedFactory.CreateClient(); + + var response = await isolatedClient.GetAsync("/api/todos"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync>(); + payload.Should().NotBeNull(); + payload!.Should().BeEmpty(); + + await ((IAsyncLifetime)isolatedFactory).DisposeAsync(); } [SkippableFact] - public async Task CreateTodo_Persists_WithPostgresOutbox() + public async Task CreateTodo_ValidRequest_ReturnsCreatedAndPersists() { Skip.IfNot(_factory.Enabled, "RUN_DOCKER_E2E not set"); - Skip.IfNot(_factory.DatabaseKind == "postgres", "Not running Postgres variant"); - var request = new CreateTodoRequest("durable-pg", null, 1, null); + var request = new CreateTodoRequest("durable-item", "desc", 1, DateTime.UtcNow.AddDays(1)); var createResponse = await _client.PostAsJsonAsync("/api/todos", request); - createResponse.StatusCode.Should().Be(HttpStatusCode.Created); + createResponse.StatusCode.Should().Be(HttpStatusCode.Created); var created = await createResponse.Content.ReadFromJsonAsync(); created.Should().NotBeNull(); + created!.Title.Should().Be("durable-item"); + + var getResponse = await _client.GetAsync($"/api/todos/{created.Id}"); - var getResponse = await _client.GetAsync($"/api/todos/{created!.Id}"); getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var fetched = await getResponse.Content.ReadFromJsonAsync(); + fetched.Should().NotBeNull(); + fetched!.Id.Should().Be(created.Id); + } + +#if (UseValidation) + [SkippableFact] + public async Task CreateTodo_InvalidRequest_ReturnsBadRequest() + { + Skip.IfNot(_factory.Enabled, "RUN_DOCKER_E2E not set"); + + var request = new CreateTodoRequest(string.Empty, null, 0, null); + + var response = await _client.PostAsJsonAsync("/api/todos", request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } +#endif } public class DurableApiFactory : WebApplicationFactory, IAsyncLifetime { private readonly bool _enabled; - private readonly string _dbKind; + private string? _originalConnectionString; +#if (UseSqlServer) private MsSqlContainer? _sqlServer; +#endif +#if (UsePostgres) private PostgreSqlContainer? _postgres; +#endif public bool Enabled => _enabled; - public string DatabaseKind => _dbKind; public DurableApiFactory() { _enabled = string.Equals(Environment.GetEnvironmentVariable("RUN_DOCKER_E2E"), "1", StringComparison.OrdinalIgnoreCase); - _dbKind = Environment.GetEnvironmentVariable("RUN_DOCKER_DB")?.ToLowerInvariant() switch - { - "postgres" => "postgres", - _ => "sqlserver" - }; } protected override void ConfigureWebHost(IWebHostBuilder builder) { if (!_enabled) { - // Prevent host startup when disabled - builder.ConfigureServices(services => - { - services.RemoveAll(typeof(DbContextOptions)); - services.AddDbContext(o => o.UseInMemoryDatabase("SkippedDb")); - }); return; } builder.UseEnvironment("Development"); - builder.ConfigureAppConfiguration((context, config) => - { - var dict = new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = _dbKind switch - { - "postgres" => _postgres?.GetConnectionString(), - _ => _sqlServer?.GetConnectionString() - } - }; - config.AddInMemoryCollection(dict!); - }); - - builder.ConfigureServices(services => - { - services.RemoveAll(typeof(DbContextOptions)); - services.RemoveAll(); - - if (_dbKind == "postgres") - { - services.AddDbContext(options => - { - options.UseNpgsql(_postgres!.GetConnectionString()); - options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - }); - } - else - { - services.AddDbContext(options => - { - options.UseSqlServer(_sqlServer!.GetConnectionString()); - options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - }); - } - }); } public async Task InitializeAsync() @@ -152,37 +138,53 @@ public async Task InitializeAsync() return; } - if (_dbKind == "postgres") - { - _postgres = new PostgreSqlBuilder("postgres:latest") - .WithDatabase("mca") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); - await _postgres.StartAsync(); - } - else - { - _sqlServer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest") - .WithPassword("YourStrong@Passw0rd") - .WithPortBinding(0, 1433) - .Build(); - await _sqlServer.StartAsync(); - } +#if (UsePostgres) + _postgres = new PostgreSqlBuilder("postgres:latest") + .WithDatabase("mca") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + await _postgres.StartAsync(); +#endif +#if (UseSqlServer) + _sqlServer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("YourStrong@Passw0rd") + .WithPortBinding(0, 1433) + .Build(); + await _sqlServer.StartAsync(); +#endif + + _originalConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection"); + Environment.SetEnvironmentVariable( + "ConnectionStrings__DefaultConnection", +#if (UsePostgres) + _postgres!.GetConnectionString() +#else + new SqlConnectionStringBuilder(_sqlServer!.GetConnectionString()) + { + InitialCatalog = "mca_tests" + }.ConnectionString +#endif + ); } async Task IAsyncLifetime.DisposeAsync() { + Environment.SetEnvironmentVariable("ConnectionStrings__DefaultConnection", _originalConnectionString); + + await base.DisposeAsync(); + +#if (UsePostgres) if (_postgres is not null) { await _postgres.DisposeAsync(); } - +#endif +#if (UseSqlServer) if (_sqlServer is not null) { await _sqlServer.DisposeAsync(); } - - await base.DisposeAsync(); +#endif } } diff --git a/templates/scripts/validate-templates.ps1 b/templates/scripts/validate-templates.ps1 index 6c22713..889be58 100644 --- a/templates/scripts/validate-templates.ps1 +++ b/templates/scripts/validate-templates.ps1 @@ -1,10 +1,10 @@ param( [string]$LocalFeedPath = "$PSScriptRoot/../../artifacts/packages", [string]$TemplatePackagePath = "$PSScriptRoot/../../artifacts/packages", - [string]$McaVersion = "0.1.14", + [string]$McaVersion = "0.1.16-preview", [string]$Framework = "net10.0", [switch]$RunDockerE2E = $false, - [switch]$IncludeNugetOrg = $false + [bool]$IncludeNugetOrg = $true ) set-strictmode -version latest @@ -121,6 +121,8 @@ $runStamp = Get-Date -Format "yyyyMMddHHmmssfff" $workRoot = Join-Path $PSScriptRoot "../../temp/validate/$runStamp" New-Item -ItemType Directory -Force -Path $workRoot | Out-Null Write-Host "==> Validation output: $workRoot" +$restoreConfigPath = Join-Path $workRoot "NuGet.Config" +New-RestoreConfig -ConfigPath $restoreConfigPath -FeedPath $localFeed -UseNugetOrg:$IncludeNugetOrg $buildProps = @( "-p:UseSharedCompilation=false", @@ -142,7 +144,6 @@ foreach ($scenario in $scenarios) { $scenarioArgs = $scenario.Args $projName = "App_" + ($name -replace '[^A-Za-z0-9]', '_') $outDir = Join-Path $workRoot $name - $tempRestoreConfig = Join-Path $outDir "NuGet.Temp.config" try { Invoke-Checked -Description "Scaffolding $name" -Action { @@ -151,7 +152,6 @@ foreach ($scenario in $scenarios) { Push-Location $outDir try { - New-RestoreConfig -ConfigPath $tempRestoreConfig -FeedPath $localFeed -UseNugetOrg:$IncludeNugetOrg $solution = Get-ChildItem -Path $outDir -Filter "*.sln" -ErrorAction SilentlyContinue | Select-Object -First 1 $restoreTarget = $null if ($solution) { @@ -166,7 +166,7 @@ foreach ($scenario in $scenarios) { } Invoke-Checked -Description "Restore $name" -Action { - dotnet restore $restoreTarget --configfile $tempRestoreConfig + dotnet restore $restoreTarget --configfile $restoreConfigPath } Invoke-Checked -Description "Build $name" -Action { @@ -192,7 +192,6 @@ foreach ($scenario in $scenarios) { } finally { Pop-Location - Remove-Item -Path $tempRestoreConfig -Force -ErrorAction SilentlyContinue } } catch { diff --git a/tests/MinimalCleanArch.Templates.Tests/TemplateTestFixture.cs b/tests/MinimalCleanArch.Templates.Tests/TemplateTestFixture.cs index 4fe247d..df4ae13 100644 --- a/tests/MinimalCleanArch.Templates.Tests/TemplateTestFixture.cs +++ b/tests/MinimalCleanArch.Templates.Tests/TemplateTestFixture.cs @@ -61,7 +61,7 @@ private static (string packagePath, string version) ResolveTemplatePackage() .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); - var version = ExtractVersion(packagePath) ?? ReadVersionFromCsproj() ?? "0.1.14"; + var version = ExtractVersion(packagePath) ?? ReadVersionFromCsproj() ?? "0.1.16-preview"; return (packagePath ?? TemplatePath, version); }