diff --git a/.editorconfig b/.editorconfig index 7ea9b6496..aa679d79f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -143,9 +143,19 @@ csharp_style_prefer_top_level_statements = true:silent csharp_style_prefer_primary_constructors = true:suggestion csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent + +dotnet_diagnostic.CA1707.severity = none + +dotnet_diagnostic.CA1014.severity = none +// CA1848: Use 'LoggerMessage.Define' instead of 'LoggerMessage.Define' +// DO not force Logger Massages Delegates all everywhere +dotnet_diagnostic.CA1848.severity= none +# Avoid forcing LoggerMessage-style logging everywhere in this template/sample repository. +dotnet_diagnostic.CA1873.severity = none + ############################### # VB Coding Conventions # ############################### [*.vb] # Modifier preferences -visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion \ No newline at end of file +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion diff --git a/.gitignore b/.gitignore index e3f7b12fe..2349d2d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,11 @@ bld/ .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ +# Local SonarQube Scanner working directory +.sonarqube/ +# Visual Studio local workspace state +.vs/ # Hugo generated site output docs/public/ diff --git a/Clean.Architecture.slnx b/Clean.Architecture.slnx index 77b21ac68..18d488761 100644 --- a/Clean.Architecture.slnx +++ b/Clean.Architecture.slnx @@ -6,12 +6,14 @@ + + @@ -22,6 +24,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 1b57a4a27..29bd78c81 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,10 @@ true true + latest + Recommended true + false net10.0 enable enable diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs index 7562dbb51..d9cc68cc2 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs @@ -8,7 +8,7 @@ public class ContributorDeletedHandler(ILogger logger { public async ValueTask Handle(ContributorDeletedEvent domainEvent, CancellationToken cancellationToken) { - logger.LogInformation("Handling Contributed Deleted event for {contributorId}", domainEvent.ContributorId); + logger.LogInformation("Handling Contributed Deleted event for {ContributorId}", domainEvent.ContributorId); await emailSender.SendEmailAsync("to@test.com", "from@test.com", diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs index f35f27e51..354733167 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs @@ -9,7 +9,7 @@ public class ContributorNameUpdatedEmailNotificationHandler( { public async ValueTask Handle(ContributorNameUpdatedEvent domainEvent, CancellationToken cancellationToken) { - logger.LogInformation("Handling Contributor Name Updated event for {contributorId}", domainEvent.Contributor.Id); + logger.LogInformation("Handling Contributor Name Updated event for {ContributorId}", domainEvent.Contributor.Id); await emailSender.SendEmailAsync("to@test.com", "from@test.com", diff --git a/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs b/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs index 68d689de2..ebb92acb3 100644 --- a/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs +++ b/src/Clean.Architecture.Core/Interfaces/IEmailSender.cs @@ -2,5 +2,5 @@ public interface IEmailSender { - Task SendEmailAsync(string to, string from, string subject, string body); + Task SendEmailAsync(string recipientEmail, string senderEmail, string subject, string body); } diff --git a/src/Clean.Architecture.Core/Services/DeleteContributorService.cs b/src/Clean.Architecture.Core/Services/DeleteContributorService.cs index 153fb0a0b..514452e55 100644 --- a/src/Clean.Architecture.Core/Services/DeleteContributorService.cs +++ b/src/Clean.Architecture.Core/Services/DeleteContributorService.cs @@ -5,23 +5,41 @@ namespace Clean.Architecture.Core.Services; /// -/// This is here mainly so there's an example of a domain service +/// This service is here mainly so there's an example of a domain service /// and also to demonstrate how to fire domain events from a service. /// -/// -/// -/// -public class DeleteContributorService(IRepository _repository, - IMediator _mediator, - ILogger _logger) : IDeleteContributorService +/// +/// The logging call intentionally uses a precompiled delegate. +/// This avoids analyzer warning CA1848 and demonstrates the recommended high-performance +/// logging pattern without using LoggerExtensions.LogInformation directly. +/// +/// The repository used to load and delete contributors. +/// The mediator used to publish domain events. +/// The logger used by the service. +public class DeleteContributorService( + IRepository repository, + IMediator mediator, + ILogger logger) : IDeleteContributorService { + private static readonly Action LogDeletingContributor = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(DeleteContributor)), + "Deleting Contributor {ContributorId}"); + + private readonly IRepository _repository = repository; + private readonly IMediator _mediator = mediator; + private readonly ILogger _logger = logger; + public async ValueTask DeleteContributor(ContributorId contributorId) { - _logger.LogInformation("Deleting Contributor {contributorId}", contributorId); + LogDeletingContributor(_logger, contributorId, null); + Contributor? aggregateToDelete = await _repository.GetByIdAsync(contributorId); if (aggregateToDelete == null) return Result.NotFound(); await _repository.DeleteAsync(aggregateToDelete); + var domainEvent = new ContributorDeletedEvent(contributorId); await _mediator.Publish(domainEvent); diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs b/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs index 302816215..675c8101c 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs @@ -2,5 +2,5 @@ public static class DataSchemaConstants { - public const int DEFAULT_NAME_LENGTH = 100; + public const int DefaultNameLength = 100; } diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs b/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs index b26a46854..3c74148d8 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs @@ -5,4 +5,4 @@ namespace Clean.Architecture.Infrastructure.Data.Config; [EfCoreConverter] [EfCoreConverter] -internal partial class VogenEfCoreConverters; +internal sealed partial class VogenEfCoreConverters; diff --git a/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs b/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs index 0d879cdae..fdf5fed3d 100644 --- a/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs +++ b/src/Clean.Architecture.Infrastructure/Data/EventDispatcherInterceptor.cs @@ -20,7 +20,7 @@ public override async ValueTask SavedChangesAsync(SaveChangesCompletedEvent // Retrieve all tracked entities that have domain events var entitiesWithEvents = appDbContext.ChangeTracker.Entries() .Select(e => e.Entity) - .Where(e => e.DomainEvents.Any()) + .Where(e => e.DomainEvents.Count != 0) .ToArray(); // Dispatch and clear domain events diff --git a/src/Clean.Architecture.Infrastructure/Data/SeedData.cs b/src/Clean.Architecture.Infrastructure/Data/SeedData.cs index 8e447d5d7..cde052242 100644 --- a/src/Clean.Architecture.Infrastructure/Data/SeedData.cs +++ b/src/Clean.Architecture.Infrastructure/Data/SeedData.cs @@ -4,7 +4,7 @@ namespace Clean.Architecture.Infrastructure.Data; public static class SeedData { - public const int NUMBER_OF_CONTRIBUTORS = 27; // including the 2 below + public const int NumberOfContributors = 27; // including the 2 below public static readonly ContributorName Contributor1Name = ContributorName.From("Ardalis"); public static readonly ContributorName Contributor2Name = ContributorName.From("Ilyana"); @@ -22,7 +22,7 @@ public static async Task PopulateTestDataAsync(AppDbContext dbContext) await dbContext.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({Contributor2Name.Value}, {ContributorStatus.NotSet.Value})"); // Add a bunch more contributors to support demonstrating paging. - for (int i = 1; i <= NUMBER_OF_CONTRIBUTORS - 2; i++) + for (int i = 1; i <= NumberOfContributors - 2; i++) { await dbContext.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO [Contributors] ([Name], [Status]) VALUES ({$"Contributor {i}"}, {ContributorStatus.NotSet.Value})"); } diff --git a/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs b/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs index 945d002d6..aa58f362b 100644 --- a/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs +++ b/src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs @@ -5,9 +5,9 @@ namespace Clean.Architecture.Infrastructure.Email; public class FakeEmailSender(ILogger logger) : IEmailSender { private readonly ILogger _logger = logger; - public Task SendEmailAsync(string to, string from, string subject, string body) + public Task SendEmailAsync(string recipientEmail, string senderEmail, string subject, string body) { - _logger.LogInformation("Not actually sending an email to {to} from {from} with subject {subject}", to, from, subject); + _logger.LogInformation("Not actually sending an email to {To} from {From} with subject {Subject}", recipientEmail, senderEmail, subject); return Task.CompletedTask; } } diff --git a/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs b/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs index c1846335a..1433ba3e5 100644 --- a/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs +++ b/src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs @@ -8,22 +8,22 @@ public class MimeKitEmailSender(ILogger logger, private readonly ILogger _logger = logger; private readonly MailserverConfiguration _mailserverConfiguration = mailserverOptions.Value!; - public async Task SendEmailAsync(string to, string from, string subject, string body) + public async Task SendEmailAsync(string recipientEmail, string senderEmail, string subject, string body) { - _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, this.ToString()); + _logger.LogWarning("Sending email to {To} from {From} with subject {Subject} using {Type}.", recipientEmail, senderEmail, subject, this.ToString()); - using var client = new MailKit.Net.Smtp.SmtpClient(); - await client.ConnectAsync(_mailserverConfiguration.Hostname, + using var client = new MailKit.Net.Smtp.SmtpClient(); + await client.ConnectAsync(_mailserverConfiguration.Hostname, _mailserverConfiguration.Port, false); var message = new MimeMessage(); - message.From.Add(new MailboxAddress(from, from)); - message.To.Add(new MailboxAddress(to, to)); + message.From.Add(new MailboxAddress(senderEmail, senderEmail)); + message.To.Add(new MailboxAddress(recipientEmail, recipientEmail)); message.Subject = subject; message.Body = new TextPart("plain") { Text = body }; await client.SendAsync(message); - await client.DisconnectAsync(true, + await client.DisconnectAsync(true, new CancellationToken(canceled: true)); } } diff --git a/src/Clean.Architecture.ServiceDefaults/Extensions.cs b/src/Clean.Architecture.ServiceDefaults/Extensions.cs index 8ffe5aed0..22f5956b9 100644 --- a/src/Clean.Architecture.ServiceDefaults/Extensions.cs +++ b/src/Clean.Architecture.ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -34,12 +33,13 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where // Turn on service discovery by default http.AddServiceDiscovery(); }); - +#pragma warning disable S125 // This template intentionally contains commented sample configuration. // Uncomment the following to restrict the allowed schemes for service discovery. // builder.Services.Configure(options => // { // options.AllowedSchemes = ["https"]; // }); +#pragma warning restore S125 return builder; } @@ -65,8 +65,8 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .AddAspNetCoreInstrumentation(tracing => // Exclude health check requests from tracing tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + !context.Request.Path.StartsWithSegments(HealthEndpointPath, StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath, StringComparison.OrdinalIgnoreCase) ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() @@ -86,13 +86,14 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde { builder.Services.AddOpenTelemetry().UseOtlpExporter(); } - +#pragma warning disable S125 //this is there on purpose to show how to conditionally add exporters based on configuration, and to avoid confusion about the presence of multiple exporters in this template. // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) //{ // builder.Services.AddOpenTelemetry() // .UseAzureMonitor(); //} +#pragma warning restore return builder; } diff --git a/src/Clean.Architecture.UseCases/Constants.cs b/src/Clean.Architecture.UseCases/Constants.cs index 22ba6c62e..07ba5eb8c 100644 --- a/src/Clean.Architecture.UseCases/Constants.cs +++ b/src/Clean.Architecture.UseCases/Constants.cs @@ -1,7 +1,7 @@ namespace Clean.Architecture.UseCases; -public class Constants +public static class Constants { - public const int DEFAULT_PAGE_SIZE = 10; - public const int MAX_PAGE_SIZE = 100; + public const int DefaultPageSize = 10; + public const int MaxPageSize = 100; } diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs index 9cdc4d598..3e29dd25c 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs @@ -1,18 +1,19 @@ using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.Core.ContributorAggregate.Specifications; +using Clean.Architecture.UseCases.Contributors.GetContributor; namespace Clean.Architecture.UseCases.Contributors.Get; /// /// Queries don't necessarily need to use repository methods, but they can if it's convenient /// -public class GetContributorHandler(IReadRepository _repository) +public class GetContributorHandler(IReadRepository repository) : IQueryHandler> { public async ValueTask> Handle(GetContributorQuery request, CancellationToken cancellationToken) { var spec = new ContributorByIdSpec(request.ContributorId); - var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); + var entity = await repository.FirstOrDefaultAsync(spec, cancellationToken); if (entity == null) return Result.NotFound(); return new ContributorDto(entity.Id, entity.Name, entity.PhoneNumber ?? PhoneNumber.Unknown); diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs b/src/Clean.Architecture.UseCases/Contributors/GetContributor/GetContributorQuery.cs similarity index 69% rename from src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs rename to src/Clean.Architecture.UseCases/Contributors/GetContributor/GetContributorQuery.cs index c27d2f49b..7f3df787c 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/GetContributor/GetContributorQuery.cs @@ -1,5 +1,5 @@ using Clean.Architecture.Core.ContributorAggregate; -namespace Clean.Architecture.UseCases.Contributors.Get; +namespace Clean.Architecture.UseCases.Contributors.GetContributor; public record GetContributorQuery(ContributorId ContributorId) : IQuery>; diff --git a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs index 1dbcd8aa2..2a6321135 100644 --- a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs @@ -13,7 +13,7 @@ public async ValueTask>> Handle(ListContribut CancellationToken cancellationToken) { - var result = await _query.ListAsync(request.Page ?? 1, request.PerPage ?? Constants.DEFAULT_PAGE_SIZE); + var result = await _query.ListAsync(request.Page ?? 1, request.PerPage ?? Constants.DefaultPageSize); return Result.Success(result); } diff --git a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs index b3dda9ee8..db3f7bc59 100644 --- a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs @@ -1,4 +1,4 @@ namespace Clean.Architecture.UseCases.Contributors.List; -public record ListContributorsQuery(int? Page = 1, int? PerPage = Constants.DEFAULT_PAGE_SIZE) +public record ListContributorsQuery(int? Page = 1, int? PerPage = Constants.DefaultPageSize) : IQuery>>; diff --git a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs index 6cb2278bf..74e5a4835 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs @@ -5,10 +5,10 @@ namespace Clean.Architecture.UseCases.Contributors.Update; public class UpdateContributorHandler(IRepository _repository) : ICommandHandler> { - public async ValueTask> Handle(UpdateContributorCommand command, - CancellationToken ct) + public async ValueTask> Handle(UpdateContributorCommand command, + CancellationToken cancellationToken) { - var existingContributor = await _repository.GetByIdAsync(command.ContributorId, ct); + var existingContributor = await _repository.GetByIdAsync(command.ContributorId, cancellationToken); if (existingContributor == null) { return Result.NotFound(); @@ -16,7 +16,7 @@ public async ValueTask> Handle(UpdateContributorCommand c existingContributor.UpdateName(command.NewName); - await _repository.UpdateAsync(existingContributor, ct); + await _repository.UpdateAsync(existingContributor, cancellationToken); return new ContributorDto(existingContributor.Id, existingContributor.Name, existingContributor.PhoneNumber ?? PhoneNumber.Unknown); diff --git a/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs b/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs index d5c3ed9c4..cc12e64a1 100644 --- a/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs +++ b/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs @@ -1,4 +1,5 @@ -using Serilog; +using System.Globalization; +using Serilog; namespace Clean.Architecture.Web.Configurations; @@ -12,7 +13,7 @@ public static WebApplicationBuilder AddLoggerConfigs(this WebApplicationBuilder .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .Enrich.WithProperty("Application", builder.Environment.ApplicationName) - .WriteTo.Console() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) .CreateLogger()); return builder; diff --git a/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs b/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs index 755847cf7..dc4210d7a 100644 --- a/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs +++ b/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs @@ -14,7 +14,7 @@ public static async Task UseAppMiddlewareAndSeedDatabase(th app.UseShowAllServicesMiddleware(); // see https://github.com/ardalis/AspNetCoreStartupServices } else - { + { app.UseDefaultExceptionHandler(); // from FastEndpoints app.UseHsts(); } @@ -32,7 +32,7 @@ public static async Task UseAppMiddlewareAndSeedDatabase(th settings.Path = "/swagger"; settings.DocumentPath = "/openapi/{documentName}.json"; }); - + app.MapScalarApiReference(options => { options.WithTitle("Clean Architecture API"); @@ -43,9 +43,9 @@ public static async Task UseAppMiddlewareAndSeedDatabase(th app.UseHttpsRedirection(); // Note this will drop Authorization headers // Run migrations and seed in Development or when explicitly requested via environment variable - var shouldMigrate = app.Environment.IsDevelopment() || + var shouldMigrate = app.Environment.IsDevelopment() || app.Configuration.GetValue("Database:ApplyMigrationsOnStartup"); - + if (shouldMigrate) { await MigrateDatabaseAsync(app); @@ -65,7 +65,7 @@ static async Task MigrateDatabaseAsync(WebApplication app) { logger.LogInformation("Applying database migrations..."); var context = services.GetRequiredService(); - + // For SQLite, use EnsureCreated instead of migrations (common for dev/local scenarios) // For SQL Server, use migrations (production scenario) if (context.Database.IsSqlite()) @@ -81,7 +81,7 @@ static async Task MigrateDatabaseAsync(WebApplication app) } catch (Exception ex) { - logger.LogError(ex, "An error occurred migrating the DB. {exceptionMessage}", ex.Message); + logger.LogError(ex, "An error occurred migrating the DB. {ExceptionMessage}", ex.Message); throw; // Re-throw to make startup fail if migrations fail } } @@ -101,7 +101,7 @@ static async Task SeedDatabaseAsync(WebApplication app) } catch (Exception ex) { - logger.LogError(ex, "An error occurred seeding the DB. {exceptionMessage}", ex.Message); + logger.LogError(ex, "An error occurred seeding the DB. {ExceptionMessage}", ex.Message); // Don't re-throw for seeding errors - it's not critical } } diff --git a/src/Clean.Architecture.Web/Contributors/Create.cs b/src/Clean.Architecture.Web/Contributors/Create.cs index e061d8fc4..45be6f63f 100644 --- a/src/Clean.Architecture.Web/Contributors/Create.cs +++ b/src/Clean.Architecture.Web/Contributors/Create.cs @@ -49,9 +49,9 @@ public override void Configure() } public override async Task, ValidationProblem, ProblemHttpResult>> - ExecuteAsync(CreateContributorRequest request, CancellationToken cancellationToken) + ExecuteAsync(CreateContributorRequest request, CancellationToken ct) { - var result = await _mediator.Send(new CreateContributorCommand(ContributorName.From(request.Name!), request.PhoneNumber)); + var result = await _mediator.Send(new CreateContributorCommand(ContributorName.From(request.Name!), request.PhoneNumber), ct); return result.ToCreatedResult( id => $"/Contributors/{id}", @@ -65,7 +65,7 @@ public class CreateContributorRequest [Required] public string Name { get; set; } = String.Empty; - public string? PhoneNumber { get; set; } = null; + public string? PhoneNumber { get; set; } } public class CreateContributorValidator : Validator diff --git a/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs b/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs index dbd7dca19..984243e7a 100644 --- a/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs +++ b/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs @@ -1,9 +1,11 @@ -namespace Clean.Architecture.Web.Contributors; +using System.Globalization; + +namespace Clean.Architecture.Web.Contributors; public record DeleteContributorRequest { public const string Route = "/Contributors/{ContributorId:int}"; - public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); + public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString(CultureInfo.InvariantCulture), StringComparison.InvariantCulture); public int ContributorId { get; set; } } diff --git a/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs b/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs index e7dec507f..c9fcd7790 100644 --- a/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs +++ b/src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs @@ -1,9 +1,11 @@ -namespace Clean.Architecture.Web.Contributors; +using System.Globalization; + +namespace Clean.Architecture.Web.Contributors; public class GetContributorByIdRequest { public const string Route = "/Contributors/{ContributorId:int}"; - public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); + public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString(CultureInfo.InvariantCulture), StringComparison.InvariantCulture); public int ContributorId { get; set; } } diff --git a/src/Clean.Architecture.Web/Contributors/GetById.cs b/src/Clean.Architecture.Web/Contributors/GetById.cs index 94ce0555f..1c60bb771 100644 --- a/src/Clean.Architecture.Web/Contributors/GetById.cs +++ b/src/Clean.Architecture.Web/Contributors/GetById.cs @@ -1,6 +1,6 @@ using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.UseCases.Contributors; -using Clean.Architecture.UseCases.Contributors.Get; +using Clean.Architecture.UseCases.Contributors.GetContributor; using Clean.Architecture.Web.Extensions; using Microsoft.AspNetCore.Http.HttpResults; diff --git a/src/Clean.Architecture.Web/Contributors/List.cs b/src/Clean.Architecture.Web/Contributors/List.cs index 6914695c5..1d0315168 100644 --- a/src/Clean.Architecture.Web/Contributors/List.cs +++ b/src/Clean.Architecture.Web/Contributors/List.cs @@ -29,7 +29,7 @@ public override void Configure() // Document pagination parameters s.Params["page"] = "1-based page index (default 1)"; - s.Params["per_page"] = $"Page size 1–{UseCases.Constants.MAX_PAGE_SIZE} (default {UseCases.Constants.DEFAULT_PAGE_SIZE})"; + s.Params["per_page"] = $"Page size 1–{UseCases.Constants.MaxPageSize} (default {UseCases.Constants.DefaultPageSize})"; // Document possible responses s.Responses[200] = "Paginated list of contributors returned successfully"; @@ -46,12 +46,12 @@ public override void Configure() .ProducesProblem(400)); } - public override async Task HandleAsync(ListContributorsRequest request, CancellationToken cancellationToken) + public override async Task HandleAsync(ListContributorsRequest request, CancellationToken ct) { - var result = await _mediator.Send(new ListContributorsQuery(request.Page, request.PerPage)); + var result = await _mediator.Send(new ListContributorsQuery(request.Page, request.PerPage), ct); if (!result.IsSuccess) { - await Send.ErrorsAsync(statusCode: 400, cancellationToken); + await Send.ErrorsAsync(statusCode: 400, ct); return; } @@ -59,7 +59,7 @@ public override async Task HandleAsync(ListContributorsRequest request, Cancella AddLinkHeader(pagedResult.Page, pagedResult.PerPage, pagedResult.TotalPages); var response = Map.FromEntity(pagedResult); - await Send.OkAsync(response, cancellationToken); + await Send.OkAsync(response, ct); } private void AddLinkHeader(int page, int perPage, int totalPages) @@ -92,7 +92,7 @@ public sealed class ListContributorsRequest // Bind to ?per_page= [BindFrom("per_page")] - public int PerPage { get; init; } = UseCases.Constants.DEFAULT_PAGE_SIZE; + public int PerPage { get; init; } = UseCases.Constants.DefaultPageSize; } public record ContributorListResponse : UseCases.PagedResult @@ -113,8 +113,8 @@ public ListContributorsValidator() .WithMessage("page must be >= 1"); RuleFor(x => x.PerPage) - .InclusiveBetween(1, UseCases.Constants.MAX_PAGE_SIZE) - .WithMessage($"per_page must be between 1 and {UseCases.Constants.MAX_PAGE_SIZE}"); + .InclusiveBetween(1, UseCases.Constants.MaxPageSize) + .WithMessage($"per_page must be between 1 and {UseCases.Constants.MaxPageSize}"); } } diff --git a/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorRequest.cs b/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorRequest.cs index a59341279..b1783940d 100644 --- a/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorRequest.cs +++ b/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorRequest.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; namespace Clean.Architecture.Web.Contributors; public class UpdateContributorRequest { public const string Route = "/Contributors/{ContributorId:int}"; - public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); + public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString(CultureInfo.InvariantCulture), StringComparison.InvariantCulture); public int ContributorId { get; set; } diff --git a/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorValidator.cs b/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorValidator.cs index c2e114edf..a9358b366 100644 --- a/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorValidator.cs +++ b/src/Clean.Architecture.Web/Contributors/Update.UpdateContributorValidator.cs @@ -1,5 +1,4 @@ using Clean.Architecture.Infrastructure.Data.Config; -using FastEndpoints; using FluentValidation; namespace Clean.Architecture.Web.Contributors; @@ -15,7 +14,7 @@ public UpdateContributorValidator() .NotEmpty() .WithMessage("Name is required.") .MinimumLength(2) - .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); + .MaximumLength(DataSchemaConstants.DefaultNameLength); RuleFor(x => x.ContributorId) .Must((args, contributorId) => args.Id == contributorId) .WithMessage("Route and body Ids must match; cannot update Id of an existing resource."); diff --git a/src/Clean.Architecture.Web/Contributors/Update.cs b/src/Clean.Architecture.Web/Contributors/Update.cs index 0f017c1ab..65c3a6bf3 100644 --- a/src/Clean.Architecture.Web/Contributors/Update.cs +++ b/src/Clean.Architecture.Web/Contributors/Update.cs @@ -1,6 +1,5 @@ using Clean.Architecture.Core.ContributorAggregate; using Clean.Architecture.UseCases.Contributors; -using Clean.Architecture.UseCases.Contributors.Get; using Clean.Architecture.UseCases.Contributors.Update; using Clean.Architecture.Web.Extensions; using Microsoft.AspNetCore.Http.HttpResults; diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 000000000..2982e3d6f --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,155 @@ +############################### +# Core EditorConfig Options # +############################### +root = false +# All files +[*] +indent_style = space + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 2 +insert_final_newline = true +charset = utf-8-bom +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +############################### +# Naming Conventions # +############################### +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const +tab_width= 2 +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ +dotnet_style_operator_placement_when_wrapping = beginning_of_line +end_of_line = crlf +############################### +# C# Coding Conventions # +############################### +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +# Namespaces +csharp_style_namespace_declarations = file_scoped:warning +############################### +# C# Formatting Rules # +############################### +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent + +dotnet_diagnostic.CA1707.severity = none + + +############################### +# VB Coding Conventions # +############################### +[*.vb] +# Modifier preferences +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion diff --git a/tests/.runsettings b/tests/.runsettings new file mode 100644 index 000000000..6862ac743 --- /dev/null +++ b/tests/.runsettings @@ -0,0 +1,17 @@ + + + + + 0 + + false + + + + + true + true + 0 + + + diff --git a/tests/Clean.Architecture.AspireTests/Clean.Architecture.AspireTests.csproj b/tests/Clean.Architecture.AspireTests/Clean.Architecture.AspireTests.csproj index 8c9210f4f..94c47bb2b 100644 --- a/tests/Clean.Architecture.AspireTests/Clean.Architecture.AspireTests.csproj +++ b/tests/Clean.Architecture.AspireTests/Clean.Architecture.AspireTests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs index 548fce886..b17ebb568 100644 --- a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs +++ b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs @@ -13,7 +13,7 @@ public async Task ReturnsTwoContributors() { var result = await _client.GetAndDeserializeAsync("/Contributors"); - Assert.Equal(SeedData.NUMBER_OF_CONTRIBUTORS, result.TotalCount); + Assert.Equal(SeedData.NumberOfContributors, result.TotalCount); Assert.Contains(result.Items, i => i.Name == SeedData.Contributor1Name.Value); Assert.Contains(result.Items, i => i.Name == SeedData.Contributor2Name.Value); } diff --git a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs index 0a040d0df..612538875 100644 --- a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs +++ b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs @@ -25,7 +25,7 @@ public async ValueTask InitializeAsync() } } - public new async ValueTask DisposeAsync() + public override async ValueTask DisposeAsync() { // Clean up environment variable Environment.SetEnvironmentVariable("USE_SQL_SERVER", null); @@ -33,6 +33,9 @@ public async ValueTask InitializeAsync() { await _dbContainer.DisposeAsync(); } + + await base.DisposeAsync(); + GC.SuppressFinalize(this); } /// @@ -71,7 +74,7 @@ protected override IHost CreateHost(IHostBuilder builder) } catch (Exception ex) { - logger.LogError(ex, "An error occurred seeding the database with test messages. Error: {exceptionMessage}", ex.Message); + logger.LogError(ex, "An error occurred seeding the database with test messages. Error: {ExceptionMessage}", ex.Message); throw; } } diff --git a/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs b/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs index d5c7b56f1..ee48bcb15 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs @@ -3,9 +3,11 @@ namespace Clean.Architecture.IntegrationTests.Data; -public abstract class BaseEfRepoTestFixture +public abstract class BaseEfRepoTestFixture : IDisposable, IAsyncDisposable { - protected AppDbContext _dbContext; + private readonly AppDbContext _dbContext; + + protected AppDbContext DbContext => _dbContext; protected BaseEfRepoTestFixture() { @@ -40,4 +42,16 @@ protected EfRepository GetRepository() { return new EfRepository(_dbContext); } + + public void Dispose() + { + GC.SuppressFinalize(this); + _dbContext.Dispose(); + } + + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + await _dbContext.DisposeAsync(); + } } diff --git a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs index 7d099503a..d675317d8 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs @@ -16,7 +16,7 @@ public async Task UpdatesItemAfterAddingIt() await repository.AddAsync(Contributor, cancellationToken); // detach the item so we get a different instance - _dbContext.Entry(Contributor).State = EntityState.Detached; + DbContext.Entry(Contributor).State = EntityState.Detached; // fetch the item and update its title var newContributor = (await repository.ListAsync(cancellationToken)) diff --git a/tests/Clean.Architecture.UnitTests/NoOpMediator.cs b/tests/Clean.Architecture.UnitTests/NoOpMediator.cs index 2358dab52..4d52ba997 100644 --- a/tests/Clean.Architecture.UnitTests/NoOpMediator.cs +++ b/tests/Clean.Architecture.UnitTests/NoOpMediator.cs @@ -2,9 +2,9 @@ public class NoOpMediator : IMediator { - public async Task> CreateStream(IStreamQuery query, CancellationToken cancellationToken = default) + public static async Task> CreateStream(IStreamQuery query, CancellationToken cancellationToken = default) { - await Task.Delay(1); + await Task.Delay(1, cancellationToken); return AsyncEnumerable.Empty(); }