From 34607035fa81c4b9cfba2ff3156606716dc8c80d Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Sun, 6 Sep 2026 17:36:50 -0500 Subject: [PATCH 1/4] Experiment: generate the Clean Architecture Solution Template 10.8.0 into experiments/before-clean-template dotnet new ca-sln -n LayerCake.CleanTemplate -cf None -db postgresql -o experiments/before-clean-template Template as generated, plus the two edits it took to build in place on this machine: global.json floor lowered to the installed 10.0.1xx SDK band, and NU1902/NU1903 audit warnings on the template's own transitive graph added to the template's existing WarningsNotAsErrors list (TreatWarningsAsErrors is on). Not part of LayerCake.slnx; not on the scorecard. --- .../.aspire/settings.json | 3 + .../.devcontainer/README.md | 21 + .../.devcontainer/devcontainer.json | 27 + .../before-clean-template/.editorconfig | 382 ++++++++++++++ experiments/before-clean-template/.gitignore | 492 ++++++++++++++++++ .../Directory.Build.props | 12 + .../Directory.Packages.props | 50 ++ .../LayerCake.CleanTemplate.slnx | 26 + experiments/before-clean-template/README.md | 57 ++ experiments/before-clean-template/global.json | 6 + .../src/AppHost/AppHost.csproj | 21 + .../src/AppHost/Extensions.cs | 14 + .../src/AppHost/Program.cs | 26 + .../AppHost/Properties/launchSettings.json | 39 ++ .../src/AppHost/appsettings.Development.json | 8 + .../src/AppHost/appsettings.json | 9 + .../src/Application/Application.csproj | 23 + .../Behaviours/AuthorizationBehaviour.cs | 80 +++ .../Common/Behaviours/LoggingBehaviour.cs | 35 ++ .../Common/Behaviours/PerformanceBehaviour.cs | 54 ++ .../Behaviours/UnhandledExceptionBehaviour.cs | 30 ++ .../Common/Behaviours/ValidationBehaviour.cs | 34 ++ .../Exceptions/ForbiddenAccessException.cs | 6 + .../Common/Exceptions/ValidationException.cs | 22 + .../Interfaces/IApplicationDbContext.cs | 12 + .../Common/Interfaces/IIdentityService.cs | 16 + .../Application/Common/Interfaces/IUser.cs | 8 + .../Application/Common/Models/LookupDto.cs | 19 + .../src/Application/Common/Models/Result.cs | 24 + .../Common/Security/AuthorizeAttribute.cs | 23 + .../src/Application/DependencyInjection.cs | 25 + .../src/Application/GlobalUsings.cs | 6 + .../Commands/CreateTodoItem/CreateTodoItem.cs | 37 ++ .../CreateTodoItemCommandValidator.cs | 11 + .../Commands/DeleteTodoItem/DeleteTodoItem.cs | 28 + .../Commands/UpdateTodoItem/UpdateTodoItem.cs | 35 ++ .../UpdateTodoItemCommandValidator.cs | 11 + .../UpdateTodoItemDetail.cs | 39 ++ .../EventHandlers/LogTodoItemCompleted.cs | 21 + .../Commands/CreateTodoList/CreateTodoList.cs | 37 ++ .../CreateTodoListCommandValidator.cs | 26 + .../Commands/DeleteTodoList/DeleteTodoList.cs | 28 + .../Commands/UpdateTodoList/UpdateTodoList.cs | 40 ++ .../UpdateTodoListCommandValidator.cs | 27 + .../TodoLists/Queries/GetTodos/GetTodos.cs | 50 ++ .../TodoLists/Queries/GetTodos/TodoItemDto.cs | 27 + .../TodoLists/Queries/GetTodos/TodoListDto.cs | 27 + .../TodoLists/Queries/GetTodos/TodosVm.cs | 19 + .../GetWeatherForecastsQuery.cs | 25 + .../GetWeatherForecasts/WeatherForecast.cs | 12 + .../src/Domain/Common/BaseAuditableEntity.cs | 12 + .../src/Domain/Common/BaseEntity.cs | 30 ++ .../src/Domain/Common/BaseEvent.cs | 7 + .../src/Domain/Common/ValueObject.cs | 55 ++ .../src/Domain/Constants/Roles.cs | 6 + .../src/Domain/Domain.csproj | 12 + .../src/Domain/Entities/TodoItem.cs | 29 ++ .../src/Domain/Entities/TodoList.cs | 10 + .../src/Domain/Enums/PriorityLevel.cs | 9 + .../Domain/Events/TodoItemCompletedEvent.cs | 11 + .../Exceptions/UnsupportedColourException.cs | 9 + .../src/Domain/GlobalUsings.cs | 6 + .../src/Domain/ValueObjects/Colour.cs | 66 +++ .../Data/ApplicationDbContext.cs | 23 + .../Data/ApplicationDbContextInitialiser.cs | 110 ++++ .../Configurations/TodoItemConfiguration.cs | 15 + .../Configurations/TodoListConfiguration.cs | 18 + .../AuditableEntityInterceptor.cs | 64 +++ .../DispatchDomainEventsInterceptor.cs | 50 ++ .../src/Infrastructure/DependencyInjection.cs | 50 ++ .../src/Infrastructure/GlobalUsings.cs | 2 + .../Identity/ApplicationUser.cs | 7 + .../Identity/IdentityResultExtensions.cs | 14 + .../Identity/IdentityService.cs | 81 +++ .../src/Infrastructure/Infrastructure.csproj | 21 + .../src/ServiceDefaults/Extensions.cs | 119 +++++ .../ServiceDefaults/ServiceDefaults.csproj | 21 + .../src/Shared/Services.cs | 28 + .../src/Shared/Shared.csproj | 14 + .../src/Web/DependencyInjection.cs | 47 ++ .../src/Web/Endpoints/TodoItems.cs | 61 +++ .../src/Web/Endpoints/TodoLists.cs | 58 +++ .../src/Web/Endpoints/Users.cs | 29 ++ .../src/Web/Endpoints/WeatherForecasts.cs | 23 + .../src/Web/GlobalUsings.cs | 3 + .../ApiExceptionOperationTransformer.cs | 31 ++ .../BearerSecuritySchemeTransformer.cs | 33 ++ .../EndpointRouteBuilderExtensions.cs | 63 +++ .../src/Web/Infrastructure/IEndpointGroup.cs | 19 + .../IdentityApiOperationTransformer.cs | 42 ++ .../Infrastructure/MethodInfoExtensions.cs | 27 + .../ProblemDetailsExceptionHandler.cs | 52 ++ .../WebApplicationExtensions.cs | 29 ++ .../before-clean-template/src/Web/Program.cs | 46 ++ .../src/Web/Properties/launchSettings.json | 25 + .../src/Web/Services/CurrentUser.cs | 19 + .../before-clean-template/src/Web/Web.csproj | 28 + .../before-clean-template/src/Web/Web.http | 139 +++++ .../src/Web/appsettings.json | 12 + .../src/Web/wwwroot/favicon.ico | Bin 0 -> 5430 bytes .../Application.FunctionalTests.csproj | 40 ++ .../FunctionalTestSetup.cs | 55 ++ .../GlobalUsings.cs | 6 + .../Infrastructure/DatabaseResetter.cs | 36 ++ .../Infrastructure/TestApp.cs | 123 +++++ .../Infrastructure/TestBase.cs | 10 + .../Infrastructure/WebApiFactory.cs | 30 ++ .../TodoItems/Commands/CreateTodoItemTests.cs | 46 ++ .../TodoItems/Commands/DeleteTodoItemTests.cs | 38 ++ .../Commands/UpdateTodoItemDetailTests.cs | 56 ++ .../TodoItems/Commands/UpdateTodoItemTests.cs | 49 ++ .../TodoLists/Commands/CreateTodoListTests.cs | 51 ++ .../TodoLists/Commands/DeleteTodoListTests.cs | 30 ++ .../TodoLists/Commands/UpdateTodoListTests.cs | 68 +++ .../TodoLists/Queries/GetTodosTests.cs | 59 +++ .../Application.UnitTests.csproj | 30 ++ .../Common/Behaviours/RequestLoggerTests.cs | 45 ++ .../Exceptions/ValidationExceptionTests.cs | 63 +++ .../Common/Mappings/MappingTests.cs | 61 +++ .../Domain.UnitTests/Domain.UnitTests.csproj | 27 + .../ValueObjects/ColourTests.cs | 59 +++ .../GlobalUsings.cs | 1 + .../Infrastructure.IntegrationTests.csproj | 22 + .../tests/TestAppHost/Program.cs | 16 + .../tests/TestAppHost/TestAppHost.csproj | 18 + 125 files changed, 4834 insertions(+) create mode 100644 experiments/before-clean-template/.aspire/settings.json create mode 100644 experiments/before-clean-template/.devcontainer/README.md create mode 100644 experiments/before-clean-template/.devcontainer/devcontainer.json create mode 100644 experiments/before-clean-template/.editorconfig create mode 100644 experiments/before-clean-template/.gitignore create mode 100644 experiments/before-clean-template/Directory.Build.props create mode 100644 experiments/before-clean-template/Directory.Packages.props create mode 100644 experiments/before-clean-template/LayerCake.CleanTemplate.slnx create mode 100644 experiments/before-clean-template/README.md create mode 100644 experiments/before-clean-template/global.json create mode 100644 experiments/before-clean-template/src/AppHost/AppHost.csproj create mode 100644 experiments/before-clean-template/src/AppHost/Extensions.cs create mode 100644 experiments/before-clean-template/src/AppHost/Program.cs create mode 100644 experiments/before-clean-template/src/AppHost/Properties/launchSettings.json create mode 100644 experiments/before-clean-template/src/AppHost/appsettings.Development.json create mode 100644 experiments/before-clean-template/src/AppHost/appsettings.json create mode 100644 experiments/before-clean-template/src/Application/Application.csproj create mode 100644 experiments/before-clean-template/src/Application/Common/Behaviours/AuthorizationBehaviour.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Behaviours/LoggingBehaviour.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Behaviours/PerformanceBehaviour.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Behaviours/ValidationBehaviour.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Exceptions/ForbiddenAccessException.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Exceptions/ValidationException.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Interfaces/IIdentityService.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Interfaces/IUser.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Models/LookupDto.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Models/Result.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Security/AuthorizeAttribute.cs create mode 100644 experiments/before-clean-template/src/Application/DependencyInjection.cs create mode 100644 experiments/before-clean-template/src/Application/GlobalUsings.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs create mode 100644 experiments/before-clean-template/src/Application/TodoItems/EventHandlers/LogTodoItemCompleted.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs create mode 100644 experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs create mode 100644 experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs create mode 100644 experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs create mode 100644 experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs create mode 100644 experiments/before-clean-template/src/Domain/Common/BaseEntity.cs create mode 100644 experiments/before-clean-template/src/Domain/Common/BaseEvent.cs create mode 100644 experiments/before-clean-template/src/Domain/Common/ValueObject.cs create mode 100644 experiments/before-clean-template/src/Domain/Constants/Roles.cs create mode 100644 experiments/before-clean-template/src/Domain/Domain.csproj create mode 100644 experiments/before-clean-template/src/Domain/Entities/TodoItem.cs create mode 100644 experiments/before-clean-template/src/Domain/Entities/TodoList.cs create mode 100644 experiments/before-clean-template/src/Domain/Enums/PriorityLevel.cs create mode 100644 experiments/before-clean-template/src/Domain/Events/TodoItemCompletedEvent.cs create mode 100644 experiments/before-clean-template/src/Domain/Exceptions/UnsupportedColourException.cs create mode 100644 experiments/before-clean-template/src/Domain/GlobalUsings.cs create mode 100644 experiments/before-clean-template/src/Domain/ValueObjects/Colour.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/DependencyInjection.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/GlobalUsings.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Identity/ApplicationUser.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Identity/IdentityResultExtensions.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Identity/IdentityService.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Infrastructure.csproj create mode 100644 experiments/before-clean-template/src/ServiceDefaults/Extensions.cs create mode 100644 experiments/before-clean-template/src/ServiceDefaults/ServiceDefaults.csproj create mode 100644 experiments/before-clean-template/src/Shared/Services.cs create mode 100644 experiments/before-clean-template/src/Shared/Shared.csproj create mode 100644 experiments/before-clean-template/src/Web/DependencyInjection.cs create mode 100644 experiments/before-clean-template/src/Web/Endpoints/TodoItems.cs create mode 100644 experiments/before-clean-template/src/Web/Endpoints/TodoLists.cs create mode 100644 experiments/before-clean-template/src/Web/Endpoints/Users.cs create mode 100644 experiments/before-clean-template/src/Web/Endpoints/WeatherForecasts.cs create mode 100644 experiments/before-clean-template/src/Web/GlobalUsings.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/ApiExceptionOperationTransformer.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/BearerSecuritySchemeTransformer.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/EndpointRouteBuilderExtensions.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/IEndpointGroup.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/IdentityApiOperationTransformer.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/MethodInfoExtensions.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs create mode 100644 experiments/before-clean-template/src/Web/Infrastructure/WebApplicationExtensions.cs create mode 100644 experiments/before-clean-template/src/Web/Program.cs create mode 100644 experiments/before-clean-template/src/Web/Properties/launchSettings.json create mode 100644 experiments/before-clean-template/src/Web/Services/CurrentUser.cs create mode 100644 experiments/before-clean-template/src/Web/Web.csproj create mode 100644 experiments/before-clean-template/src/Web/Web.http create mode 100644 experiments/before-clean-template/src/Web/appsettings.json create mode 100644 experiments/before-clean-template/src/Web/wwwroot/favicon.ico create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/Application.FunctionalTests.csproj create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/FunctionalTestSetup.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/GlobalUsings.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/DatabaseResetter.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestApp.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestBase.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/WebApiFactory.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs create mode 100644 experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs create mode 100644 experiments/before-clean-template/tests/Application.UnitTests/Application.UnitTests.csproj create mode 100644 experiments/before-clean-template/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs create mode 100644 experiments/before-clean-template/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs create mode 100644 experiments/before-clean-template/tests/Application.UnitTests/Common/Mappings/MappingTests.cs create mode 100644 experiments/before-clean-template/tests/Domain.UnitTests/Domain.UnitTests.csproj create mode 100644 experiments/before-clean-template/tests/Domain.UnitTests/ValueObjects/ColourTests.cs create mode 100644 experiments/before-clean-template/tests/Infrastructure.IntegrationTests/GlobalUsings.cs create mode 100644 experiments/before-clean-template/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj create mode 100644 experiments/before-clean-template/tests/TestAppHost/Program.cs create mode 100644 experiments/before-clean-template/tests/TestAppHost/TestAppHost.csproj diff --git a/experiments/before-clean-template/.aspire/settings.json b/experiments/before-clean-template/.aspire/settings.json new file mode 100644 index 0000000..50df8a3 --- /dev/null +++ b/experiments/before-clean-template/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../src/AppHost/AppHost.csproj" +} \ No newline at end of file diff --git a/experiments/before-clean-template/.devcontainer/README.md b/experiments/before-clean-template/.devcontainer/README.md new file mode 100644 index 0000000..9e55e45 --- /dev/null +++ b/experiments/before-clean-template/.devcontainer/README.md @@ -0,0 +1,21 @@ +# Dev Container + +This folder contains configuration for running the project inside a **Dev Container** (VS Code Remote Containers or GitHub Codespaces). + +## What this folder does +- Defines the development environment (SDK versions, tools, extensions) +- Ensures every developer uses the same environment +- Simplifies onboarding by eliminating local machine setup issues +- Supports GitHub Codespaces for cloud-based development + +## When to use it +If using VS Code: +1. Install the “Dev Containers” extension. +2. Open the repository. +3. Select **“Reopen in Container”**. + +If using GitHub Codespaces: +- Codespaces will automatically use this configuration when the workspace starts. + +## Notes +This folder has no effect on the runtime application. It is only used to configure the developer environment. diff --git a/experiments/before-clean-template/.devcontainer/devcontainer.json b/experiments/before-clean-template/.devcontainer/devcontainer.json new file mode 100644 index 0000000..74f9d30 --- /dev/null +++ b/experiments/before-clean-template/.devcontainer/devcontainer.json @@ -0,0 +1,27 @@ +{ + "name": "LayerCake.CleanTemplate", + "image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye", + "features": { + // See https://containers.dev/features for list of features + "ghcr.io/devcontainers/features/docker-in-docker:2": { + } + }, + "customizations": { + "vscode": { + "extensions": [ + "GitHub.vscode-github-actions", + "ms-azuretools.vscode-docker" + // Include other VSCode language extensions if needed + // Right click on an extension inside VSCode to add directly to devcontainer.json, or copy the extension ID + ] + } + }, + "forwardPorts": [ + // Forward ports if needed for local development + ], + "postCreateCommand": "", + "remoteUser": "vscode", + "hostRequirements": { + "memory": "8gb" + } +} diff --git a/experiments/before-clean-template/.editorconfig b/experiments/before-clean-template/.editorconfig new file mode 100644 index 0000000..59b0e38 --- /dev/null +++ b/experiments/before-clean-template/.editorconfig @@ -0,0 +1,382 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.{xml,csproj,props,targets,ruleset,nuspec,resx}] +indent_size = 2 + +# Javascript files +[*.js] +indent_size = 2 + +# Json files +[*.{json,config}] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = lf +insert_final_newline = true + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = 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_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_style_namespace_declarations = file_scoped:silent +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_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + +dotnet_style_namespace_match_folder = true:suggestion diff --git a/experiments/before-clean-template/.gitignore b/experiments/before-clean-template/.gitignore new file mode 100644 index 0000000..26a9067 --- /dev/null +++ b/experiments/before-clean-template/.gitignore @@ -0,0 +1,492 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +tools/ +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Auto-generated OpenAPI specification and TypeScript API clients +src/Web/wwwroot/openapi/v1.json +src/Web/ClientApp/src/app/web-api-client.ts +src/Web/ClientApp-React/src/web-api-client.ts + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp +.azure + +# SQLite database files +*.db +*.db-shm +*.db-wal diff --git a/experiments/before-clean-template/Directory.Build.props b/experiments/before-clean-template/Directory.Build.props new file mode 100644 index 0000000..17d0198 --- /dev/null +++ b/experiments/before-clean-template/Directory.Build.props @@ -0,0 +1,12 @@ + + + + net10.0 + true + + NU1608;NU1902;NU1903 + $(MSBuildThisFileDirectory)artifacts + enable + enable + + \ No newline at end of file diff --git a/experiments/before-clean-template/Directory.Packages.props b/experiments/before-clean-template/Directory.Packages.props new file mode 100644 index 0000000..dfdc545 --- /dev/null +++ b/experiments/before-clean-template/Directory.Packages.props @@ -0,0 +1,50 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/experiments/before-clean-template/LayerCake.CleanTemplate.slnx b/experiments/before-clean-template/LayerCake.CleanTemplate.slnx new file mode 100644 index 0000000..73459da --- /dev/null +++ b/experiments/before-clean-template/LayerCake.CleanTemplate.slnx @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experiments/before-clean-template/README.md b/experiments/before-clean-template/README.md new file mode 100644 index 0000000..0a02cd9 --- /dev/null +++ b/experiments/before-clean-template/README.md @@ -0,0 +1,57 @@ +# LayerCake.CleanTemplate + +The project was generated using the [Clean.Architecture.Solution.Template](https://github.com/jasontaylordev/CleanArchitecture) version 10.8.0. + +## Build + +Run `dotnet build` to build the solution. + +## Run + +To run the application: + +```bash +dotnet run --project .\src\AppHost +``` + +The Aspire dashboard will open automatically, showing the application URLs and logs. + +## Code Styles & Formatting + +The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution. + +## Code Scaffolding + +The template includes support to scaffold new commands and queries. + +Start in the `.\src\Application\` folder. + +Create a new command: + +``` +dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int +``` + +Create a new query: + +``` +dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm +``` + +If you encounter the error *"No templates or subcommands found matching: 'ca-usecase'."*, install the template and try again: + +```bash +dotnet new install Clean.Architecture.Solution.Template::10.8.0 +``` + +## Test + +The solution contains unit, integration, and functional tests. + +To run the tests: +```bash +dotnet test +``` + +## Help +To learn more about the template go to the [project website](https://cleanarchitecture.jasontaylor.dev). Here you can find additional guidance, request new features, report a bug, and discuss the template with other users. \ No newline at end of file diff --git a/experiments/before-clean-template/global.json b/experiments/before-clean-template/global.json new file mode 100644 index 0000000..f72210c --- /dev/null +++ b/experiments/before-clean-template/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/experiments/before-clean-template/src/AppHost/AppHost.csproj b/experiments/before-clean-template/src/AppHost/AppHost.csproj new file mode 100644 index 0000000..3b389a1 --- /dev/null +++ b/experiments/before-clean-template/src/AppHost/AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + LayerCake.CleanTemplate.AppHost + LayerCake.CleanTemplate.AppHost + + + + + + + + + + + + + + + diff --git a/experiments/before-clean-template/src/AppHost/Extensions.cs b/experiments/before-clean-template/src/AppHost/Extensions.cs new file mode 100644 index 0000000..ff5fd3d --- /dev/null +++ b/experiments/before-clean-template/src/AppHost/Extensions.cs @@ -0,0 +1,14 @@ +internal static class AspireExtensions +{ + public static IResourceBuilder WithAspNetCoreEnvironment(this IResourceBuilder builder) + where T : IResourceWithEnvironment + { + builder.WithEnvironment(context => + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + context.EnvironmentVariables["ASPNETCORE_ENVIRONMENT"] = environment ?? "Development"; + }); + + return builder; + } +} \ No newline at end of file diff --git a/experiments/before-clean-template/src/AppHost/Program.cs b/experiments/before-clean-template/src/AppHost/Program.cs new file mode 100644 index 0000000..809bc19 --- /dev/null +++ b/experiments/before-clean-template/src/AppHost/Program.cs @@ -0,0 +1,26 @@ +using LayerCake.CleanTemplate.Shared; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddAzureContainerAppEnvironment("aca-env"); + +var databaseServer = builder + .AddAzurePostgresFlexibleServer(Services.DatabaseServer) + .WithPasswordAuthentication() + .RunAsContainer(container => + container.WithLifetime(ContainerLifetime.Persistent)) + .AddDatabase(Services.Database); + +var web = builder.AddProject(Services.WebApi) + .WithReference(databaseServer) + .WaitFor(databaseServer) + .WithExternalHttpEndpoints() + .WithAspNetCoreEnvironment() + .WithUrlForEndpoint("http", url => + { + url.DisplayText = "Scalar API Reference"; + url.Url = "/scalar"; + }); + + +builder.Build().Run(); diff --git a/experiments/before-clean-template/src/AppHost/Properties/launchSettings.json b/experiments/before-clean-template/src/AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..a1fed65 --- /dev/null +++ b/experiments/before-clean-template/src/AppHost/Properties/launchSettings.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://layercake.cleantemplate.dev.localhost:17279;http://layercake.cleantemplate.dev.localhost:15206", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21207", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22288" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://layercake.cleantemplate.dev.localhost:15206", + "environmentVariables": { + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19245", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20062" + } + }, + "manifest": { + "commandName": "Project", + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "dotnetRunMessages": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production", + "DOTNET_ENVIRONMENT": "Production" + } + } + } +} diff --git a/experiments/before-clean-template/src/AppHost/appsettings.Development.json b/experiments/before-clean-template/src/AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/experiments/before-clean-template/src/AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/experiments/before-clean-template/src/AppHost/appsettings.json b/experiments/before-clean-template/src/AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/experiments/before-clean-template/src/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/experiments/before-clean-template/src/Application/Application.csproj b/experiments/before-clean-template/src/Application/Application.csproj new file mode 100644 index 0000000..51c44c7 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Application.csproj @@ -0,0 +1,23 @@ + + + + LayerCake.CleanTemplate.Application + LayerCake.CleanTemplate.Application + + + + + + + + + + + + + + + + + + diff --git a/experiments/before-clean-template/src/Application/Common/Behaviours/AuthorizationBehaviour.cs b/experiments/before-clean-template/src/Application/Common/Behaviours/AuthorizationBehaviour.cs new file mode 100644 index 0000000..43fdbe3 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Behaviours/AuthorizationBehaviour.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Application.Common.Security; + +namespace LayerCake.CleanTemplate.Application.Common.Behaviours; + +public class AuthorizationBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public AuthorizationBehaviour( + IUser user, + IIdentityService identityService) + { + _user = user; + _identityService = identityService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var authorizeAttributes = request.GetType().GetCustomAttributes(); + + if (authorizeAttributes.Any()) + { + // Must be authenticated user + if (_user.Id == null) + { + throw new UnauthorizedAccessException(); + } + + // Role-based authorization + var authorizeAttributesWithRoles = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Roles)); + + if (authorizeAttributesWithRoles.Any()) + { + var authorized = false; + + foreach (var roles in authorizeAttributesWithRoles.Select(a => a.Roles.Split(','))) + { + foreach (var role in roles) + { + var isInRole = _user.Roles?.Any(x => role == x)??false; + if (isInRole) + { + authorized = true; + break; + } + } + } + + // Must be a member of at least one role in roles + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + + // Policy-based authorization + var authorizeAttributesWithPolicies = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Policy)); + if (authorizeAttributesWithPolicies.Any()) + { + foreach (var policy in authorizeAttributesWithPolicies.Select(a => a.Policy)) + { + var authorized = await _identityService.AuthorizeAsync(_user.Id, policy); + + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + } + } + + // User is authorized / authorization not required + return await next(); + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Behaviours/LoggingBehaviour.cs b/experiments/before-clean-template/src/Application/Common/Behaviours/LoggingBehaviour.cs new file mode 100644 index 0000000..d87a2d1 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Behaviours/LoggingBehaviour.cs @@ -0,0 +1,35 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using MediatR.Pipeline; +using Microsoft.Extensions.Logging; + +namespace LayerCake.CleanTemplate.Application.Common.Behaviours; + +public class LoggingBehaviour : IRequestPreProcessor + where TRequest : notnull +{ + private readonly ILogger _logger; + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public LoggingBehaviour(ILogger logger, IUser user, IIdentityService identityService) + { + _logger = logger; + _user = user; + _identityService = identityService; + } + + public async Task Process(TRequest request, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + var userId = _user.Id ?? string.Empty; + string? userName = string.Empty; + + if (!string.IsNullOrEmpty(userId)) + { + userName = await _identityService.GetUserNameAsync(userId); + } + + _logger.LogInformation("LayerCake.CleanTemplate Request: {Name} {@UserId} {@UserName} {@Request}", + requestName, userId, userName, request); + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Behaviours/PerformanceBehaviour.cs b/experiments/before-clean-template/src/Application/Common/Behaviours/PerformanceBehaviour.cs new file mode 100644 index 0000000..32f60b8 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Behaviours/PerformanceBehaviour.cs @@ -0,0 +1,54 @@ +using System.Diagnostics; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using Microsoft.Extensions.Logging; + +namespace LayerCake.CleanTemplate.Application.Common.Behaviours; + +public class PerformanceBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly Stopwatch _timer; + private readonly ILogger _logger; + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public PerformanceBehaviour( + ILogger logger, + IUser user, + IIdentityService identityService) + { + _timer = new Stopwatch(); + + _logger = logger; + _user = user; + _identityService = identityService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + _timer.Start(); + + var response = await next(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds > 500) + { + var requestName = typeof(TRequest).Name; + var userId = _user.Id ?? string.Empty; + var userName = string.Empty; + + if (!string.IsNullOrEmpty(userId)) + { + userName = await _identityService.GetUserNameAsync(userId); + } + + _logger.LogWarning("LayerCake.CleanTemplate Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}", + requestName, elapsedMilliseconds, userId, userName, request); + } + + return response; + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs b/experiments/before-clean-template/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs new file mode 100644 index 0000000..5bb99a9 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; + +namespace LayerCake.CleanTemplate.Application.Common.Behaviours; + +public class UnhandledExceptionBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly ILogger _logger; + + public UnhandledExceptionBehaviour(ILogger logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + + _logger.LogError(ex, "LayerCake.CleanTemplate Request: Unhandled Exception for Request {Name} {@Request}", requestName, request); + + throw; + } + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Behaviours/ValidationBehaviour.cs b/experiments/before-clean-template/src/Application/Common/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000..cad97a7 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,34 @@ +using ValidationException = LayerCake.CleanTemplate.Application.Common.Exceptions.ValidationException; + +namespace LayerCake.CleanTemplate.Application.Common.Behaviours; + +public class ValidationBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehaviour(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var validationResults = await Task.WhenAll( + _validators.Select(v => + v.ValidateAsync(new ValidationContext(request), cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Any()) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Exceptions/ForbiddenAccessException.cs b/experiments/before-clean-template/src/Application/Common/Exceptions/ForbiddenAccessException.cs new file mode 100644 index 0000000..58cf882 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Exceptions/ForbiddenAccessException.cs @@ -0,0 +1,6 @@ +namespace LayerCake.CleanTemplate.Application.Common.Exceptions; + +public class ForbiddenAccessException : Exception +{ + public ForbiddenAccessException() : base() { } +} diff --git a/experiments/before-clean-template/src/Application/Common/Exceptions/ValidationException.cs b/experiments/before-clean-template/src/Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000..648e503 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,22 @@ +using FluentValidation.Results; + +namespace LayerCake.CleanTemplate.Application.Common.Exceptions; + +public class ValidationException : Exception +{ + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public IDictionary Errors { get; } +} diff --git a/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs b/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs new file mode 100644 index 0000000..d6477de --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -0,0 +1,12 @@ +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.Common.Interfaces; + +public interface IApplicationDbContext +{ + DbSet TodoLists { get; } + + DbSet TodoItems { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/experiments/before-clean-template/src/Application/Common/Interfaces/IIdentityService.cs b/experiments/before-clean-template/src/Application/Common/Interfaces/IIdentityService.cs new file mode 100644 index 0000000..0ca8c99 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Interfaces/IIdentityService.cs @@ -0,0 +1,16 @@ +using LayerCake.CleanTemplate.Application.Common.Models; + +namespace LayerCake.CleanTemplate.Application.Common.Interfaces; + +public interface IIdentityService +{ + Task GetUserNameAsync(string userId); + + Task IsInRoleAsync(string userId, string role); + + Task AuthorizeAsync(string userId, string policyName); + + Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password); + + Task DeleteUserAsync(string userId); +} diff --git a/experiments/before-clean-template/src/Application/Common/Interfaces/IUser.cs b/experiments/before-clean-template/src/Application/Common/Interfaces/IUser.cs new file mode 100644 index 0000000..7f8f24f --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Interfaces/IUser.cs @@ -0,0 +1,8 @@ +namespace LayerCake.CleanTemplate.Application.Common.Interfaces; + +public interface IUser +{ + string? Id { get; } + List? Roles { get; } + +} diff --git a/experiments/before-clean-template/src/Application/Common/Models/LookupDto.cs b/experiments/before-clean-template/src/Application/Common/Models/LookupDto.cs new file mode 100644 index 0000000..7496e35 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Models/LookupDto.cs @@ -0,0 +1,19 @@ +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.Common.Models; + +public class LookupDto +{ + public int Id { get; init; } + + public string? Title { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + CreateMap(); + } + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Models/Result.cs b/experiments/before-clean-template/src/Application/Common/Models/Result.cs new file mode 100644 index 0000000..fe89c53 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Models/Result.cs @@ -0,0 +1,24 @@ +namespace LayerCake.CleanTemplate.Application.Common.Models; + +public class Result +{ + internal Result(bool succeeded, IEnumerable errors) + { + Succeeded = succeeded; + Errors = errors.ToArray(); + } + + public bool Succeeded { get; init; } + + public string[] Errors { get; init; } + + public static Result Success() + { + return new Result(true, Array.Empty()); + } + + public static Result Failure(IEnumerable errors) + { + return new Result(false, errors); + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Security/AuthorizeAttribute.cs b/experiments/before-clean-template/src/Application/Common/Security/AuthorizeAttribute.cs new file mode 100644 index 0000000..79602ea --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Security/AuthorizeAttribute.cs @@ -0,0 +1,23 @@ +namespace LayerCake.CleanTemplate.Application.Common.Security; + +/// +/// Specifies the class this attribute is applied to requires authorization. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public class AuthorizeAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public AuthorizeAttribute() { } + + /// + /// Gets or sets a comma delimited list of roles that are allowed to access the resource. + /// + public string Roles { get; set; } = string.Empty; + + /// + /// Gets or sets the policy name that determines access to the resource. + /// + public string Policy { get; set; } = string.Empty; +} diff --git a/experiments/before-clean-template/src/Application/DependencyInjection.cs b/experiments/before-clean-template/src/Application/DependencyInjection.cs new file mode 100644 index 0000000..16294b5 --- /dev/null +++ b/experiments/before-clean-template/src/Application/DependencyInjection.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using LayerCake.CleanTemplate.Application.Common.Behaviours; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static void AddApplicationServices(this IHostApplicationBuilder builder) + { + builder.Services.AddAutoMapper(cfg => + cfg.AddMaps(Assembly.GetExecutingAssembly())); + + builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + cfg.AddOpenRequestPreProcessor(typeof(LoggingBehaviour<>)); + cfg.AddOpenBehavior(typeof(UnhandledExceptionBehaviour<,>)); + cfg.AddOpenBehavior(typeof(AuthorizationBehaviour<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehaviour<,>)); + cfg.AddOpenBehavior(typeof(PerformanceBehaviour<,>)); + }); + } +} diff --git a/experiments/before-clean-template/src/Application/GlobalUsings.cs b/experiments/before-clean-template/src/Application/GlobalUsings.cs new file mode 100644 index 0000000..fa904ba --- /dev/null +++ b/experiments/before-clean-template/src/Application/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using Ardalis.GuardClauses; +global using AutoMapper; +global using AutoMapper.QueryableExtensions; +global using Microsoft.EntityFrameworkCore; +global using FluentValidation; +global using MediatR; \ No newline at end of file diff --git a/experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs b/experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs new file mode 100644 index 0000000..a5fedb1 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs @@ -0,0 +1,37 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; + +public record CreateTodoItemCommand : IRequest +{ + public int ListId { get; init; } + + public string? Title { get; init; } +} + +public class CreateTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public CreateTodoItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(CreateTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = new TodoItem + { + ListId = request.ListId, + Title = request.Title, + Done = false + }; + + _context.TodoItems.Add(entity); + + await _context.SaveChangesAsync(cancellationToken); + + return entity.Id; + } +} diff --git a/experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs b/experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs new file mode 100644 index 0000000..ea705a3 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs @@ -0,0 +1,11 @@ +namespace LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; + +public class CreateTodoItemCommandValidator : AbstractValidator +{ + public CreateTodoItemCommandValidator() + { + RuleFor(v => v.Title) + .MaximumLength(200) + .NotEmpty(); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs b/experiments/before-clean-template/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs new file mode 100644 index 0000000..dea501f --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs @@ -0,0 +1,28 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.TodoItems.Commands.DeleteTodoItem; + +public record DeleteTodoItemCommand(int Id) : IRequest; + +public class DeleteTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public DeleteTodoItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItems + .FindAsync([request.Id], cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + _context.TodoItems.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + } + +} diff --git a/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs b/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs new file mode 100644 index 0000000..fd16e5f --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs @@ -0,0 +1,35 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItem; + +public record UpdateTodoItemCommand : IRequest +{ + public int Id { get; init; } + + public string? Title { get; init; } + + public bool Done { get; init; } +} + +public class UpdateTodoItemCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTodoItemCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItems + .FindAsync([request.Id], cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + entity.Title = request.Title; + entity.Done = request.Done; + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs b/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs new file mode 100644 index 0000000..c97922d --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs @@ -0,0 +1,11 @@ +namespace LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItem; + +public class UpdateTodoItemCommandValidator : AbstractValidator +{ + public UpdateTodoItemCommandValidator() + { + RuleFor(v => v.Title) + .MaximumLength(200) + .NotEmpty(); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs b/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs new file mode 100644 index 0000000..45c71a3 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs @@ -0,0 +1,39 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.Enums; + +namespace LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItemDetail; + +public record UpdateTodoItemDetailCommand : IRequest +{ + public int Id { get; init; } + + public int ListId { get; init; } + + public PriorityLevel Priority { get; init; } + + public string? Note { get; init; } +} + +public class UpdateTodoItemDetailCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoItemDetailCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTodoItemDetailCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoItems + .FindAsync([request.Id], cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + entity.ListId = request.ListId; + entity.Priority = request.Priority; + entity.Note = request.Note; + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoItems/EventHandlers/LogTodoItemCompleted.cs b/experiments/before-clean-template/src/Application/TodoItems/EventHandlers/LogTodoItemCompleted.cs new file mode 100644 index 0000000..bcd59dc --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoItems/EventHandlers/LogTodoItemCompleted.cs @@ -0,0 +1,21 @@ +using LayerCake.CleanTemplate.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace LayerCake.CleanTemplate.Application.TodoItems.EventHandlers; + +public class LogTodoItemCompleted : INotificationHandler +{ + private readonly ILogger _logger; + + public LogTodoItemCompleted(ILogger logger) + { + _logger = logger; + } + + public Task Handle(TodoItemCompletedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("LayerCake.CleanTemplate Domain Event: {DomainEvent}", notification.GetType().Name); + + return Task.CompletedTask; + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs b/experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs new file mode 100644 index 0000000..5da9768 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs @@ -0,0 +1,37 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Domain.ValueObjects; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; + +public record CreateTodoListCommand : IRequest +{ + public string? Title { get; init; } + + public string? Colour { get; init; } +} + +public class CreateTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public CreateTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(CreateTodoListCommand request, CancellationToken cancellationToken) + { + var entity = new TodoList + { + Title = request.Title, + Colour = Colour.From(request.Colour ?? Colour.Grey) + }; + + _context.TodoLists.Add(entity); + + await _context.SaveChangesAsync(cancellationToken); + + return entity.Id; + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs b/experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs new file mode 100644 index 0000000..bacdd4c --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs @@ -0,0 +1,26 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; + +public class CreateTodoListCommandValidator : AbstractValidator +{ + private readonly IApplicationDbContext _context; + + public CreateTodoListCommandValidator(IApplicationDbContext context) + { + _context = context; + + RuleFor(v => v.Title) + .NotEmpty() + .MaximumLength(200) + .MustAsync(BeUniqueTitle) + .WithMessage("'{PropertyName}' must be unique.") + .WithErrorCode("Unique"); + } + + public async Task BeUniqueTitle(string title, CancellationToken cancellationToken) + { + return !await _context.TodoLists + .AnyAsync(l => l.Title == title, cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs b/experiments/before-clean-template/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs new file mode 100644 index 0000000..b71bc77 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs @@ -0,0 +1,28 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Commands.DeleteTodoList; + +public record DeleteTodoListCommand(int Id) : IRequest; + +public class DeleteTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public DeleteTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteTodoListCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoLists + .Where(l => l.Id == request.Id) + .SingleOrDefaultAsync(cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + _context.TodoLists.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs b/experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs new file mode 100644 index 0000000..5440e29 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs @@ -0,0 +1,40 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.ValueObjects; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Commands.UpdateTodoList; + +public record UpdateTodoListCommand : IRequest +{ + public int Id { get; init; } + + public string? Title { get; init; } + + public string? Colour { get; init; } +} + +public class UpdateTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(UpdateTodoListCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoLists + .FindAsync([request.Id], cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + entity.Title = request.Title; + + if (request.Colour is not null) + { + entity.Colour = Colour.From(request.Colour); + } + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs b/experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs new file mode 100644 index 0000000..d1718b3 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs @@ -0,0 +1,27 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Commands.UpdateTodoList; + +public class UpdateTodoListCommandValidator : AbstractValidator +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoListCommandValidator(IApplicationDbContext context) + { + _context = context; + + RuleFor(v => v.Title) + .NotEmpty() + .MaximumLength(200) + .MustAsync(BeUniqueTitle) + .WithMessage("'{PropertyName}' must be unique.") + .WithErrorCode("Unique"); + } + + public async Task BeUniqueTitle(UpdateTodoListCommand model, string title, CancellationToken cancellationToken) + { + return !await _context.TodoLists + .Where(l => l.Id != model.Id) + .AnyAsync(l => l.Title == title, cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs new file mode 100644 index 0000000..38948c6 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs @@ -0,0 +1,50 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Application.Common.Models; +using LayerCake.CleanTemplate.Application.Common.Security; +using LayerCake.CleanTemplate.Domain.Enums; +using LayerCake.CleanTemplate.Domain.ValueObjects; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; + +[Authorize] +public record GetTodosQuery : IRequest; + +public class GetTodosQueryHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + + public GetTodosQueryHandler(IApplicationDbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task Handle(GetTodosQuery request, CancellationToken cancellationToken) + { + return new TodosVm + { + PriorityLevels = Enum.GetValues(typeof(PriorityLevel)) + .Cast() + .Select(p => new LookupDto { Id = (int)p, Title = p.ToString() }) + .ToList(), + + Colours = + [ + new ColourDto { Code = Colour.Grey, Name = nameof(Colour.Grey) }, + new ColourDto { Code = Colour.Purple, Name = nameof(Colour.Purple) }, + new ColourDto { Code = Colour.Blue, Name = nameof(Colour.Blue) }, + new ColourDto { Code = Colour.Teal, Name = nameof(Colour.Teal) }, + new ColourDto { Code = Colour.Green, Name = nameof(Colour.Green) }, + new ColourDto { Code = Colour.Orange, Name = nameof(Colour.Orange) }, + new ColourDto { Code = Colour.Red, Name = nameof(Colour.Red) }, + ], + + Lists = await _context.TodoLists + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(t => t.Title) + .ToListAsync(cancellationToken) + }; + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs new file mode 100644 index 0000000..5ba3504 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs @@ -0,0 +1,27 @@ +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; + +public class TodoItemDto +{ + public int Id { get; init; } + + public int ListId { get; init; } + + public string? Title { get; init; } + + public bool Done { get; init; } + + public int Priority { get; init; } + + public string? Note { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap().ForMember(d => d.Priority, + opt => opt.MapFrom(s => (int)s.Priority)); + } + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs new file mode 100644 index 0000000..5c7ba7b --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs @@ -0,0 +1,27 @@ +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; + +public class TodoListDto +{ + public TodoListDto() + { + Items = []; + } + + public int Id { get; init; } + + public string? Title { get; init; } + + public string? Colour { get; init; } + + public IReadOnlyCollection Items { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + } + } +} diff --git a/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs new file mode 100644 index 0000000..a228123 --- /dev/null +++ b/experiments/before-clean-template/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs @@ -0,0 +1,19 @@ +using LayerCake.CleanTemplate.Application.Common.Models; + +namespace LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; + +public class TodosVm +{ + public IReadOnlyCollection PriorityLevels { get; init; } = []; + + public IReadOnlyCollection Colours { get; init; } = []; + + public IReadOnlyCollection Lists { get; init; } = []; +} + +public class ColourDto +{ + public string Code { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; +} diff --git a/experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs b/experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs new file mode 100644 index 0000000..c424229 --- /dev/null +++ b/experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs @@ -0,0 +1,25 @@ +namespace LayerCake.CleanTemplate.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +public record GetWeatherForecastsQuery : IRequest>; + +public class GetWeatherForecastsQueryHandler : IRequestHandler> +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task> Handle(GetWeatherForecastsQuery request, CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + var rng = new Random(); + + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateTime.Now.AddDays(index), + TemperatureC = rng.Next(-20, 55), + Summary = Summaries[rng.Next(Summaries.Length)] + }); + } +} diff --git a/experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs b/experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs new file mode 100644 index 0000000..f7d4623 --- /dev/null +++ b/experiments/before-clean-template/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace LayerCake.CleanTemplate.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +public class WeatherForecast +{ + public DateTime Date { get; init; } + + public int TemperatureC { get; init; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string Summary { get; init; } = string.Empty; +} diff --git a/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs b/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs new file mode 100644 index 0000000..3a480a9 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs @@ -0,0 +1,12 @@ +namespace LayerCake.CleanTemplate.Domain.Common; + +public abstract class BaseAuditableEntity : BaseEntity +{ + public DateTimeOffset Created { get; set; } + + public string? CreatedBy { get; set; } + + public DateTimeOffset LastModified { get; set; } + + public string? LastModifiedBy { get; set; } +} diff --git a/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs b/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..24a9d1d --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace LayerCake.CleanTemplate.Domain.Common; + +public abstract class BaseEntity +{ + // This can easily be modified to be BaseEntity and public T Id to support different key types. + // Using non-generic integer types for simplicity + public int Id { get; set; } + + private readonly List _domainEvents = new(); + + [NotMapped] + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(BaseEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + public void RemoveDomainEvent(BaseEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + } + + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} diff --git a/experiments/before-clean-template/src/Domain/Common/BaseEvent.cs b/experiments/before-clean-template/src/Domain/Common/BaseEvent.cs new file mode 100644 index 0000000..fb16ae8 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Common/BaseEvent.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace LayerCake.CleanTemplate.Domain.Common; + +public abstract class BaseEvent : INotification +{ +} diff --git a/experiments/before-clean-template/src/Domain/Common/ValueObject.cs b/experiments/before-clean-template/src/Domain/Common/ValueObject.cs new file mode 100644 index 0000000..917be3e --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Common/ValueObject.cs @@ -0,0 +1,55 @@ +namespace LayerCake.CleanTemplate.Domain.Common; + +// Learn more: https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/implement-value-objects +public abstract class ValueObject +{ + protected static bool EqualOperator(ValueObject left, ValueObject right) + { + if (left is null ^ right is null) + { + return false; + } + + return left?.Equals(right!) != false; + } + + protected static bool NotEqualOperator(ValueObject left, ValueObject right) + { + return !(EqualOperator(left, right)); + } + + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj == null || obj.GetType() != GetType()) + { + return false; + } + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + var hash = new HashCode(); + + foreach (var component in GetEqualityComponents()) + { + hash.Add(component); + } + + return hash.ToHashCode(); + } + + public static bool operator ==(ValueObject left, ValueObject right) + { + return EqualOperator(left, right); + } + + public static bool operator !=(ValueObject left, ValueObject right) + { + return NotEqualOperator(left, right); + } +} diff --git a/experiments/before-clean-template/src/Domain/Constants/Roles.cs b/experiments/before-clean-template/src/Domain/Constants/Roles.cs new file mode 100644 index 0000000..6a0b1e9 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Constants/Roles.cs @@ -0,0 +1,6 @@ +namespace LayerCake.CleanTemplate.Domain.Constants; + +public abstract class Roles +{ + public const string Administrator = nameof(Administrator); +} \ No newline at end of file diff --git a/experiments/before-clean-template/src/Domain/Domain.csproj b/experiments/before-clean-template/src/Domain/Domain.csproj new file mode 100644 index 0000000..1013f18 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Domain.csproj @@ -0,0 +1,12 @@ + + + + LayerCake.CleanTemplate.Domain + LayerCake.CleanTemplate.Domain + + + + + + + diff --git a/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs b/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs new file mode 100644 index 0000000..4102682 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs @@ -0,0 +1,29 @@ +namespace LayerCake.CleanTemplate.Domain.Entities; + +public class TodoItem : BaseAuditableEntity +{ + public int ListId { get; set; } + + public string? Title { get; set; } + + public string? Note { get; set; } + + public PriorityLevel Priority { get; set; } + + private bool _done; + public bool Done + { + get => _done; + set + { + if (value && !_done) + { + AddDomainEvent(new TodoItemCompletedEvent(this)); + } + + _done = value; + } + } + + public TodoList List { get; set; } = null!; +} diff --git a/experiments/before-clean-template/src/Domain/Entities/TodoList.cs b/experiments/before-clean-template/src/Domain/Entities/TodoList.cs new file mode 100644 index 0000000..27e3191 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Entities/TodoList.cs @@ -0,0 +1,10 @@ +namespace LayerCake.CleanTemplate.Domain.Entities; + +public class TodoList : BaseAuditableEntity +{ + public string? Title { get; set; } + + public Colour Colour { get; set; } = Colour.Grey; + + public IList Items { get; private set; } = new List(); +} diff --git a/experiments/before-clean-template/src/Domain/Enums/PriorityLevel.cs b/experiments/before-clean-template/src/Domain/Enums/PriorityLevel.cs new file mode 100644 index 0000000..7fea4f3 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Enums/PriorityLevel.cs @@ -0,0 +1,9 @@ +namespace LayerCake.CleanTemplate.Domain.Enums; + +public enum PriorityLevel +{ + None = 0, + Low = 1, + Medium = 2, + High = 3 +} diff --git a/experiments/before-clean-template/src/Domain/Events/TodoItemCompletedEvent.cs b/experiments/before-clean-template/src/Domain/Events/TodoItemCompletedEvent.cs new file mode 100644 index 0000000..253ab2d --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Events/TodoItemCompletedEvent.cs @@ -0,0 +1,11 @@ +namespace LayerCake.CleanTemplate.Domain.Events; + +public class TodoItemCompletedEvent : BaseEvent +{ + public TodoItemCompletedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/experiments/before-clean-template/src/Domain/Exceptions/UnsupportedColourException.cs b/experiments/before-clean-template/src/Domain/Exceptions/UnsupportedColourException.cs new file mode 100644 index 0000000..27c846e --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Exceptions/UnsupportedColourException.cs @@ -0,0 +1,9 @@ +namespace LayerCake.CleanTemplate.Domain.Exceptions; + +public class UnsupportedColourException : Exception +{ + public UnsupportedColourException(string code) + : base($"Colour \"{code}\" is unsupported.") + { + } +} diff --git a/experiments/before-clean-template/src/Domain/GlobalUsings.cs b/experiments/before-clean-template/src/Domain/GlobalUsings.cs new file mode 100644 index 0000000..a95eaf5 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using LayerCake.CleanTemplate.Domain.Common; +global using LayerCake.CleanTemplate.Domain.Entities; +global using LayerCake.CleanTemplate.Domain.Enums; +global using LayerCake.CleanTemplate.Domain.Events; +global using LayerCake.CleanTemplate.Domain.Exceptions; +global using LayerCake.CleanTemplate.Domain.ValueObjects; \ No newline at end of file diff --git a/experiments/before-clean-template/src/Domain/ValueObjects/Colour.cs b/experiments/before-clean-template/src/Domain/ValueObjects/Colour.cs new file mode 100644 index 0000000..73bbeae --- /dev/null +++ b/experiments/before-clean-template/src/Domain/ValueObjects/Colour.cs @@ -0,0 +1,66 @@ +namespace LayerCake.CleanTemplate.Domain.ValueObjects; + +public class Colour(string code) : ValueObject +{ + public static Colour From(string code) + { + var colour = new Colour(code); + + if (!SupportedColours.Contains(colour)) + { + throw new UnsupportedColourException(code); + } + + return colour; + } + + public static Colour Red => new("#E05C4D"); + + public static Colour Orange => new("#D98B2B"); + + public static Colour Green => new("#4CAF50"); + + public static Colour Teal => new("#26A69A"); + + public static Colour Blue => new("#5C6BC0"); + + public static Colour Purple => new("#AB47BC"); + + public static Colour Grey => new("#78909C"); + + public string Code { get; private set; } = string.IsNullOrWhiteSpace(code)?"#000000":code; + + public static implicit operator string(Colour colour) + { + return colour.ToString(); + } + + public static explicit operator Colour(string code) + { + return From(code); + } + + public override string ToString() + { + return Code; + } + + public static IEnumerable SupportedColours + { + get + { + yield return Red; + yield return Orange; + yield return Green; + yield return Teal; + yield return Blue; + yield return Purple; + yield return Grey; + } + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Code; + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..fd82bee --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace LayerCake.CleanTemplate.Infrastructure.Data; + +public class ApplicationDbContext : IdentityDbContext, IApplicationDbContext +{ + public ApplicationDbContext(DbContextOptions options) : base(options) { } + + public DbSet TodoLists => Set(); + + public DbSet TodoItems => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs new file mode 100644 index 0000000..385fe83 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs @@ -0,0 +1,110 @@ +using LayerCake.CleanTemplate.Domain.Constants; +using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Domain.ValueObjects; +using LayerCake.CleanTemplate.Infrastructure.Identity; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LayerCake.CleanTemplate.Infrastructure.Data; + +public static class InitialiserExtensions +{ + public static async Task InitialiseDatabaseAsync(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + + var initialiser = scope.ServiceProvider.GetRequiredService(); + + await initialiser.InitialiseAsync(); + await initialiser.SeedAsync(); + } +} + +public class ApplicationDbContextInitialiser +{ + private readonly ILogger _logger; + private readonly ApplicationDbContext _context; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + + public ApplicationDbContextInitialiser(ILogger logger, ApplicationDbContext context, UserManager userManager, RoleManager roleManager) + { + _logger = logger; + _context = context; + _userManager = userManager; + _roleManager = roleManager; + } + + public async Task InitialiseAsync() + { + try + { + // See https://jasontaylor.dev/ef-core-database-initialisation-strategies + await _context.Database.EnsureDeletedAsync(); + await _context.Database.EnsureCreatedAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while initialising the database."); + throw; + } + } + + public async Task SeedAsync() + { + try + { + await TrySeedAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while seeding the database."); + throw; + } + } + + public async Task TrySeedAsync() + { + // Default roles + var administratorRole = new IdentityRole(Roles.Administrator); + + if (_roleManager.Roles.All(r => r.Name != administratorRole.Name)) + { + await _roleManager.CreateAsync(administratorRole); + } + + // Default users + var administrator = new ApplicationUser { UserName = "administrator@localhost", Email = "administrator@localhost" }; + + if (_userManager.Users.All(u => u.UserName != administrator.UserName)) + { + await _userManager.CreateAsync(administrator, "Administrator1!"); + if (!string.IsNullOrWhiteSpace(administratorRole.Name)) + { + await _userManager.AddToRolesAsync(administrator, new [] { administratorRole.Name }); + } + } + + // Default data + // Seed, if necessary + if (!_context.TodoLists.Any()) + { + _context.TodoLists.Add(new TodoList + { + Title = "Tasks", + Colour = Colour.Green, + Items = + { + new TodoItem { Title = "Make a todo list 📃" }, + new TodoItem { Title = "Check off the first item ✅" }, + new TodoItem { Title = "Realise you've already done two things on the list! 🤯"}, + new TodoItem { Title = "Reward yourself with a nice, long nap 🏆" }, + } + }); + + await _context.SaveChangesAsync(); + } + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs b/experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs new file mode 100644 index 0000000..0d59c08 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs @@ -0,0 +1,15 @@ +using LayerCake.CleanTemplate.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LayerCake.CleanTemplate.Infrastructure.Data.Configurations; + +public class TodoItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs b/experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs new file mode 100644 index 0000000..2b0f260 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs @@ -0,0 +1,18 @@ +using LayerCake.CleanTemplate.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LayerCake.CleanTemplate.Infrastructure.Data.Configurations; + +public class TodoListConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + + builder + .OwnsOne(b => b.Colour); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/experiments/before-clean-template/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..263eb93 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,64 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace LayerCake.CleanTemplate.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + private readonly IUser _user; + private readonly TimeProvider _dateTime; + + public AuditableEntityInterceptor( + IUser user, + TimeProvider dateTime) + { + _user = user; + _dateTime = dateTime; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public void UpdateEntities(DbContext? context) + { + if (context == null) return; + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities()) + { + var utcNow = _dateTime.GetUtcNow(); + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = _user.Id; + entry.Entity.Created = utcNow; + } + entry.Entity.LastModifiedBy = _user.Id; + entry.Entity.LastModified = utcNow; + } + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) => + entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); +} diff --git a/experiments/before-clean-template/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/experiments/before-clean-template/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..a8f6a81 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,50 @@ +using LayerCake.CleanTemplate.Domain.Common; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace LayerCake.CleanTemplate.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor : SaveChangesInterceptor +{ + private readonly IMediator _mediator; + + public DispatchDomainEventsInterceptor(IMediator mediator) + { + _mediator = mediator; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + + return base.SavingChanges(eventData, result); + + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var entities = context.ChangeTracker + .Entries() + .Where(e => e.Entity.DomainEvents.Any()) + .Select(e => e.Entity); + + var domainEvents = entities + .SelectMany(e => e.DomainEvents) + .ToList(); + + entities.ToList().ForEach(e => e.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await _mediator.Publish(domainEvent); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/DependencyInjection.cs b/experiments/before-clean-template/src/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..06c4099 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/DependencyInjection.cs @@ -0,0 +1,50 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Infrastructure.Data; +using LayerCake.CleanTemplate.Infrastructure.Data.Interceptors; +using LayerCake.CleanTemplate.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static void AddInfrastructureServices(this IHostApplicationBuilder builder) + { + var connectionString = builder.Configuration.GetConnectionString(Services.Database); + Guard.Against.Null(connectionString, message: $"Connection string '{Services.Database}' not found."); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddDbContext((sp, options) => + { + options.AddInterceptors(sp.GetServices()); + options.UseNpgsql(connectionString); + options.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); + }); + + builder.EnrichNpgsqlDbContext(); + + builder.Services.AddScoped(provider => provider.GetRequiredService()); + + builder.Services.AddScoped(); + + builder.Services.AddAuthentication() + .AddBearerToken(IdentityConstants.BearerScheme); + + builder.Services.AddAuthorizationBuilder(); + + builder.Services + .AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores() + .AddApiEndpoints(); + + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddTransient(); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/GlobalUsings.cs b/experiments/before-clean-template/src/Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..e929a72 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Ardalis.GuardClauses; +global using LayerCake.CleanTemplate.Shared; \ No newline at end of file diff --git a/experiments/before-clean-template/src/Infrastructure/Identity/ApplicationUser.cs b/experiments/before-clean-template/src/Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..ee884d6 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace LayerCake.CleanTemplate.Infrastructure.Identity; + +public class ApplicationUser : IdentityUser +{ +} diff --git a/experiments/before-clean-template/src/Infrastructure/Identity/IdentityResultExtensions.cs b/experiments/before-clean-template/src/Infrastructure/Identity/IdentityResultExtensions.cs new file mode 100644 index 0000000..1b0312a --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Identity/IdentityResultExtensions.cs @@ -0,0 +1,14 @@ +using LayerCake.CleanTemplate.Application.Common.Models; +using Microsoft.AspNetCore.Identity; + +namespace LayerCake.CleanTemplate.Infrastructure.Identity; + +public static class IdentityResultExtensions +{ + public static Result ToApplicationResult(this IdentityResult result) + { + return result.Succeeded + ? Result.Success() + : Result.Failure(result.Errors.Select(e => e.Description)); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Identity/IdentityService.cs b/experiments/before-clean-template/src/Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..6c7a831 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,81 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Application.Common.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace LayerCake.CleanTemplate.Infrastructure.Identity; + +public class IdentityService : IIdentityService +{ + private readonly UserManager _userManager; + private readonly IUserClaimsPrincipalFactory _userClaimsPrincipalFactory; + private readonly IAuthorizationService _authorizationService; + + public IdentityService( + UserManager userManager, + IUserClaimsPrincipalFactory userClaimsPrincipalFactory, + IAuthorizationService authorizationService) + { + _userManager = userManager; + _userClaimsPrincipalFactory = userClaimsPrincipalFactory; + _authorizationService = authorizationService; + } + + public async Task GetUserNameAsync(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + + return user?.UserName; + } + + public async Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password) + { + var user = new ApplicationUser + { + UserName = userName, + Email = userName, + }; + + var result = await _userManager.CreateAsync(user, password); + + return (result.ToApplicationResult(), user.Id); + } + + public async Task IsInRoleAsync(string userId, string role) + { + var user = await _userManager.FindByIdAsync(userId); + + return user != null && await _userManager.IsInRoleAsync(user, role); + } + + public async Task AuthorizeAsync(string userId, string policyName) + { + var user = await _userManager.FindByIdAsync(userId); + + if (user == null) + { + return false; + } + + var principal = await _userClaimsPrincipalFactory.CreateAsync(user); + + var result = await _authorizationService.AuthorizeAsync(principal, policyName); + + return result.Succeeded; + } + + public async Task DeleteUserAsync(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + + return user != null ? await DeleteUserAsync(user) : Result.Success(); + } + + public async Task DeleteUserAsync(ApplicationUser user) + { + var result = await _userManager.DeleteAsync(user); + + return result.ToApplicationResult(); + } +} diff --git a/experiments/before-clean-template/src/Infrastructure/Infrastructure.csproj b/experiments/before-clean-template/src/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..9e5fbef --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + LayerCake.CleanTemplate.Infrastructure + LayerCake.CleanTemplate.Infrastructure + + + + + + + + + + + + + + + + diff --git a/experiments/before-clean-template/src/ServiceDefaults/Extensions.cs b/experiments/before-clean-template/src/ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..13151bf --- /dev/null +++ b/experiments/before-clean-template/src/ServiceDefaults/Extensions.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +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; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // 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(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/experiments/before-clean-template/src/ServiceDefaults/ServiceDefaults.csproj b/experiments/before-clean-template/src/ServiceDefaults/ServiceDefaults.csproj new file mode 100644 index 0000000..b84adde --- /dev/null +++ b/experiments/before-clean-template/src/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + true + LayerCake.CleanTemplate.ServiceDefaults + LayerCake.CleanTemplate.ServiceDefaults + + + + + + + + + + + + + + + diff --git a/experiments/before-clean-template/src/Shared/Services.cs b/experiments/before-clean-template/src/Shared/Services.cs new file mode 100644 index 0000000..ab92218 --- /dev/null +++ b/experiments/before-clean-template/src/Shared/Services.cs @@ -0,0 +1,28 @@ +namespace LayerCake.CleanTemplate.Shared; + +public static class Services +{ + /// + /// The name of the Web Frontend service. + /// This service is responsible for hosting the frontend application. + /// + public const string WebFrontend = "webfrontend"; + + /// + /// The name of the Web API service. + /// This service is responsible for hosting the Web API application. + /// + public const string WebApi = "webapi"; + + /// + /// The name of the Database Server service. + /// This service is responsible for hosting the database server (e.g., PostgreSQL, SQL Server, or SQLite). + /// + public const string DatabaseServer = "dbserver"; + + /// + /// The name of the Database. + /// This is the name of the database that will be created and used by the application. + /// + public const string Database = "LayerCake.CleanTemplateDb"; +} diff --git a/experiments/before-clean-template/src/Shared/Shared.csproj b/experiments/before-clean-template/src/Shared/Shared.csproj new file mode 100644 index 0000000..b938cd7 --- /dev/null +++ b/experiments/before-clean-template/src/Shared/Shared.csproj @@ -0,0 +1,14 @@ + + + + LayerCake.CleanTemplate.Shared + LayerCake.CleanTemplate.Shared + + + + net10.0 + enable + enable + + + diff --git a/experiments/before-clean-template/src/Web/DependencyInjection.cs b/experiments/before-clean-template/src/Web/DependencyInjection.cs new file mode 100644 index 0000000..edad30e --- /dev/null +++ b/experiments/before-clean-template/src/Web/DependencyInjection.cs @@ -0,0 +1,47 @@ +using Azure.Identity; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Infrastructure.Data; +using LayerCake.CleanTemplate.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static void AddWebServices(this IHostApplicationBuilder builder) + { + builder.Services.AddDatabaseDeveloperPageExceptionFilter(); + + builder.Services.AddScoped(); + + builder.Services.AddHttpContextAccessor(); + + builder.Services.AddExceptionHandler(); + + // Customise default API behaviour + builder.Services.Configure(options => + options.SuppressModelStateInvalidFilter = true); + + builder.Services.AddEndpointsApiExplorer(); + + builder.Services.AddOpenApi(options => + { + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddDocumentTransformer(); + }); + + builder.Services.AddCors(); + } + + public static void AddKeyVaultIfConfigured(this IHostApplicationBuilder builder) + { + var keyVaultUri = builder.Configuration["AZURE_KEY_VAULT_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(keyVaultUri)) + { + builder.Configuration.AddAzureKeyVault( + new Uri(keyVaultUri), + new DefaultAzureCredential()); + } + } +} diff --git a/experiments/before-clean-template/src/Web/Endpoints/TodoItems.cs b/experiments/before-clean-template/src/Web/Endpoints/TodoItems.cs new file mode 100644 index 0000000..33093d4 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Endpoints/TodoItems.cs @@ -0,0 +1,61 @@ +using LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.DeleteTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItemDetail; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace LayerCake.CleanTemplate.Web.Endpoints; + +public class TodoItems : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapPost(CreateTodoItem); + groupBuilder.MapPut(UpdateTodoItem, "{id}"); + groupBuilder.MapPatch(UpdateTodoItemDetail, "UpdateDetail/{id}"); + groupBuilder.MapDelete(DeleteTodoItem, "{id}"); + } + + [EndpointSummary("Create a new Todo Item")] + [EndpointDescription("Creates a new todo item using the provided details and returns the ID of the created item.")] + public static async Task> CreateTodoItem(ISender sender, CreateTodoItemCommand command) + { + var id = await sender.Send(command); + + return TypedResults.Created($"/{nameof(TodoItems)}/{id}", id); + } + + [EndpointSummary("Update a Todo Item")] + [EndpointDescription("Updates the specified todo item. The ID in the URL must match the ID in the payload.")] + public static async Task> UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command) + { + if (id != command.Id) + return TypedResults.BadRequest(); + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + [EndpointSummary("Update Todo Item Details")] + [EndpointDescription("Updates the detail fields of a specific todo item. The ID in the URL must match the ID in the payload.")] + public static async Task> UpdateTodoItemDetail(ISender sender, int id, UpdateTodoItemDetailCommand command) + { + if (id != command.Id) return TypedResults.BadRequest(); + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + [EndpointSummary("Delete a Todo Item")] + [EndpointDescription("Deletes the todo item with the specified ID.")] + public static async Task DeleteTodoItem(ISender sender, int id) + { + await sender.Send(new DeleteTodoItemCommand(id)); + + return TypedResults.NoContent(); + } +} diff --git a/experiments/before-clean-template/src/Web/Endpoints/TodoLists.cs b/experiments/before-clean-template/src/Web/Endpoints/TodoLists.cs new file mode 100644 index 0000000..b3d381b --- /dev/null +++ b/experiments/before-clean-template/src/Web/Endpoints/TodoLists.cs @@ -0,0 +1,58 @@ +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.DeleteTodoList; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.UpdateTodoList; +using LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace LayerCake.CleanTemplate.Web.Endpoints; + +public class TodoLists : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetTodoLists); + groupBuilder.MapPost(CreateTodoList); + groupBuilder.MapPut(UpdateTodoList, "{id}"); + groupBuilder.MapDelete(DeleteTodoList, "{id}"); + } + + [EndpointSummary("Get all Todo Lists")] + [EndpointDescription("Retrieves all todo lists along with their items.")] + public static async Task> GetTodoLists(ISender sender) + { + var vm = await sender.Send(new GetTodosQuery()); + + return TypedResults.Ok(vm); + } + + [EndpointSummary("Create a new Todo List")] + [EndpointDescription("Creates a new todo list using the provided details and returns the ID of the created list.")] + public static async Task> CreateTodoList(ISender sender, CreateTodoListCommand command) + { + var id = await sender.Send(command); + + return TypedResults.Created($"/{nameof(TodoLists)}/{id}", id); + } + + [EndpointSummary("Update a Todo List")] + [EndpointDescription("Updates the specified todo list. The ID in the URL must match the ID in the payload.")] + public static async Task> UpdateTodoList(ISender sender, int id, UpdateTodoListCommand command) + { + if (id != command.Id) return TypedResults.BadRequest(); + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + [EndpointSummary("Delete a Todo List")] + [EndpointDescription("Deletes the todo list with the specified ID.")] + public static async Task DeleteTodoList(ISender sender, int id) + { + await sender.Send(new DeleteTodoListCommand(id)); + + return TypedResults.NoContent(); + } +} diff --git a/experiments/before-clean-template/src/Web/Endpoints/Users.cs b/experiments/before-clean-template/src/Web/Endpoints/Users.cs new file mode 100644 index 0000000..04ef93a --- /dev/null +++ b/experiments/before-clean-template/src/Web/Endpoints/Users.cs @@ -0,0 +1,29 @@ +using LayerCake.CleanTemplate.Infrastructure.Identity; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace LayerCake.CleanTemplate.Web.Endpoints; + +public class Users : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapIdentityApi(); + + groupBuilder.MapPost(Logout, "logout").RequireAuthorization(); + } + + [EndpointSummary("Log out")] + [EndpointDescription("Logs out the current user by clearing the authentication cookie.")] + public static async Task> Logout(SignInManager signInManager, [FromBody] object empty) + { + if (empty != null) + { + await signInManager.SignOutAsync(); + return TypedResults.Ok(); + } + + return TypedResults.Unauthorized(); + } +} diff --git a/experiments/before-clean-template/src/Web/Endpoints/WeatherForecasts.cs b/experiments/before-clean-template/src/Web/Endpoints/WeatherForecasts.cs new file mode 100644 index 0000000..ce5b8de --- /dev/null +++ b/experiments/before-clean-template/src/Web/Endpoints/WeatherForecasts.cs @@ -0,0 +1,23 @@ +using LayerCake.CleanTemplate.Application.WeatherForecasts.Queries.GetWeatherForecasts; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace LayerCake.CleanTemplate.Web.Endpoints; + +public class WeatherForecasts : IEndpointGroup +{ + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetWeatherForecasts); + } + + [EndpointSummary("Get Weather Forecasts")] + [EndpointDescription("Retrieves a list of weather forecasts for the next few days.")] + public static async Task>> GetWeatherForecasts(ISender sender) + { + var forecasts = await sender.Send(new GetWeatherForecastsQuery()); + + return TypedResults.Ok(forecasts); + } +} diff --git a/experiments/before-clean-template/src/Web/GlobalUsings.cs b/experiments/before-clean-template/src/Web/GlobalUsings.cs new file mode 100644 index 0000000..6c62a4d --- /dev/null +++ b/experiments/before-clean-template/src/Web/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Ardalis.GuardClauses; +global using LayerCake.CleanTemplate.Web.Infrastructure; +global using MediatR; diff --git a/experiments/before-clean-template/src/Web/Infrastructure/ApiExceptionOperationTransformer.cs b/experiments/before-clean-template/src/Web/Infrastructure/ApiExceptionOperationTransformer.cs new file mode 100644 index 0000000..0185346 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/ApiExceptionOperationTransformer.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +/// +/// Adds standard error responses to every OpenAPI operation. A 400 Bad Request is added to all +/// operations because every request passes through ValidationBehaviour in the MediatR +/// pipeline. 401 Unauthorized and 403 Forbidden are added only to operations that carry +/// metadata. +/// +internal sealed class ApiExceptionOperationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + operation.Responses ??= []; + operation.Responses.TryAdd("400", new OpenApiResponse { Description = "Bad Request" }); + + var requiresAuth = context.Description.ActionDescriptor.EndpointMetadata + .Any(m => m is IAuthorizeData); + + if (requiresAuth) + { + operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); + } + + return Task.CompletedTask; + } +} diff --git a/experiments/before-clean-template/src/Web/Infrastructure/BearerSecuritySchemeTransformer.cs b/experiments/before-clean-template/src/Web/Infrastructure/BearerSecuritySchemeTransformer.cs new file mode 100644 index 0000000..0d05c55 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/BearerSecuritySchemeTransformer.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +/// +/// Adds the Bearer JWT security scheme to the OpenAPI document when Bearer authentication +/// is configured, enabling the Scalar UI to send Authorization: Bearer <token> +/// headers from the interactive documentation. +/// +internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +{ + public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + var authenticationSchemes = await authenticationSchemeProvider.GetAllSchemesAsync(); + if (authenticationSchemes.Any(authScheme => authScheme.Name == "Bearer")) + { + var requirements = new Dictionary + { + ["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + In = ParameterLocation.Header, + BearerFormat = "Json Web Token" + } + }; + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes = requirements; + } + } +} diff --git a/experiments/before-clean-template/src/Web/Infrastructure/EndpointRouteBuilderExtensions.cs b/experiments/before-clean-template/src/Web/Infrastructure/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..5772c4f --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +/// +/// Extends with convenience overloads used inside +/// . Each method wraps the standard ASP.NET Core +/// Map{Verb} call and automatically derives the endpoint name from the handler's +/// method name, which becomes the OpenAPI operationId and is used for typed +/// client generation (e.g. nswag). +/// +/// pattern is optional for GET and POST (collection-level operations that typically +/// have no route parameter) but required for PUT, PATCH, and DELETE (resource-level +/// operations that almost always target a specific item by ID, e.g. "{id}"). +/// +/// +public static class EndpointRouteBuilderExtensions +{ + /// + public static RouteHandlerBuilder MapGet(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "") + { + Guard.Against.AnonymousMethod(handler); + + return builder.MapGet(pattern, handler) + .WithName(handler.Method.Name); + } + + /// + public static RouteHandlerBuilder MapPost(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern = "") + { + Guard.Against.AnonymousMethod(handler); + + return builder.MapPost(pattern, handler) + .WithName(handler.Method.Name); + } + + /// + public static RouteHandlerBuilder MapPut(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) + { + Guard.Against.AnonymousMethod(handler); + + return builder.MapPut(pattern, handler) + .WithName(handler.Method.Name); + } + + /// + public static RouteHandlerBuilder MapPatch(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) + { + Guard.Against.AnonymousMethod(handler); + + return builder.MapPatch(pattern, handler) + .WithName(handler.Method.Name); + } + + /// + public static RouteHandlerBuilder MapDelete(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) + { + Guard.Against.AnonymousMethod(handler); + + return builder.MapDelete(pattern, handler) + .WithName(handler.Method.Name); + } +} diff --git a/experiments/before-clean-template/src/Web/Infrastructure/IEndpointGroup.cs b/experiments/before-clean-template/src/Web/Infrastructure/IEndpointGroup.cs new file mode 100644 index 0000000..726fef6 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/IEndpointGroup.cs @@ -0,0 +1,19 @@ +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +/// +/// Defines a group of related Minimal API endpoints. +/// Implementations are automatically discovered and registered as a route group with a matching +/// OpenAPI tag. By default the route prefix is /api/{ClassName}; override +/// to use a custom path, including nested resource paths such as +/// /api/TodoLists/{todoListId}/TodoItems. +/// +public interface IEndpointGroup +{ + /// + /// The route prefix for this endpoint group. + /// Defaults to /api/{ClassName}. Override to specify a custom or nested path. + /// + static virtual string? RoutePrefix => null; + + static abstract void Map(RouteGroupBuilder groupBuilder); +} diff --git a/experiments/before-clean-template/src/Web/Infrastructure/IdentityApiOperationTransformer.cs b/experiments/before-clean-template/src/Web/Infrastructure/IdentityApiOperationTransformer.cs new file mode 100644 index 0000000..bb1e41c --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/IdentityApiOperationTransformer.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +/// +/// Adds human-readable summaries and descriptions to the ASP.NET Core Identity endpoints +/// registered by MapIdentityApi. Those endpoints are generated by the framework and +/// cannot be annotated with or +/// directly, so metadata is applied here by +/// matching on the operation's relative path (and HTTP method for ambiguous paths). +/// +internal sealed class IdentityApiOperationTransformer : IOpenApiOperationTransformer +{ + private static readonly Dictionary _metadata = new() + { + ["api/Users/register"] = ("Register", "Creates a new user account."), + ["api/Users/login"] = ("Log in", "Authenticates a user. Use ?useCookies=true for cookie-based authentication."), + ["api/Users/refresh"] = ("Refresh token", "Returns a new access token using a valid refresh token."), + ["api/Users/confirmEmail"] = ("Confirm email", "Confirms a user's email address using the token sent by email."), + ["api/Users/resendConfirmationEmail"] = ("Resend confirmation email", "Sends a new email confirmation link to the specified address."), + ["api/Users/forgotPassword"] = ("Forgot password", "Sends a password reset link to the specified email address."), + ["api/Users/resetPassword"] = ("Reset password", "Resets a user's password using the token sent by email."), + ["api/Users/manage/2fa"] = ("Manage two-factor authentication", "Enables, disables, or retrieves two-factor authentication settings."), + ["api/Users/manage/info GET"] = ("Get account info", "Returns the current user's email and two-factor authentication status."), + ["api/Users/manage/info POST"] = ("Update account info", "Updates the current user's email or password."), + }; + + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + var path = context.Description.RelativePath ?? ""; + var key = $"{path} {context.Description.HttpMethod?.ToUpperInvariant()}"; + + if (_metadata.TryGetValue(key, out var meta) || _metadata.TryGetValue(path, out meta)) + { + operation.Summary = meta.Summary; + operation.Description = meta.Description; + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/experiments/before-clean-template/src/Web/Infrastructure/MethodInfoExtensions.cs b/experiments/before-clean-template/src/Web/Infrastructure/MethodInfoExtensions.cs new file mode 100644 index 0000000..1978f70 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/MethodInfoExtensions.cs @@ -0,0 +1,27 @@ +using System.Reflection; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +public static class MethodInfoExtensions +{ + // Compiler-generated anonymous methods (lambdas, local functions) contain '<' and '>' in their names. + private static readonly char[] AnonymousMethodChars = ['<', '>']; + + /// + /// Returns if the method was compiler-generated from an anonymous delegate or lambda + /// (i.e. it has no stable, human-readable name that can be used as an OpenAPI operationId). + /// + public static bool IsAnonymous(this MethodInfo method) => + method.Name.Any(AnonymousMethodChars.Contains); + + /// + /// Throws if is an anonymous handler. + /// Endpoint handlers must be named methods so that a meaningful operationId can be derived + /// from for use in OpenAPI and typed client generation. + /// + public static void AnonymousMethod(this IGuardClause guardClause, Delegate input) + { + if (input.Method.IsAnonymous()) + throw new ArgumentException("The endpoint name must be specified when using anonymous handlers."); + } +} \ No newline at end of file diff --git a/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs b/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs new file mode 100644 index 0000000..5d7af8e --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs @@ -0,0 +1,52 @@ +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +/// +/// Converts well-known application exceptions into RFC 9110-compliant responses, +/// mapping → 400, → 404, +/// → 401, and → 403. +/// Unrecognised exceptions are not handled and fall through to the default middleware. +/// +public class ProblemDetailsExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var (statusCode, problemDetails) = exception switch + { + ValidationException ve => (StatusCodes.Status400BadRequest, (ProblemDetails)new ValidationProblemDetails(ve.Errors) + { + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1" + }), + NotFoundException ne => (StatusCodes.Status404NotFound, new ProblemDetails + { + Status = StatusCodes.Status404NotFound, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.5", + Title = "The specified resource was not found.", + Detail = ne.Message + }), + UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2" + }), + ForbiddenAccessException => (StatusCodes.Status403Forbidden, new ProblemDetails + { + Status = StatusCodes.Status403Forbidden, + Title = "Forbidden", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.4" + }), + _ => (-1, null) + }; + + if (problemDetails is null) return false; + + httpContext.Response.StatusCode = statusCode; + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + return true; + } +} diff --git a/experiments/before-clean-template/src/Web/Infrastructure/WebApplicationExtensions.cs b/experiments/before-clean-template/src/Web/Infrastructure/WebApplicationExtensions.cs new file mode 100644 index 0000000..ca09acf --- /dev/null +++ b/experiments/before-clean-template/src/Web/Infrastructure/WebApplicationExtensions.cs @@ -0,0 +1,29 @@ +using System.Reflection; + +namespace LayerCake.CleanTemplate.Web.Infrastructure; + +public static class WebApplicationExtensions +{ + /// + /// Discovers all implementations in + /// and registers each as a route group with a matching OpenAPI tag. The route prefix defaults + /// to /api/{ClassName} but can be overridden via . + /// + public static WebApplication MapEndpoints(this WebApplication app, Assembly assembly) + { + var endpointGroupTypes = assembly.GetExportedTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false } + && t.IsAssignableTo(typeof(IEndpointGroup))); + + foreach (var type in endpointGroupTypes) + { + var groupName = type.Name; + var routePrefix = type.GetProperty(nameof(IEndpointGroup.RoutePrefix)) + ?.GetValue(null) as string ?? $"/api/{groupName}"; + var group = app.MapGroup(routePrefix).WithTags(groupName); + type.GetMethod(nameof(IEndpointGroup.Map))!.Invoke(null, [group]); + } + + return app; + } +} diff --git a/experiments/before-clean-template/src/Web/Program.cs b/experiments/before-clean-template/src/Web/Program.cs new file mode 100644 index 0000000..5402438 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Program.cs @@ -0,0 +1,46 @@ +using LayerCake.CleanTemplate.Infrastructure.Data; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.AddServiceDefaults(); + +builder.AddKeyVaultIfConfigured(); +builder.AddApplicationServices(); +builder.AddInfrastructureServices(); +builder.AddWebServices(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + await app.InitialiseDatabaseAsync(); +} +else +{ + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseCors(static builder => + builder.AllowAnyMethod() + .AllowAnyHeader() + .AllowAnyOrigin()); + +app.UseFileServer(); + +app.MapOpenApi(); +app.MapScalarApiReference(); + +app.UseExceptionHandler(options => { }); + +app.Map("/", () => Results.Redirect("/scalar")); + +app.MapDefaultEndpoints(); +app.MapEndpoints(typeof(Program).Assembly); + + +app.Run(); diff --git a/experiments/before-clean-template/src/Web/Properties/launchSettings.json b/experiments/before-clean-template/src/Web/Properties/launchSettings.json new file mode 100644 index 0000000..2588d49 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "http://localhost:5113", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7120;http://localhost:5113", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/experiments/before-clean-template/src/Web/Services/CurrentUser.cs b/experiments/before-clean-template/src/Web/Services/CurrentUser.cs new file mode 100644 index 0000000..ac72ae5 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Services/CurrentUser.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; + +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Web.Services; + +public class CurrentUser : IUser +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUser(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? Id => _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); + public List? Roles => _httpContextAccessor.HttpContext?.User?.FindAll(ClaimTypes.Role).Select(x => x.Value).ToList(); + +} diff --git a/experiments/before-clean-template/src/Web/Web.csproj b/experiments/before-clean-template/src/Web/Web.csproj new file mode 100644 index 0000000..1ae99cc --- /dev/null +++ b/experiments/before-clean-template/src/Web/Web.csproj @@ -0,0 +1,28 @@ + + + + LayerCake.CleanTemplate.Web + LayerCake.CleanTemplate.Web + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/experiments/before-clean-template/src/Web/Web.http b/experiments/before-clean-template/src/Web/Web.http new file mode 100644 index 0000000..dd7460a --- /dev/null +++ b/experiments/before-clean-template/src/Web/Web.http @@ -0,0 +1,139 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile +@Web_HostAddress = https://localhost:7120 + +@Email=administrator@localhost +@Password=Administrator1! +@BearerToken= + +# POST Users Register +POST {{Web_HostAddress}}/api/Users/Register +Content-Type: application/json + +{ + "email": "{{Email}}", + "password": "{{Password}}" +} + +### + +# POST Users Login +POST {{Web_HostAddress}}/api/Users/Login +Content-Type: application/json + +{ + "email": "{{Email}}", + "password": "{{Password}}" +} + +### + +# POST Users Refresh +POST {{Web_HostAddress}}/api/Users/Refresh +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +{ + "refreshToken": "" +} + +### + +# GET WeatherForecast +GET {{Web_HostAddress}}/api/WeatherForecasts +Authorization: Bearer {{BearerToken}} + +### + +# GET TodoLists +GET {{Web_HostAddress}}/api/TodoLists +Authorization: Bearer {{BearerToken}} + +### + +# POST TodoLists +POST {{Web_HostAddress}}/api/TodoLists +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// CreateTodoListCommand +{ + "Title": "Backlog" +} + +### + +# PUT TodoLists +PUT {{Web_HostAddress}}/api/TodoLists/1 +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// UpdateTodoListCommand +{ + "Id": 1, + "Title": "Product Backlog" +} + +### + +# DELETE TodoLists +DELETE {{Web_HostAddress}}/api/TodoLists/1 +Authorization: Bearer {{BearerToken}} + +### + +# GET TodoItems +@PageNumber = 1 +@PageSize = 10 +GET {{Web_HostAddress}}/api/TodoItems?ListId=1&PageNumber={{PageNumber}}&PageSize={{PageSize}} + +Authorization: Bearer {{BearerToken}} + +### + +# POST TodoItems +POST {{Web_HostAddress}}/api/TodoItems +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// CreateTodoItemCommand +{ + "ListId": 1, + "Title": "Eat a burrito 🌯" +} + +### + +#PUT TodoItems UpdateItemDetails +PUT {{Web_HostAddress}}/api/TodoItems/UpdateItemDetails?Id=1 +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// UpdateTodoItemDetailCommand +{ + "Id": 1, + "ListId": 1, + "Priority": 3, + "Note": "This is a good idea!" +} + +### + +# PUT TodoItems +PUT {{Web_HostAddress}}/api/TodoItems/1 +Authorization: Bearer {{BearerToken}} +Content-Type: application/json + +// UpdateTodoItemCommand +{ + "Id": 1, + "Title": "Eat a yummy burrito 🌯", + "Done": true +} + +### + +# DELETE TodoItem +DELETE {{Web_HostAddress}}/api/TodoItems/1 +Authorization: Bearer {{BearerToken}} + +### \ No newline at end of file diff --git a/experiments/before-clean-template/src/Web/appsettings.json b/experiments/before-clean-template/src/Web/appsettings.json new file mode 100644 index 0000000..608c008 --- /dev/null +++ b/experiments/before-clean-template/src/Web/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "LayerCake.CleanTemplateDb": "Server=127.0.0.1;Port=5432;Database=LayerCake.CleanTemplateDb;Username=admin;Password=password;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/experiments/before-clean-template/src/Web/wwwroot/favicon.ico b/experiments/before-clean-template/src/Web/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..63e859b476eff5055e0e557aaa151ca8223fbeef GIT binary patch literal 5430 zcmc&&Yj2xp8Fqnv;>&(QB_ve7>^E#o2mu=cO~A%R>DU-_hfbSRv1t;m7zJ_AMrntN zy0+^f&8be>q&YYzH%(88lQ?#KwiCzaCO*ZEo%j&v;<}&Lj_stKTKK>#U3nin@AF>w zb3ONSAFR{u(S1d?cdw53y}Gt1b-Hirbh;;bm(Rcbnoc*%@jiaXM|4jU^1WO~`TYZ~ zC-~jh9~b-f?fX`DmwvcguQzn*uV}c^Vd&~?H|RUs4Epv~gTAfR(B0lT&?RWQOtduM z^1vUD9{HQsW!{a9|0crA34m7Z6lpG^}f6f?={zD+ zXAzk^i^aKN_}s2$eX81wjSMONE#WVdzf|MT)Ap*}Vsn!XbvsI#6o&ij{87^d%$|A{ z=F{KB%)g%@z76yBzbb7seW**Ju8r4e*Z3PWNX3_tTDgzZatz7)Q6ytwB%@&@A|XT; zecM`Snxx5po$C)%yCP!KEtos~eOS)@2=kX-RIm)4glMCoagTEFxrBeSX%Euz734Fk z%7)x(k~T!@Hbg_37NSQL!vlTBXoURSzt~I**Zw`&F24fH*&kx=%nvZv|49SC*daD( zIw<~%#=lk8{2-l(BcIjy^Q$Q&m#KlWL9?UG{b8@qhlD z;umc+6p%|NsAT~0@DgV4-NKgQuWPWrmPIK&&XhV&n%`{l zOl^bbWYjQNuVXTXESO)@|iUKVmErPUDfz2Wh`4dF@OFiaCW|d`3paV^@|r^8T_ZxM)Z+$p5qx# z#K=z@%;aBPO=C4JNNGqVv6@UGolIz;KZsAro``Rz8X%vq_gpi^qEV&evgHb_=Y9-l z`)imdx0UC>GWZYj)3+3aKh?zVb}=@%oNzg7a8%kfVl)SV-Amp1Okw&+hEZ3|v(k8vRjXW9?ih`&FFM zV$~{j3IzhtcXk?Mu_!12;=+I7XK-IR2>Yd%VB^?oI9c^E&Chb&&je$NV0P-R;ujkP z;cbLCCPEF6|22NDj=S`F^2e~XwT1ZnRX8ra0#DaFa9-X|8(xNW_+JhD75WnSd7cxo z2>I_J5{c|WPfrgl7E2R)^c}F7ry()Z>$Jhk9CzZxiPKL#_0%`&{MX>P_%b~Dx0D^S z7xP1(DQ!d_Icpk!RN3I1w@~|O1ru#CO==h#9M~S4Chx*@?=EKUPGBv$tmU+7Zs_al z`!jR?6T&Z7(%uVq>#yLu`abWk!FBlnY{RFNHlj~6zh*;@u}+}viRKsD`IIxN#R-X3 z@vxu#EA_m}I503U(8Qmx^}u;)KfGP`O9E1H1Q|xeeksX8jC%@!{YT1)!lWgO=+Y3*jr=iSxvOW1}^HSy=y){tOMQJ@an>sOl4FYniE z;GOxd7AqxZNbYFNqobpv&HVO$c-w!Y*6r;$2oJ~h(a#(Bp<-)dg*mNigX~9rPqcHv z^;c*|Md?tD)$y?6FO$DWl$jUGV`F1G_^E&E>sY*YnA~ruv3=z9F8&&~Xpm<<75?N3 z>x~`I&M9q)O1=zWZHN9hZWx>RQ}zLP+iL57Q)%&_^$Sme^^G7;e-P~CR?kqU#Io#( z(nH1Wn*Ig)|M>WLGrxoU?FZrS`4GO&w;+39A3f8w{{Q7eg|$+dIlNFPAe+tN=FOYU z{A&Fg|H73+w1IK(W=j*L>JQgz$g0 z7JpKXLHIh}#$wm|N`s}o-@|L_`>*(gTQ~)wr3Eap7g%PVNisKw82im;Gdv#85x#s+ zoqqtnwu4ycd>cOQgRh-=aEJbnvVK`}ja%+FZx}&ehtX)n(9nVfe4{mn0bgijUbNr7Tf5X^$*{qh2%`?--%+sbSrjE^;1e3>% zqa%jdY16{Y)a1hSy*mr0JGU05Z%=qlx5vGvTjSpTt6k%nR06q}1DU`SQh_ZAeJ}A@`hL~xvv05U?0%=spP`R>dk?cOWM9^KNb7B?xjex>OZo%JMQQ1Q zB|q@}8RiP@DWn-(fB;phPaIOP2Yp)XN3-Fsn)S3w($4&+p8f5W_f%gac}QvmkHfCj$2=!t`boCvQ zCW;&Dto=f8v##}dy^wg3VNaBy&kCe3N;1|@n@pUaMPT?(aJ9b*(gJ28$}(2qFt$H~u5z94xcIQkcOI++)*exzbrk?WOOOf*|%k5#KV zL=&ky3)Eirv$wbRJ2F2s_ILQY--D~~7>^f}W|Aw^e7inXr#WLI{@h`0|jHud2Y~cI~Yn{r_kU^Vo{1gja + + + LayerCake.CleanTemplate.Application.FunctionalTests + LayerCake.CleanTemplate.Application.FunctionalTests + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/FunctionalTestSetup.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/FunctionalTestSetup.cs new file mode 100644 index 0000000..fb67200 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/FunctionalTestSetup.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests; + +[SetUpFixture] +public class FunctionalTestSetup +{ + internal static IServiceScopeFactory ScopeFactory { get; private set; } = null!; + internal static DatabaseResetter? DbResetter { get; private set; } + + private static WebApiFactory? _factory; + private static DistributedApplication? _app; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var cancellationToken = cts.Token; + + var builder = await DistributedApplicationTestingBuilder + .CreateAsync( + args: [], + configureBuilder: (options, _) => + { + options.DisableDashboard = true; + }); + + builder.Configuration["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true"; + + _app = await builder + .BuildAsync(cancellationToken) + .WaitAsync(cancellationToken); + + await _app + .StartAsync(cancellationToken) + .WaitAsync(cancellationToken); + + await _app.ResourceNotifications.WaitForResourceHealthyAsync( + Services.Database, cancellationToken); + + var connectionString = (await _app.GetConnectionStringAsync(Services.Database))!; + + _factory = new WebApiFactory(connectionString); + ScopeFactory = _factory.Services.GetRequiredService(); + DbResetter = await DatabaseResetter.CreateAsync(connectionString); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + if (DbResetter is not null) await DbResetter.DisposeAsync(); + if (_app is not null) await _app.DisposeAsync(); + if (_factory is not null) await _factory.DisposeAsync(); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/GlobalUsings.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/GlobalUsings.cs new file mode 100644 index 0000000..3bc5a6b --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using Ardalis.GuardClauses; +global using LayerCake.CleanTemplate.Application.FunctionalTests.Infrastructure; +global using LayerCake.CleanTemplate.Shared; +global using Moq; +global using NUnit.Framework; +global using Shouldly; diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/DatabaseResetter.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/DatabaseResetter.cs new file mode 100644 index 0000000..9398d98 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/DatabaseResetter.cs @@ -0,0 +1,36 @@ +using Npgsql; +using Respawn; +using System.Data.Common; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.Infrastructure; + +internal sealed class DatabaseResetter : IAsyncDisposable +{ + private readonly DbConnection _connection; + private readonly Respawner _respawner; + + private DatabaseResetter(DbConnection connection, Respawner respawner) + { + _connection = connection; + _respawner = respawner; + } + + public static async Task CreateAsync(string connectionString) + { + var connection = new NpgsqlConnection(connectionString); + + await connection.OpenAsync(); + var respawner = await Respawner.CreateAsync(connection); + await connection.CloseAsync(); + return new DatabaseResetter(connection, respawner); + } + + public async Task ResetAsync() + { + await _connection.OpenAsync(); + await _respawner.ResetAsync(_connection); + await _connection.CloseAsync(); + } + + public async ValueTask DisposeAsync() => await _connection.DisposeAsync(); +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestApp.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestApp.cs new file mode 100644 index 0000000..bcc1b30 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestApp.cs @@ -0,0 +1,123 @@ +using LayerCake.CleanTemplate.Domain.Constants; +using LayerCake.CleanTemplate.Infrastructure.Data; +using LayerCake.CleanTemplate.Infrastructure.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.Infrastructure; + +public static class TestApp +{ + private static string? _userId; + private static List? _roles; + + public static async Task SendAsync(IRequest request) + { + using var scope = FunctionalTestSetup.ScopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + return await mediator.Send(request); + } + + public static async Task SendAsync(IBaseRequest request) + { + using var scope = FunctionalTestSetup.ScopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + await mediator.Send(request); + } + + public static string? GetUserId() => _userId; + + public static List? GetRoles() => _roles; + + public static async Task RunAsDefaultUserAsync() + { + return await RunAsUserAsync("test@local", "Testing1234!", []); + } + + public static async Task RunAsAdministratorAsync() + { + return await RunAsUserAsync("administrator@local", "Administrator1234!", [Roles.Administrator]); + } + + public static async Task RunAsUserAsync(string userName, string password, string[] roles) + { + using var scope = FunctionalTestSetup.ScopeFactory.CreateScope(); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = new ApplicationUser { UserName = userName, Email = userName }; + + var result = await userManager.CreateAsync(user, password); + + if (roles.Length > 0) + { + var roleManager = scope.ServiceProvider.GetRequiredService>(); + + foreach (var role in roles) + { + await roleManager.CreateAsync(new IdentityRole(role)); + } + + await userManager.AddToRolesAsync(user, roles); + } + + if (result.Succeeded) + { + _userId = user.Id; + _roles = [..roles]; + return _userId; + } + + var errors = string.Join(Environment.NewLine, result.ToApplicationResult().Errors); + + throw new Exception($"Unable to create {userName}.{Environment.NewLine}{errors}"); + } + + public static async Task ResetState() + { + if (FunctionalTestSetup.DbResetter is not null) + { + await FunctionalTestSetup.DbResetter.ResetAsync(); + } + + _userId = null; + _roles = null; + } + + public static async Task FindAsync(params object[] keyValues) + where TEntity : class + { + using var scope = FunctionalTestSetup.ScopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.FindAsync(keyValues); + } + + public static async Task AddAsync(TEntity entity) + where TEntity : class + { + using var scope = FunctionalTestSetup.ScopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + context.Add(entity); + + await context.SaveChangesAsync(); + } + + public static async Task CountAsync() where TEntity : class + { + using var scope = FunctionalTestSetup.ScopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.Set().CountAsync(); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestBase.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestBase.cs new file mode 100644 index 0000000..ba1bd8f --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/TestBase.cs @@ -0,0 +1,10 @@ +namespace LayerCake.CleanTemplate.Application.FunctionalTests.Infrastructure; + +public abstract class TestBase +{ + [SetUp] + public async Task SetUp() + { + await TestApp.ResetState(); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/WebApiFactory.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/WebApiFactory.cs new file mode 100644 index 0000000..13eb717 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/Infrastructure/WebApiFactory.cs @@ -0,0 +1,30 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.Infrastructure; + +public class WebApiFactory(string connectionString) : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder + .UseSetting("ConnectionStrings:LayerCake.CleanTemplateDb", connectionString); + + builder.ConfigureTestServices(services => + { + services + .RemoveAll() + .AddTransient(provider => + { + var mock = new Mock(); + mock.SetupGet(x => x.Roles).Returns(TestApp.GetRoles()); + mock.SetupGet(x => x.Id).Returns(TestApp.GetUserId()); + return mock.Object; + }); + }); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs new file mode 100644 index 0000000..961445a --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs @@ -0,0 +1,46 @@ +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoItems.Commands; + +public class CreateTodoItemTests : TestBase +{ + [Test] + public async Task ShouldRequireMinimumFields() + { + var command = new CreateTodoItemCommand(); + + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldCreateTodoItem() + { + var userId = await TestApp.RunAsDefaultUserAsync(); + + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var command = new CreateTodoItemCommand + { + ListId = listId, + Title = "Tasks" + }; + + var itemId = await TestApp.SendAsync(command); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.ListId.ShouldBe(command.ListId); + item.Title.ShouldBe(command.Title); + item.CreatedBy.ShouldBe(userId); + item.Created.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + item.LastModifiedBy.ShouldBe(userId); + item.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs new file mode 100644 index 0000000..2157841 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs @@ -0,0 +1,38 @@ +using LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.DeleteTodoItem; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoItems.Commands; + +public class DeleteTodoItemTests : TestBase +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new DeleteTodoItemCommand(99); + + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldDeleteTodoItem() + { + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await TestApp.SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + await TestApp.SendAsync(new DeleteTodoItemCommand(itemId)); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldBeNull(); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs new file mode 100644 index 0000000..733ad1b --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs @@ -0,0 +1,56 @@ +using LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItemDetail; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Domain.Enums; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoItems.Commands; + +public class UpdateTodoItemDetailTests : TestBase +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new UpdateTodoItemCommand { Id = 99, Title = "New Title" }; + + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldUpdateTodoItem() + { + var userId = await TestApp.RunAsDefaultUserAsync(); + + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await TestApp.SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + var command = new UpdateTodoItemDetailCommand + { + Id = itemId, + ListId = listId, + Note = "This is the note.", + Priority = PriorityLevel.High + }; + + await TestApp.SendAsync(command); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.ListId.ShouldBe(command.ListId); + item.Note.ShouldBe(command.Note); + item.Priority.ShouldBe(command.Priority); + item.LastModifiedBy.ShouldNotBeNull(); + item.LastModifiedBy.ShouldBe(userId); + item.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs new file mode 100644 index 0000000..0122fd3 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs @@ -0,0 +1,49 @@ +using LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.UpdateTodoItem; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoItems.Commands; + +public class UpdateTodoItemTests : TestBase +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new UpdateTodoItemCommand { Id = 99, Title = "New Title" }; + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldUpdateTodoItem() + { + var userId = await TestApp.RunAsDefaultUserAsync(); + + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await TestApp.SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + var command = new UpdateTodoItemCommand + { + Id = itemId, + Title = "Updated Item Title" + }; + + await TestApp.SendAsync(command); + + var item = await TestApp.FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.Title.ShouldBe(command.Title); + item.LastModifiedBy.ShouldNotBeNull(); + item.LastModifiedBy.ShouldBe(userId); + item.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs new file mode 100644 index 0000000..85bae6d --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs @@ -0,0 +1,51 @@ +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoLists.Commands; + +public class CreateTodoListTests : TestBase +{ + [Test] + public async Task ShouldRequireMinimumFields() + { + var command = new CreateTodoListCommand(); + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldRequireUniqueTitle() + { + await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "Shopping" + }); + + var command = new CreateTodoListCommand + { + Title = "Shopping" + }; + + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldCreateTodoList() + { + var userId = await TestApp.RunAsDefaultUserAsync(); + + var command = new CreateTodoListCommand + { + Title = "Tasks" + }; + + var id = await TestApp.SendAsync(command); + + var list = await TestApp.FindAsync(id); + + list.ShouldNotBeNull(); + list!.Title.ShouldBe(command.Title); + list.CreatedBy.ShouldBe(userId); + list.Created.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs new file mode 100644 index 0000000..10306a4 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs @@ -0,0 +1,30 @@ +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.DeleteTodoList; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoLists.Commands; + +public class DeleteTodoListTests : TestBase +{ + [Test] + public async Task ShouldRequireValidTodoListId() + { + var command = new DeleteTodoListCommand(99); + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldDeleteTodoList() + { + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + await TestApp.SendAsync(new DeleteTodoListCommand(listId)); + + var list = await TestApp.FindAsync(listId); + + list.ShouldBeNull(); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs new file mode 100644 index 0000000..8f39157 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs @@ -0,0 +1,68 @@ +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.CreateTodoList; +using LayerCake.CleanTemplate.Application.TodoLists.Commands.UpdateTodoList; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoLists.Commands; + +public class UpdateTodoListTests : TestBase +{ + [Test] + public async Task ShouldRequireValidTodoListId() + { + var command = new UpdateTodoListCommand { Id = 99, Title = "New Title" }; + await Should.ThrowAsync(() => TestApp.SendAsync(command)); + } + + [Test] + public async Task ShouldRequireUniqueTitle() + { + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "Other List" + }); + + var command = new UpdateTodoListCommand + { + Id = listId, + Title = "Other List" + }; + + var ex = await Should.ThrowAsync(() => TestApp.SendAsync(command)); + + ex.Errors.ShouldContainKey("Title"); + ex.Errors["Title"].ShouldContain("'Title' must be unique."); + } + + [Test] + public async Task ShouldUpdateTodoList() + { + var userId = await TestApp.RunAsDefaultUserAsync(); + + var listId = await TestApp.SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var command = new UpdateTodoListCommand + { + Id = listId, + Title = "Updated List Title" + }; + + await TestApp.SendAsync(command); + + var list = await TestApp.FindAsync(listId); + + list.ShouldNotBeNull(); + list!.Title.ShouldBe(command.Title); + list.LastModifiedBy.ShouldNotBeNull(); + list.LastModifiedBy.ShouldBe(userId); + list.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs new file mode 100644 index 0000000..d92a015 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs @@ -0,0 +1,59 @@ +using LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; +using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Domain.ValueObjects; + +namespace LayerCake.CleanTemplate.Application.FunctionalTests.TodoLists.Queries; + +public class GetTodosTests : TestBase +{ + [Test] + public async Task ShouldReturnPriorityLevels() + { + await TestApp.RunAsDefaultUserAsync(); + + var query = new GetTodosQuery(); + + var result = await TestApp.SendAsync(query); + + result.PriorityLevels.ShouldNotBeEmpty(); + } + + [Test] + public async Task ShouldReturnAllListsAndItems() + { + await TestApp.RunAsDefaultUserAsync(); + + await TestApp.AddAsync(new TodoList + { + Title = "Shopping", + Colour = Colour.Blue, + Items = + { + new TodoItem { Title = "Apples", Done = true }, + new TodoItem { Title = "Milk", Done = true }, + new TodoItem { Title = "Bread", Done = true }, + new TodoItem { Title = "Toilet paper" }, + new TodoItem { Title = "Pasta" }, + new TodoItem { Title = "Tissues" }, + new TodoItem { Title = "Tuna" } + } + }); + + var query = new GetTodosQuery(); + + var result = await TestApp.SendAsync(query); + + result.Lists.Count.ShouldBe(1); + result.Lists.First().Items.Count.ShouldBe(7); + } + + [Test] + public async Task ShouldDenyAnonymousUser() + { + var query = new GetTodosQuery(); + + var action = () => TestApp.SendAsync(query); + + await Should.ThrowAsync(action); + } +} diff --git a/experiments/before-clean-template/tests/Application.UnitTests/Application.UnitTests.csproj b/experiments/before-clean-template/tests/Application.UnitTests/Application.UnitTests.csproj new file mode 100644 index 0000000..90d2465 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.UnitTests/Application.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + LayerCake.CleanTemplate.Application.UnitTests + LayerCake.CleanTemplate.Application.UnitTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/experiments/before-clean-template/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs b/experiments/before-clean-template/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs new file mode 100644 index 0000000..8189c59 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs @@ -0,0 +1,45 @@ +using LayerCake.CleanTemplate.Application.Common.Behaviours; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Application.TodoItems.Commands.CreateTodoItem; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace LayerCake.CleanTemplate.Application.UnitTests.Common.Behaviours; + +public class RequestLoggerTests +{ + private Mock> _logger = null!; + private Mock _user = null!; + private Mock _identityService = null!; + + [SetUp] + public void Setup() + { + _logger = new Mock>(); + _user = new Mock(); + _identityService = new Mock(); + } + + [Test] + public async Task ShouldCallGetUserNameAsyncOnceIfAuthenticated() + { + _user.Setup(x => x.Id).Returns(Guid.NewGuid().ToString()); + + var requestLogger = new LoggingBehaviour(_logger.Object, _user.Object, _identityService.Object); + + await requestLogger.Process(new CreateTodoItemCommand { ListId = 1, Title = "title" }, new CancellationToken()); + + _identityService.Verify(i => i.GetUserNameAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task ShouldNotCallGetUserNameAsyncOnceIfUnauthenticated() + { + var requestLogger = new LoggingBehaviour(_logger.Object, _user.Object, _identityService.Object); + + await requestLogger.Process(new CreateTodoItemCommand { ListId = 1, Title = "title" }, new CancellationToken()); + + _identityService.Verify(i => i.GetUserNameAsync(It.IsAny()), Times.Never); + } +} diff --git a/experiments/before-clean-template/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs b/experiments/before-clean-template/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs new file mode 100644 index 0000000..0bc0638 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs @@ -0,0 +1,63 @@ +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using FluentValidation.Results; +using NUnit.Framework; +using Shouldly; + +namespace LayerCake.CleanTemplate.Application.UnitTests.Common.Exceptions; + +public class ValidationExceptionTests +{ + [Test] + public void DefaultConstructorCreatesAnEmptyErrorDictionary() + { + var actual = new ValidationException().Errors; + + actual.Keys.ShouldBeEmpty(); + } + + [Test] + public void SingleValidationFailureCreatesASingleElementErrorDictionary() + { + var failures = new List + { + new ValidationFailure("Age", "must be over 18"), + }; + + var actual = new ValidationException(failures).Errors; + + actual.Keys.ShouldBe(new string[] { "Age" }); + actual["Age"].ShouldBe(new string[] { "must be over 18" }); + } + + [Test] + public void MulitpleValidationFailureForMultiplePropertiesCreatesAMultipleElementErrorDictionaryEachWithMultipleValues() + { + var failures = new List + { + new ValidationFailure("Age", "must be 18 or older"), + new ValidationFailure("Age", "must be 25 or younger"), + new ValidationFailure("Password", "must contain at least 8 characters"), + new ValidationFailure("Password", "must contain a digit"), + new ValidationFailure("Password", "must contain upper case letter"), + new ValidationFailure("Password", "must contain lower case letter"), + }; + + var actual = new ValidationException(failures).Errors; + + actual.Keys.ShouldBe(new string[] { "Password", "Age" }, ignoreOrder: true); + + actual["Age"].ShouldBe(new string[] + { + "must be 25 or younger", + "must be 18 or older", + }, ignoreOrder: true); + + actual["Password"].ShouldBe(new string[] + { + "must contain lower case letter", + "must contain upper case letter", + "must contain at least 8 characters", + "must contain a digit", + }, ignoreOrder: true); + } +} diff --git a/experiments/before-clean-template/tests/Application.UnitTests/Common/Mappings/MappingTests.cs b/experiments/before-clean-template/tests/Application.UnitTests/Common/Mappings/MappingTests.cs new file mode 100644 index 0000000..1c33226 --- /dev/null +++ b/experiments/before-clean-template/tests/Application.UnitTests/Common/Mappings/MappingTests.cs @@ -0,0 +1,61 @@ +using System.Runtime.CompilerServices; +using AutoMapper; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Application.TodoLists.Queries.GetTodos; +using LayerCake.CleanTemplate.Domain.Entities; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace LayerCake.CleanTemplate.Application.UnitTests.Common.Mappings; + +public class MappingTests +{ + private ILoggerFactory? _loggerFactory; + private MapperConfiguration? _configuration; + private IMapper? _mapper; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + // Minimal logger factory for tests + _loggerFactory = LoggerFactory.Create(b => b.AddDebug().SetMinimumLevel(LogLevel.Debug)); + + _configuration = new MapperConfiguration(cfg => + cfg.AddMaps(typeof(IApplicationDbContext).Assembly), + loggerFactory: _loggerFactory); + + _mapper = _configuration.CreateMapper(); + } + + [Test] + public void ShouldHaveValidConfiguration() + { + _configuration!.AssertConfigurationIsValid(); + } + + [Test] + [TestCase(typeof(TodoList), typeof(TodoListDto))] + [TestCase(typeof(TodoItem), typeof(TodoItemDto))] + public void ShouldSupportMappingFromSourceToDestination(Type source, Type destination) + { + var instance = GetInstanceOf(source); + + _mapper!.Map(instance, source, destination); + } + + private static object GetInstanceOf(Type type) + { + if (type.GetConstructor(Type.EmptyTypes) != null) + return Activator.CreateInstance(type)!; + + // Type without parameterless constructor + return RuntimeHelpers.GetUninitializedObject(type); + } + + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _loggerFactory?.Dispose(); + } +} diff --git a/experiments/before-clean-template/tests/Domain.UnitTests/Domain.UnitTests.csproj b/experiments/before-clean-template/tests/Domain.UnitTests/Domain.UnitTests.csproj new file mode 100644 index 0000000..816de3f --- /dev/null +++ b/experiments/before-clean-template/tests/Domain.UnitTests/Domain.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + LayerCake.CleanTemplate.Domain.UnitTests + LayerCake.CleanTemplate.Domain.UnitTests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/experiments/before-clean-template/tests/Domain.UnitTests/ValueObjects/ColourTests.cs b/experiments/before-clean-template/tests/Domain.UnitTests/ValueObjects/ColourTests.cs new file mode 100644 index 0000000..238d0b5 --- /dev/null +++ b/experiments/before-clean-template/tests/Domain.UnitTests/ValueObjects/ColourTests.cs @@ -0,0 +1,59 @@ +using LayerCake.CleanTemplate.Domain.Exceptions; +using LayerCake.CleanTemplate.Domain.ValueObjects; +using NUnit.Framework; +using Shouldly; + +namespace LayerCake.CleanTemplate.Domain.UnitTests.ValueObjects; + +public class ColourTests +{ + [Test] + public void ShouldReturnCorrectColourCode() + { + var code = "#E05C4D"; + + var colour = Colour.From(code); + + colour.Code.ShouldBe(code); + } + + [Test] + public void ToStringReturnsCode() + { + var colour = Colour.Red; + + colour.ToString().ShouldBe(colour.Code); + } + + [Test] + public void ShouldPerformImplicitConversionToColourCodeString() + { + string code = Colour.Red; + + code.ShouldBe("#E05C4D"); + } + + [Test] + public void ShouldPerformExplicitConversionGivenSupportedColourCode() + { + var colour = (Colour)"#E05C4D"; + + colour.ShouldBe(Colour.Red); + } + + [Test] + public void ShouldThrowUnsupportedColourExceptionGivenNotSupportedColourCode() + { + Should.Throw(() => Colour.From("##FF33CC")); + } + + [Test] + public void ShouldBeComparableWithOperators() + { + var color1 = new Colour("#E05C4D"); + var color2 = new Colour("#E05C4D"); + var color3 = new Colour("#AAAAAA"); + (color1 == color2).ShouldBe(true); + (color1 == color3).ShouldBe(false); + } +} diff --git a/experiments/before-clean-template/tests/Infrastructure.IntegrationTests/GlobalUsings.cs b/experiments/before-clean-template/tests/Infrastructure.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/experiments/before-clean-template/tests/Infrastructure.IntegrationTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/experiments/before-clean-template/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj b/experiments/before-clean-template/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj new file mode 100644 index 0000000..4104993 --- /dev/null +++ b/experiments/before-clean-template/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj @@ -0,0 +1,22 @@ + + + + LayerCake.CleanTemplate.Infrastructure.IntegrationTests + LayerCake.CleanTemplate.Infrastructure.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/experiments/before-clean-template/tests/TestAppHost/Program.cs b/experiments/before-clean-template/tests/TestAppHost/Program.cs new file mode 100644 index 0000000..74e2489 --- /dev/null +++ b/experiments/before-clean-template/tests/TestAppHost/Program.cs @@ -0,0 +1,16 @@ +using LayerCake.CleanTemplate.Shared; + +namespace LayerCake.CleanTemplate.TestAppHost; + +public class Program +{ + public static void Main(string[] args) + { + var builder = DistributedApplication.CreateBuilder(args); + + builder.AddPostgres(Services.DatabaseServer) + .AddDatabase(Services.Database); + + builder.Build().Run(); + } +} \ No newline at end of file diff --git a/experiments/before-clean-template/tests/TestAppHost/TestAppHost.csproj b/experiments/before-clean-template/tests/TestAppHost/TestAppHost.csproj new file mode 100644 index 0000000..2f9e682 --- /dev/null +++ b/experiments/before-clean-template/tests/TestAppHost/TestAppHost.csproj @@ -0,0 +1,18 @@ + + + + Exe + LayerCake.CleanTemplate.TestAppHost + LayerCake.CleanTemplate.TestAppHost + + + + + + + + + + + + From 19ae28ddd5147fce8977941d349cebe012178368 Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Sun, 6 Sep 2026 17:36:50 -0500 Subject: [PATCH 2/4] Experiment: slice 001 (PublishCake, BrowseCakes, GetCake) on the template, the template's way IApplicationDbContext instead of a repository, command and handler in one file, DTO with a nested AutoMapper profile and ProjectTo on reads, FluentValidation validator picked up by the template's pipeline, Guard.Against.NotFound for the 404, a new DuplicateCakeNameException mapped to 409 in the template's ProblemDetailsExceptionHandler, an IEndpointGroup with RoutePrefix /cakes, seeds in the template's initialiser. The template's int-keyed base entity goes generic the way its own comment suggests; the two Todo samples move to BaseAuditableEntity. A /ping group is added for the shared ping scenario and counted separately. --- .../src/Application/Cakes/CakeDto.cs | 24 +++++++++ .../Cakes/Commands/PublishCake/PublishCake.cs | 50 +++++++++++++++++++ .../PublishCakeCommandValidator.cs | 14 ++++++ .../Cakes/Queries/BrowseCakes/BrowseCakes.cs | 26 ++++++++++ .../Cakes/Queries/GetCake/GetCake.cs | 30 +++++++++++ .../Exceptions/DuplicateCakeNameException.cs | 9 ++++ .../Interfaces/IApplicationDbContext.cs | 4 +- .../src/Domain/Common/BaseAuditableEntity.cs | 12 ++++- .../src/Domain/Common/BaseEntity.cs | 15 ++++-- .../src/Domain/Entities/Cake.cs | 12 +++++ .../src/Domain/Entities/TodoItem.cs | 4 +- .../src/Domain/Entities/TodoList.cs | 4 +- .../Data/ApplicationDbContext.cs | 4 +- .../Data/ApplicationDbContextInitialiser.cs | 14 +++++- .../Data/Configurations/CakeConfiguration.cs | 25 ++++++++++ .../src/Web/Endpoints/Cakes.cs | 46 +++++++++++++++++ .../src/Web/Endpoints/Ping.cs | 20 ++++++++ .../ProblemDetailsExceptionHandler.cs | 10 +++- 18 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 experiments/before-clean-template/src/Application/Cakes/CakeDto.cs create mode 100644 experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCake.cs create mode 100644 experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs create mode 100644 experiments/before-clean-template/src/Application/Cakes/Queries/BrowseCakes/BrowseCakes.cs create mode 100644 experiments/before-clean-template/src/Application/Cakes/Queries/GetCake/GetCake.cs create mode 100644 experiments/before-clean-template/src/Application/Common/Exceptions/DuplicateCakeNameException.cs create mode 100644 experiments/before-clean-template/src/Domain/Entities/Cake.cs create mode 100644 experiments/before-clean-template/src/Infrastructure/Data/Configurations/CakeConfiguration.cs create mode 100644 experiments/before-clean-template/src/Web/Endpoints/Cakes.cs create mode 100644 experiments/before-clean-template/src/Web/Endpoints/Ping.cs diff --git a/experiments/before-clean-template/src/Application/Cakes/CakeDto.cs b/experiments/before-clean-template/src/Application/Cakes/CakeDto.cs new file mode 100644 index 0000000..abe3dcb --- /dev/null +++ b/experiments/before-clean-template/src/Application/Cakes/CakeDto.cs @@ -0,0 +1,24 @@ +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.Cakes; + +public class CakeDto +{ + public Guid Id { get; init; } + + public string? Name { get; init; } + + public string? Description { get; init; } + + public decimal Price { get; init; } + + public DateTimeOffset PublishedAt { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + } + } +} diff --git a/experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCake.cs b/experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCake.cs new file mode 100644 index 0000000..3d2a196 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCake.cs @@ -0,0 +1,50 @@ +using LayerCake.CleanTemplate.Application.Common.Exceptions; +using LayerCake.CleanTemplate.Application.Common.Interfaces; +using LayerCake.CleanTemplate.Domain.Entities; + +namespace LayerCake.CleanTemplate.Application.Cakes.Commands.PublishCake; + +public record PublishCakeCommand : IRequest +{ + public string? Name { get; init; } + + public string? Description { get; init; } + + public decimal Price { get; init; } +} + +public class PublishCakeCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + private readonly TimeProvider _timeProvider; + + public PublishCakeCommandHandler(IApplicationDbContext context, IMapper mapper, TimeProvider timeProvider) + { + _context = context; + _mapper = mapper; + _timeProvider = timeProvider; + } + + public async Task Handle(PublishCakeCommand request, CancellationToken cancellationToken) + { + if (await _context.Cakes.AnyAsync(c => c.Name == request.Name, cancellationToken)) + { + throw new DuplicateCakeNameException(request.Name!); + } + + var entity = new Cake + { + Name = request.Name, + Description = request.Description ?? string.Empty, + Price = request.Price, + PublishedAt = _timeProvider.GetUtcNow() + }; + + _context.Cakes.Add(entity); + + await _context.SaveChangesAsync(cancellationToken); + + return _mapper.Map(entity); + } +} diff --git a/experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs b/experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs new file mode 100644 index 0000000..e543d64 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs @@ -0,0 +1,14 @@ +namespace LayerCake.CleanTemplate.Application.Cakes.Commands.PublishCake; + +public class PublishCakeCommandValidator : AbstractValidator +{ + public PublishCakeCommandValidator() + { + RuleFor(v => v.Name) + .NotEmpty() + .MaximumLength(200); + + RuleFor(v => v.Price) + .GreaterThan(0); + } +} diff --git a/experiments/before-clean-template/src/Application/Cakes/Queries/BrowseCakes/BrowseCakes.cs b/experiments/before-clean-template/src/Application/Cakes/Queries/BrowseCakes/BrowseCakes.cs new file mode 100644 index 0000000..2b8a8ce --- /dev/null +++ b/experiments/before-clean-template/src/Application/Cakes/Queries/BrowseCakes/BrowseCakes.cs @@ -0,0 +1,26 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.Cakes.Queries.BrowseCakes; + +public record BrowseCakesQuery : IRequest>; + +public class BrowseCakesQueryHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + + public BrowseCakesQueryHandler(IApplicationDbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task> Handle(BrowseCakesQuery request, CancellationToken cancellationToken) + { + return await _context.Cakes + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(c => c.Name) + .ToListAsync(cancellationToken); + } +} diff --git a/experiments/before-clean-template/src/Application/Cakes/Queries/GetCake/GetCake.cs b/experiments/before-clean-template/src/Application/Cakes/Queries/GetCake/GetCake.cs new file mode 100644 index 0000000..f3b59ed --- /dev/null +++ b/experiments/before-clean-template/src/Application/Cakes/Queries/GetCake/GetCake.cs @@ -0,0 +1,30 @@ +using LayerCake.CleanTemplate.Application.Common.Interfaces; + +namespace LayerCake.CleanTemplate.Application.Cakes.Queries.GetCake; + +public record GetCakeQuery(Guid Id) : IRequest; + +public class GetCakeQueryHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + private readonly IMapper _mapper; + + public GetCakeQueryHandler(IApplicationDbContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public async Task Handle(GetCakeQuery request, CancellationToken cancellationToken) + { + var cake = await _context.Cakes + .AsNoTracking() + .Where(c => c.Id == request.Id) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(cancellationToken); + + Guard.Against.NotFound(request.Id, cake); + + return cake; + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Exceptions/DuplicateCakeNameException.cs b/experiments/before-clean-template/src/Application/Common/Exceptions/DuplicateCakeNameException.cs new file mode 100644 index 0000000..02ef991 --- /dev/null +++ b/experiments/before-clean-template/src/Application/Common/Exceptions/DuplicateCakeNameException.cs @@ -0,0 +1,9 @@ +namespace LayerCake.CleanTemplate.Application.Common.Exceptions; + +public class DuplicateCakeNameException : Exception +{ + public DuplicateCakeNameException(string name) + : base($"A cake named \"{name}\" has already been published.") + { + } +} diff --git a/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs b/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs index d6477de..a6930a1 100644 --- a/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs +++ b/experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -1,4 +1,4 @@ -using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Domain.Entities; namespace LayerCake.CleanTemplate.Application.Common.Interfaces; @@ -8,5 +8,7 @@ public interface IApplicationDbContext DbSet TodoItems { get; } + DbSet Cakes { get; } + Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs b/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs index 3a480a9..38b18d2 100644 --- a/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs +++ b/experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs @@ -1,5 +1,10 @@ -namespace LayerCake.CleanTemplate.Domain.Common; +namespace LayerCake.CleanTemplate.Domain.Common; +/// +/// Non-generic root so AuditableEntityInterceptor can stamp every +/// auditable entity regardless of key type. The key itself lives on +/// . +/// public abstract class BaseAuditableEntity : BaseEntity { public DateTimeOffset Created { get; set; } @@ -10,3 +15,8 @@ public abstract class BaseAuditableEntity : BaseEntity public string? LastModifiedBy { get; set; } } + +public abstract class BaseAuditableEntity : BaseAuditableEntity +{ + public TId Id { get; set; } = default!; +} diff --git a/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs b/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs index 24a9d1d..be1c9e3 100644 --- a/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs +++ b/experiments/before-clean-template/src/Domain/Common/BaseEntity.cs @@ -1,13 +1,13 @@ -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations.Schema; namespace LayerCake.CleanTemplate.Domain.Common; +/// +/// Non-generic root so persistence interceptors can find every entity +/// regardless of key type. The key itself lives on . +/// public abstract class BaseEntity { - // This can easily be modified to be BaseEntity and public T Id to support different key types. - // Using non-generic integer types for simplicity - public int Id { get; set; } - private readonly List _domainEvents = new(); [NotMapped] @@ -28,3 +28,8 @@ public void ClearDomainEvents() _domainEvents.Clear(); } } + +public abstract class BaseEntity : BaseEntity +{ + public TId Id { get; set; } = default!; +} diff --git a/experiments/before-clean-template/src/Domain/Entities/Cake.cs b/experiments/before-clean-template/src/Domain/Entities/Cake.cs new file mode 100644 index 0000000..7eac9f5 --- /dev/null +++ b/experiments/before-clean-template/src/Domain/Entities/Cake.cs @@ -0,0 +1,12 @@ +namespace LayerCake.CleanTemplate.Domain.Entities; + +public class Cake : BaseAuditableEntity +{ + public string? Name { get; set; } + + public string? Description { get; set; } + + public decimal Price { get; set; } + + public DateTimeOffset PublishedAt { get; set; } +} diff --git a/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs b/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs index 4102682..20f4c50 100644 --- a/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs +++ b/experiments/before-clean-template/src/Domain/Entities/TodoItem.cs @@ -1,6 +1,6 @@ -namespace LayerCake.CleanTemplate.Domain.Entities; +namespace LayerCake.CleanTemplate.Domain.Entities; -public class TodoItem : BaseAuditableEntity +public class TodoItem : BaseAuditableEntity { public int ListId { get; set; } diff --git a/experiments/before-clean-template/src/Domain/Entities/TodoList.cs b/experiments/before-clean-template/src/Domain/Entities/TodoList.cs index 27e3191..1c59256 100644 --- a/experiments/before-clean-template/src/Domain/Entities/TodoList.cs +++ b/experiments/before-clean-template/src/Domain/Entities/TodoList.cs @@ -1,6 +1,6 @@ -namespace LayerCake.CleanTemplate.Domain.Entities; +namespace LayerCake.CleanTemplate.Domain.Entities; -public class TodoList : BaseAuditableEntity +public class TodoList : BaseAuditableEntity { public string? Title { get; set; } diff --git a/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs index fd82bee..62fe8a3 100644 --- a/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs +++ b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using LayerCake.CleanTemplate.Application.Common.Interfaces; using LayerCake.CleanTemplate.Domain.Entities; using LayerCake.CleanTemplate.Infrastructure.Identity; @@ -15,6 +15,8 @@ public ApplicationDbContext(DbContextOptions options) : ba public DbSet TodoItems => Set(); + public DbSet Cakes => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs index 385fe83..133f09d 100644 --- a/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs +++ b/experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs @@ -1,4 +1,4 @@ -using LayerCake.CleanTemplate.Domain.Constants; +using LayerCake.CleanTemplate.Domain.Constants; using LayerCake.CleanTemplate.Domain.Entities; using LayerCake.CleanTemplate.Domain.ValueObjects; using LayerCake.CleanTemplate.Infrastructure.Identity; @@ -106,5 +106,17 @@ public async Task TrySeedAsync() await _context.SaveChangesAsync(); } + + if (!_context.Cakes.Any()) + { + var publishedAt = DateTimeOffset.UtcNow; + + _context.Cakes.AddRange( + new Cake { Name = "Classic Yellow", Description = "Three layers, vanilla buttercream", Price = 24.00m, PublishedAt = publishedAt }, + new Cake { Name = "Chocolate Stout", Description = "Six layers, no mercy", Price = 34.00m, PublishedAt = publishedAt }, + new Cake { Name = "Lemon Chiffon", Description = "Light, tart, dangerously easy", Price = 28.00m, PublishedAt = publishedAt }); + + await _context.SaveChangesAsync(); + } } } diff --git a/experiments/before-clean-template/src/Infrastructure/Data/Configurations/CakeConfiguration.cs b/experiments/before-clean-template/src/Infrastructure/Data/Configurations/CakeConfiguration.cs new file mode 100644 index 0000000..e0c8d48 --- /dev/null +++ b/experiments/before-clean-template/src/Infrastructure/Data/Configurations/CakeConfiguration.cs @@ -0,0 +1,25 @@ +using LayerCake.CleanTemplate.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LayerCake.CleanTemplate.Infrastructure.Data.Configurations; + +public class CakeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(c => c.Name) + .HasMaxLength(200) + .IsRequired(); + + builder.HasIndex(c => c.Name) + .IsUnique(); + + builder.Property(c => c.Description) + .HasMaxLength(2000) + .IsRequired(); + + builder.Property(c => c.Price) + .HasPrecision(10, 2); + } +} diff --git a/experiments/before-clean-template/src/Web/Endpoints/Cakes.cs b/experiments/before-clean-template/src/Web/Endpoints/Cakes.cs new file mode 100644 index 0000000..fc509ce --- /dev/null +++ b/experiments/before-clean-template/src/Web/Endpoints/Cakes.cs @@ -0,0 +1,46 @@ +using LayerCake.CleanTemplate.Application.Cakes; +using LayerCake.CleanTemplate.Application.Cakes.Commands.PublishCake; +using LayerCake.CleanTemplate.Application.Cakes.Queries.BrowseCakes; +using LayerCake.CleanTemplate.Application.Cakes.Queries.GetCake; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace LayerCake.CleanTemplate.Web.Endpoints; + +public class Cakes : IEndpointGroup +{ + public static string? RoutePrefix => "/cakes"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapGet(BrowseCakes); + groupBuilder.MapGet(GetCake, "{id}"); + groupBuilder.MapPost(PublishCake); + } + + [EndpointSummary("Browse cakes")] + [EndpointDescription("Retrieves every published cake, ordered by name.")] + public static async Task>> BrowseCakes(ISender sender) + { + var cakes = await sender.Send(new BrowseCakesQuery()); + + return TypedResults.Ok(cakes); + } + + [EndpointSummary("Get a cake")] + [EndpointDescription("Retrieves a single published cake by id.")] + public static async Task> GetCake(ISender sender, Guid id) + { + var cake = await sender.Send(new GetCakeQuery(id)); + + return TypedResults.Ok(cake); + } + + [EndpointSummary("Publish a cake")] + [EndpointDescription("Publishes a new cake and returns it. Cake names are unique.")] + public static async Task> PublishCake(ISender sender, PublishCakeCommand command) + { + var cake = await sender.Send(command); + + return TypedResults.Created($"/cakes/{cake.Id}", cake); + } +} diff --git a/experiments/before-clean-template/src/Web/Endpoints/Ping.cs b/experiments/before-clean-template/src/Web/Endpoints/Ping.cs new file mode 100644 index 0000000..d232000 --- /dev/null +++ b/experiments/before-clean-template/src/Web/Endpoints/Ping.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace LayerCake.CleanTemplate.Web.Endpoints; + +public class Ping : IEndpointGroup +{ + public static string? RoutePrefix => "/ping"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapGet(GetPing); + } + + [EndpointSummary("Ping")] + [EndpointDescription("Liveness probe shared with the two LayerCake twins.")] + public static ContentHttpResult GetPing() + { + return TypedResults.Text("pong"); + } +} diff --git a/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs b/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs index 5d7af8e..41f4b13 100644 --- a/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs +++ b/experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs @@ -7,7 +7,8 @@ namespace LayerCake.CleanTemplate.Web.Infrastructure; /// /// Converts well-known application exceptions into RFC 9110-compliant responses, /// mapping → 400, → 404, -/// → 401, and → 403. +/// → 401, → 403, +/// and → 409. /// Unrecognised exceptions are not handled and fall through to the default middleware. /// public class ProblemDetailsExceptionHandler : IExceptionHandler @@ -40,6 +41,13 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Title = "Forbidden", Type = "https://tools.ietf.org/html/rfc9110#section-15.5.4" }), + DuplicateCakeNameException de => (StatusCodes.Status409Conflict, new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.10", + Title = "Conflict", + Detail = de.Message + }), _ => (-1, null) }; From 57c17d6ad3d42b74f6939681834996f43caf5482 Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Sun, 6 Sep 2026 17:36:50 -0500 Subject: [PATCH 3/4] Experiment: third Alba host runs the shared cake and ping scenarios against the template tests/LayerCake.ContractTests.CleanTemplate is its own test project, outside LayerCake.slnx: one test process can load one MediatR and one AutoMapper, and the template's 14.1.0 / 16.1.1 unloaded the before twin's 12.5.0 / 14.0.0 when the reference was added to the shared suite (every before-twin scenario failed with a TypeLoadException before running). The twins keep their process; the template host references the existing suite for the unchanged scenario base classes and aliases the template's Web assembly because all three hosts expose a public top-level Program. Same Testcontainers PostgreSQL fixture, own database name because the template initialises with EnsureDeleted + EnsureCreated. Result as committed: 6/10 green (5/9 cakes + ping); the four error scenarios are red on content type alone (the template's exception handler writes application/json), status codes correct. --- .../CleanTemplateHost.cs | 117 ++++++++++++++++++ ...yerCake.ContractTests.CleanTemplate.csproj | 34 +++++ 2 files changed, 151 insertions(+) create mode 100644 tests/LayerCake.ContractTests.CleanTemplate/CleanTemplateHost.cs create mode 100644 tests/LayerCake.ContractTests.CleanTemplate/LayerCake.ContractTests.CleanTemplate.csproj diff --git a/tests/LayerCake.ContractTests.CleanTemplate/CleanTemplateHost.cs b/tests/LayerCake.ContractTests.CleanTemplate/CleanTemplateHost.cs new file mode 100644 index 0000000..71f3929 --- /dev/null +++ b/tests/LayerCake.ContractTests.CleanTemplate/CleanTemplateHost.cs @@ -0,0 +1,117 @@ +extern alias cleantemplate; + +using Alba; +using LayerCake.CleanTemplate.Domain.Entities; +using LayerCake.CleanTemplate.Infrastructure.Data; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LayerCake.ContractTests; + +/// +/// Experiment (branch experiment/before-clean-template only): the same cake scenarios +/// run against slice 001 built on the Clean Architecture Solution Template (`dotnet new ca-sln`) +/// (experiments/before-clean-template). Its own collection and container, the same +/// way as the two twins; no coupon or order scenarios, because the template +/// host has nothing for them to run against. +/// +[CollectionDefinition(Name)] +public sealed class CleanTemplateCollection : ICollectionFixture +{ + public const string Name = "clean template"; +} + +/// +/// Boots the template's Web host for a test class against the collection's +/// container and runs the template's own initialiser, which drops, recreates +/// and seeds the database, so every scenario class starts from the same +/// three-cake catalog. +/// +public sealed class CleanTemplateHostFixture : IAsyncLifetime +{ + private readonly PostgresContainerFixture _postgres; + + public CleanTemplateHostFixture(PostgresContainerFixture postgres) + { + _postgres = postgres; + } + + public IAlbaHost Host { get; private set; } = null!; + + public async Task InitializeAsync() + { + // The template initialises with EnsureDeleted + EnsureCreated, not + // migrations. It cannot drop the container's default "postgres" + // database while connected to it, so the template gets its own + // database name; EnsureCreated creates it on first use. + var connectionString = new NpgsqlConnectionStringBuilder(_postgres.ConnectionString) + { + Database = "layercake_clean_template" + }.ConnectionString; + + Host = await AlbaHost.For(x => + { + // The key the template's Infrastructure reads (Services.Database). + x.UseSetting("ConnectionStrings:LayerCake.CleanTemplateDb", connectionString); + }); + + // Program.cs only initialises in Development; the fixture owns it here + // the same way the before fixture owns MigrateAsync. The template's + // initialiser is also the reset: drop, recreate, seed. + using var scope = Host.Services.CreateScope(); + var initialiser = scope.ServiceProvider.GetRequiredService(); + + await initialiser.InitialiseAsync(); + await initialiser.SeedAsync(); + } + + public async Task DisposeAsync() + { + await Host.DisposeAsync(); + } +} + +[Collection(CleanTemplateCollection.Name)] +public sealed class CleanTemplatePing : PingScenarios, IClassFixture +{ + private readonly CleanTemplateHostFixture _fixture; + + public CleanTemplatePing(CleanTemplateHostFixture fixture) + { + _fixture = fixture; + } + + protected override IAlbaHost Host => _fixture.Host; +} + +[Collection(CleanTemplateCollection.Name)] +public sealed class CleanTemplateCakes : CakeScenarios, IClassFixture +{ + private readonly CleanTemplateHostFixture _fixture; + + public CleanTemplateCakes(CleanTemplateHostFixture fixture) + { + _fixture = fixture; + } + + protected override IAlbaHost Host => _fixture.Host; + + protected override async Task InsertCakeDirectlyAsync(string name) + { + // The DbContext directly, outside MediatR: the handler's AnyAsync + // check never runs, so only the unique index stands in the way. + using var scope = Host.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Cakes.Add(new Cake + { + Name = name, + Description = "Direct insert", + Price = 1.00m, + PublishedAt = DateTimeOffset.UtcNow + }); + + await dbContext.SaveChangesAsync(); + } +} diff --git a/tests/LayerCake.ContractTests.CleanTemplate/LayerCake.ContractTests.CleanTemplate.csproj b/tests/LayerCake.ContractTests.CleanTemplate/LayerCake.ContractTests.CleanTemplate.csproj new file mode 100644 index 0000000..f19eaa1 --- /dev/null +++ b/tests/LayerCake.ContractTests.CleanTemplate/LayerCake.ContractTests.CleanTemplate.csproj @@ -0,0 +1,34 @@ + + + + false + LayerCake.ContractTests + + + + + + + + + + + + + + + + + + From 1bf5d8e49aecb7c064e526039b52f6f634e0d652 Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Sun, 6 Sep 2026 17:36:51 -0500 Subject: [PATCH 4/4] Experiment bookkeeping: template section in the file inventory, dated build-log entry File inventory gains a final section outside the scorecard (9 created / 8 edited for the same three endpoints, scaffold edits, snapshot, line counts); the build log records the template version and command, the package graph, why the generated template did not build, every idiom difference from the before twin, the suite result and why four scenarios are red, the negative checks, the hop trace, the two price-rule file lists, the live run, and the calls left to Erik. --- docs/build-log.md | 30 ++++++++++++++++++++++++++++++ docs/file-inventory.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/docs/build-log.md b/docs/build-log.md index 71af18b..f389695 100644 --- a/docs/build-log.md +++ b/docs/build-log.md @@ -66,3 +66,33 @@ Running record of decisions made during build sessions: anything a later session - **2026-09-05** Demo-readiness docs pass (branch `chore/demo-readiness-docs`); doc-only, no code, counts unchanged. The build is over, so CLAUDE.md gains a mode paragraph (no new slices, no bumps, sessions exist for dry-run support, fixes, and doc accuracy), the slice slate reads as built, the schedule fallbacks are marked as history, the file-inventory rule states the final counts once (before twin 2,061 / 1,705, after twin 703 / 581), `docs/critter-stack-audit.md` joins "Where detail lives", and the display-name open item is annotated as the only one left (Erik's call, or drop it). README: the features table gains a fourth row for the RabbitMQ extension under a heading a skimmer sees ("Three features and one message"), the inventory table gains the slice 004 row and its whole-twin counts move from 1,733 / 699 to 2,061 / 703, and the repo tour lists `tests/LayerCake.Slices.Tests` and the audit doc. Two manual checks nobody had run since slice 004: (1) CI on `main` after PR #13 merged is green, 1m23s (run 34000949666), so the Ubuntu runner's cold pull of `rabbitmq:4` costs nothing worth noting. (2) The demo page against the broker path: `src/frontend/index.html` opened from disk (`file://`) in the installed Chrome, driven through its own buttons by a Playwright script because the Claude-in-Chrome extension could not inject into any page (not even example.com). Both twins: reachable, BDAY10 valid, publish 201, order of 1x Chocolate Stout + 2x Classic Yellow with BDAY10 priced 82.00 / 8.20 / 73.80 on both, baker row highlighted within about 250 ms on both, zero page errors; the management API afterwards showed both `layercake-*-baker-tasks` queues at 1 consumer, 0 ready, 2 published, 2 acked. The compose volume now carries the walk's two "Demo Walk ..." cakes and two orders; `docker compose down -v` before a dry run restores the clean seed. The wire pane shows the before twin's absolute `Location` against the after twin's relative one, the divergence accepted 2026-08-23. Out of scope, noted for whoever next touches CI: the comment in `.github/workflows/ci.yml` still says only "No PostgreSQL service" although the suite now also starts RabbitMQ through Testcontainers. Deck checklist line: pre-pull `postgres:17` and `rabbitmq:4` on the demo machine (`dotnet test --no-build` runs 14 s warm, 19 s cold). - **2026-09-05** CI flake after slice 004, root-caused and fixed (on the PR #14 branch, alongside the docs pass). The run for the docs-only PR failed with `[Test Class Cleanup Failure (BeforeTwinCoupons)] System.ObjectDisposedException` while all 54 + 15 tests passed; the two green runs on `main` never showed it and six local runs were clean. A throwaway probe (boot the before twin against the compose broker with a pre-loaded queue, dispose it mid-drain, sixteen timings) reproduced three faults in the before twin's shutdown path, all in `NotifyBakerConsumer` and `RabbitMqConnection`. (1) **`StopAsync` runs twice, concurrently, on the same hosted service** under Alba: `WebApplicationFactory.DisposeAsync` stops a host whose own `Run()` is already stopping (both call stacks captured), so `_channel.DisposeAsync()` raced itself and the second call hit RabbitMQ.Client's disposed-object check. Reproduced locally once with the CI exception's exact type and message. (2) A delivery in flight at shutdown was cancelled through the host's stopping token mid-database-call and nacked without requeue: a graceful shutdown dropped a baker task. (3) The channel was disposed under a running handler, whose ack then landed on a closed connection (`AlreadyClosedException`). Fixes, in before-twin idioms: `StopAsync` is idempotent (one shared shutdown task behind a lock); shutdown is graceful in three steps (cancel the consumer, wait on the in-flight gate, close the channel) under a 5 s cap that falls back to abort with a warning, so nothing escapes the hosted service as an exception; the handler no longer takes the stopping token, so in-flight work finishes and acknowledges; `RabbitMqConnection.DisposeAsync` closes (bounded to 5 s) before disposing, because disposing an open connection took the client's abort path and stalled 25 s on its internal timeouts in the probe. Verified: probe 16/16 clean with every disposal under 80 ms; whole-solution `dotnet test` green six times in a row on the final code (fourteen earlier green runs on intermediate versions; one intermediate version without the cap showed a single 30 s stall ending in `TaskCanceledException`, which is what the cap is for). The probe was deleted, not committed. The after twin has no equivalent: Wolverine owns its own shutdown. Counts: before twin 2,142 raw / 1,776 non-blank (was 2,061 / 1,705; +81 / +71, two files edited, none created); after twin unchanged at 703 / 581; README and CLAUDE.md updated to match. - **2026-09-05** Consumer shutdown fix, second round (same PR #14 branch). CI rejected the first round with `[Test Class Cleanup Failure (BeforeTwinOrders)] System.Threading.Tasks.TaskCanceledException` and a 31 s contract suite, the same 30 s stall seen once locally. Reproduced on demand by running two contract suites concurrently (CI-grade slowness): three failures in one round, full stack captured. The exception came from RabbitMQ.Client's `Connection.CloseAsync` inside `RabbitMqConnection.DisposeAsync`, not from the consumer. Two facts from the client's 7.2.2 source and its shutdown design note: a graceful connection close floors any timeout below 30 s up to 30 s and throws on expiry, so the 5 s bound was silently 30 s; and the 25 s stalls measured earlier (5 s abort floor plus the 20 s continuation timeout on channel 0) are the documented signature of a connection whose main loop never finished. The original code never stalled because it aborted both channel and connection; the graceful channel `CloseAsync` added in round one is what left the loop unfinished under load, and it was the newest thing in the path. Final shape: `StopAsync` idempotent (the CI root cause), handler without the stopping token (no dropped task), consumer cancelled and the in-flight delivery awaited under the 5 s cap, then the channel is aborted by `DisposeAsync`, no graceful channel close; `RabbitMqConnection.DisposeAsync` calls `AbortAsync(5 s)` (bounded, never throws) before `DisposeAsync`. Evidence: eight stressed runs (two suites at once, four rounds) with zero cleanup failures, zero cap warnings, zero dropped deliveries, every run about 15 s wall clock against 43 s and 73 s for the rejected version under the same load; three unstressed whole-solution runs green. Counts: before twin 2,140 raw / 1,775 non-blank (was 2,061 / 1,705 before the fix; +79 / +70); after twin unchanged; inventory, README, CLAUDE.md brought to these numbers. Lesson recorded for the deck's Q&A, not for a slide: with RabbitMQ.Client 7.2 the bounded primitive at shutdown is abort, and the graceful close is only safe when you can afford its 30 s floor. +- **2026-09-06** Experiment, not a slice: slice 001 rebuilt on the Clean Architecture Solution Template (`dotnet new ca-sln`), on branch `experiment/before-clean-template` only (folder `experiments/before-clean-template/`, not in `LayerCake.slnx`, outside the scorecard; never pushed unless Erik says so). Purpose: slide 2.5 says the before twin is "the way the templates generate"; the skeptic review of 2026-09-04 answered that this template has no repository, projects to DTOs, and uses endpoint groups instead of controllers. Erik's call (2026-09-06): test the claim instead of rewording it, and bring back one honest number. Counts are in `docs/file-inventory.md` (final section); this entry is the narrative. Commits: `b339cb1` generate and isolate, `4fd8441` slice 001, `e986d3b` third host, plus the bookkeeping commit. + - **Template and command**: `Clean.Architecture.Solution.Template` 10.8.0 (installed 2026-09-06), `dotnet new ca-sln -n LayerCake.CleanTemplate -cf None -db postgresql -o experiments/before-clean-template`. Target `net10.0` (template `Directory.Build.props`), `global.json` asks for SDK 10.0.201. The `--help` offers `--client-framework Angular|React|None` and `--database postgresql|sqlite|sqlserver`; there is no Aspire switch. + - **Package graph as shipped** (template `Directory.Packages.props`, untouched): MediatR 14.1.0 with MediatR.Contracts 2.0.1 (post-license-change; the host logs `You do not have a valid license key for the Lucky Penny software MediatR. This is allowed for development and testing scenarios...` at startup), AutoMapper 16.1.1 (still used: `ProjectTo` on every read, nested `Profile` classes inside DTOs; also post-license-change), FluentValidation.DependencyInjectionExtensions 12.1.1, Microsoft.EntityFrameworkCore 10.0.5 + Npgsql.EntityFrameworkCore.PostgreSQL 10.0.1, Ardalis.GuardClauses 5.0.0, Scalar.AspNetCore 2.13.13 (OpenAPI UI), Microsoft.AspNetCore.Identity.EntityFrameworkCore 10.0.5, Aspire 13.2.0 (AppHost, ServiceDefaults, `Aspire.Npgsql.EntityFrameworkCore.PostgreSQL`), OpenTelemetry 1.15.x, NUnit 4.5.1 + Moq + Respawn for its own tests. The two twins pin MediatR 12.5.0 / AutoMapper 14.0.0 deliberately; the most-installed template now ships the commercial line. + - **As generated it does not build here.** Restore fails with NU1902/NU1903 (known advisories on the template's transitive graph: MessagePack 2.5.192, System.Security.Cryptography.Xml 10.0.1, OpenTelemetry 1.15.0, Microsoft.OpenApi 2.0.0) promoted to errors by the template's own `TreatWarningsAsErrors`. Smallest change: append `NU1902;NU1903` to the `WarningsNotAsErrors` list the template already uses for NU1608. Also `global.json` lowered to `10.0.100` because this machine has 10.0.111 and `latestFeature` does not roll across feature bands. Both recorded as scaffold edits; no package moved. Build then clean: 0 errors, 162 (audit) warnings, the template's own unit tests 8/8 and 6/6. + - **Isolation props were not needed**: the template ships its own `Directory.Build.props` and `Directory.Packages.props`, and MSBuild and NuGet take the nearest of each, so the repo root's central package management and analyzers never reach it. Verified by building in place before writing a line. + - **Aspire**: no template option turns it off. The Web host runs standalone (`dotnet run` or Alba) without the AppHost; `AddServiceDefaults()` and `EnrichNpgsqlDbContext` stay as generated. The AppHost and TestAppHost projects are never started by the experiment. + - **Where the template's idiom differs from the before twin's** (one line each; this is the list that decides what slide 2.5 says): + - No repository: handlers take `IApplicationDbContext` (a `DbSet` per aggregate plus `SaveChangesAsync`); the before twin's `ICakeRepository` + `CakeRepository` pair has no counterpart. + - Reads project straight to DTOs with AutoMapper `ProjectTo`; the before twin loads entities through the repository and maps in the handler. + - Minimal API endpoint groups (`IEndpointGroup`, static `Map`, route prefix `/api/{ClassName}` by default, overridable) instead of controllers. + - Command and handler share one file (`CreateTodoList.cs`); the before twin splits them. + - The AutoMapper profile is a private nested class inside the DTO; the before twin has `Common/Mappings/*Profile.cs`. + - Exceptions become ProblemDetails in an `IExceptionHandler` (`ProblemDetailsExceptionHandler`, `UseExceptionHandler`) instead of an MVC exception filter. + - Not-found is `Guard.Against.NotFound` (Ardalis.GuardClauses) throwing the library's `NotFoundException`; the before twin has its own exception type. + - The template's own uniqueness idiom is a validator rule with `IApplicationDbContext` injected (`BeUniqueTitle`, error code `Unique`), which yields 400; a 409 needs a handler check plus a new exception plus a handler mapping, as built here. + - Five MediatR behaviours ship and run on every request (logging pre-processor, unhandled-exception, authorization, validation, performance); the before twin has one. + - Database initialisation is `EnsureDeleted` + `EnsureCreated` + seed in Development, not migrations. + - Identity, bearer tokens, `RequireAuthorization()` on every sample group, Key Vault hook, Scalar, OpenTelemetry and health checks are all in the box; the twins have none of that. + - The base entity is `int`-keyed (`BaseEntity.Id`), with a comment inviting `BaseEntity`; the before twin's is generic from the start. + - **Decided alone (route back if wrong)**: (1) `Cake : BaseAuditableEntity` by making the template's base generic the way its comment suggests, keeping behaviour on non-generic roots so the two `SaveChanges` interceptors need no change; the ripple is two base files plus the two Todo samples. The alternative, a plain `Cake` with its own `Guid Id`, would have dropped audit stamping and skewed the comparison. (2) `PublishCakeCommand` returns `CakeDto` (the before twin's shape) rather than the template's return-the-id shape, because the contract wants the body on the 201. (3) `PublishedAt` from the template's registered `TimeProvider`. (4) Duplicate check in the handler throwing `DuplicateCakeNameException`, mapped to 409 in the template's exception handler, per the task's decision 5; the template's own answer would have been 400 from a validator rule. (5) `RoutePrefix => "/cakes"` on the group, no scaffold change, so the contract's routes hold. (6) A `/ping` group added (one file, outside the feature count) so the ping scenario runs on all three hosts. (7) The template host gets its own database name inside the shared container (`layercake_clean_template`): its initialiser drops the database, and PostgreSQL will not drop the `postgres` database it is connected to. (8) The third host lives in its own test project, below. + - **The third host is a separate project, `tests/LayerCake.ContractTests.CleanTemplate/`**, not a subclass inside the shared suite as the task sketched. Tried the sketch first: adding the template's Web project to `LayerCake.ContractTests.csproj` unified the test bin to MediatR 14.1.0 / AutoMapper 16.1.1, and every before-twin scenario (27 of them) then failed with `System.TypeLoadException: Could not load type 'Microsoft.Extensions.DependencyInjection.ServiceCollectionExtensions' from assembly 'MediatR, Version=14.0.0.0'` before running. One process, one MediatR. The separate project references the existing suite (for the unchanged `CakeScenarios`, `PingScenarios`, `ProblemDetailsAssertions`, `PostgresContainerFixture`) and the template's Web project with `Aliases="cleantemplate"` because all three hosts expose a public top-level `Program`. It is not in `LayerCake.slnx`, so `dotnet test` at the root is exactly what it was on `main`; the experiment runs with `dotnet test tests/LayerCake.ContractTests.CleanTemplate/LayerCake.ContractTests.CleanTemplate.csproj`. `tests/LayerCake.ContractTests/` has no diff on the branch. + - **Suite result on the template host, two runs, identical**: `ping_returns_200_pong` green; `publish_cake_returns_201_with_location_and_body` green; `browse_returns_seeded_cakes` green; `browse_includes_newly_published_cake` green; `get_cake_by_id_returns_200` green; `database_refuses_duplicate_cake_name_behind_the_api` green; `publish_cake_with_missing_name_returns_400` RED; `publish_cake_with_nonpositive_price_returns_400` RED; `publish_cake_with_duplicate_name_returns_409` RED; `get_missing_cake_returns_404` RED. 6/10. Twins re-run at the root both times: 54/54 contract + 15/15 unit, undisturbed. + - **Why the four are red, verified with a throwaway probe (deleted)**: every status code is right (400, 400, 409, 404). All four fail the content-type assertion because the template's `ProblemDetailsExceptionHandler` writes with `Response.WriteAsJsonAsync(problemDetails)`, which emits `application/json; charset=utf-8`, never `application/problem+json`. The 404 and 409 bodies carry their reasons (`"title":"The specified resource was not found."`, `"detail":"A cake named \"Chocolate Stout\" has already been published."`). The two 400 bodies do not: `{"type":"...15.5.1","title":"One or more validation errors occurred.","status":400}` and nothing else. The handler builds a `ValidationProblemDetails(ve.Errors)` but the switch expression types it as `ProblemDetails`, and `WriteAsJsonAsync` serialises the static type, so the `errors` dictionary is dropped on the floor. As shipped, the template's validation responses never tell the client which field failed. Per the task, no scenario or helper was touched and the four stay red. + - **What it would take**: one line in one scaffold file. Replacing the handler's status-and-write pair with `await Results.Problem(problemDetails).ExecuteAsync(httpContext);` makes it 10/10 (probed, then reverted; `Results.Problem` sets `application/problem+json` and serialises the runtime type, so the `errors` come back too). Erik's call whether the committed state stays at the honest 6/10 or takes the one-line scaffold fix and reports 10/10 with the fix listed; the branch is at 6/10. + - **Negative checks (each reverted)**: unique index removed from `CakeConfiguration` turns `database_refuses_duplicate_cake_name_behind_the_api` red (the direct insert no longer throws); 409 mapping removed from the exception handler turns `publish_cake_with_duplicate_name_returns_409` red with `Expected status code 409, but was 500` (the unmapped exception falls through the template's handler). + - **One `POST /cakes` traced by hand on the template, happy path, 13 hops**: (1) `Program.cs` → `WebApplicationExtensions.MapEndpoints` reflects the `IEndpointGroup` types → `EndpointRouteBuilderExtensions.MapPost` → `Cakes.PublishCake` (Web/Endpoints/Cakes.cs); (2) minimal API binds the JSON body to `PublishCakeCommand` (Application/Cakes/Commands/PublishCake/PublishCake.cs); (3) `ISender.Send`; (4) `RequestPreProcessorBehavior` runs `LoggingBehaviour` (Application/Common/Behaviours), which resolves `IUser` (Web/Services/CurrentUser.cs) and `IIdentityService` (Infrastructure/Identity/IdentityService.cs) to log the request; (5) `UnhandledExceptionBehaviour`; (6) `AuthorizationBehaviour` reflects the command for `[Authorize]`, finds none; (7) `ValidationBehaviour` runs `PublishCakeCommandValidator`; (8) `PerformanceBehaviour` starts its stopwatch; (9) `PublishCakeCommandHandler.Handle`; (10) `IApplicationDbContext.Cakes.AnyAsync` → `ApplicationDbContext` (Infrastructure/Data) → Npgsql SELECT; (11) `Cakes.Add` + `SaveChangesAsync` → `DispatchDomainEventsInterceptor` (nothing to publish, still runs) → `AuditableEntityInterceptor` (stamps Created/LastModified from `IUser` + `TimeProvider`) → Npgsql INSERT under Aspire's retrying execution strategy; (12) `IMapper.Map` through the nested `CakeDto.Mapping` profile; (13) back out through Performance, Validation, Authorization, Unhandled to `TypedResults.Created` → System.Text.Json → 201 with `Location`. Sad path adds one: the exception climbs the same five behaviours (Unhandled logs and rethrows) into `UseExceptionHandler` → `ProblemDetailsExceptionHandler.TryHandleAsync` (Web/Infrastructure). Files a reader opens to follow the happy path: 11 (Program.cs, WebApplicationExtensions.cs, EndpointRouteBuilderExtensions.cs, Cakes.cs, PublishCake.cs, LoggingBehaviour.cs, UnhandledExceptionBehaviour.cs, AuthorizationBehaviour.cs, ValidationBehaviour.cs, PerformanceBehaviour.cs, PublishCakeCommandValidator.cs) plus ApplicationDbContext.cs, the two interceptors and CakeDto.cs for the persistence and mapping hops: 15. + - **Slide 1.10's question on the template, "nothing under five dollars"**: one file, `src/Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs` (Application), `GreaterThan(0)` becomes `GreaterThanOrEqualTo(5)`. Same answer as the before twin's validator. Harder variant, the minimum depends on something only the handler knows: if it can be phrased as a query, the template's own idiom is a validator with `IApplicationDbContext` injected (its `CreateTodoListCommandValidator` does exactly that), still one file. If it depends on state the handler computes, the rule moves into the handler and the sad path needs a status: `src/Application/Cakes/Commands/PublishCake/PublishCake.cs` (Application, the check and the throw), a new `src/Application/Common/Exceptions/Exception.cs` (Application), `src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs` (Web, the mapping): three files across two projects. The before twin's same change also touches its repository interface and implementation when the handler needs a new query. + - **Also seen, not acted on**: EF Core 10 generates version-7 GUIDs client-side for the `Guid` key (`01a077ec-...-7728-...` in the probe); `TreatWarningsAsErrors` plus default NuGet audit means a fresh `dotnet new ca-sln` is red on day one until advisories move; the template's `ApiExceptionOperationTransformer` documents 400 on every operation "because every request passes through ValidationBehaviour", which is true and the body still lacks the field names. + - **For Erik**: (a) keep the branch at 6/10 as the honest number, or apply the one-line `Results.Problem` fix as a listed scaffold edit and report 10/10; (b) whether the four Guid-key ripple edits count as feature edits (17 touched) or a one-time scaffold cost (13 touched); the inventory lists both readings; (c) the slide-2.5 wording: "the way the templates generate" is defensible on layering and ceremony (five behaviours, DTO, validator, exception mapping, `DbSet` port, entity configuration, seed) and not on the specifics the skeptic named (no repository, endpoint groups, `ProjectTo`); the number 9 created / 8 edited for three endpoints can be said aloud; (d) the Ardalis variant: this one took about half an hour of agent wall-clock from install to bookkeeping (12:53 to 13:19 CDT), most of it reading the template, so a second template is an hour, not an afternoon; (e) push or keep local. + - **Live run, same day (Erik asked for it)**: the template host was run with `dotnet run` against a throwaway `postgres:17` container (port 55432, removed afterwards; the compose database was never touched), bound to its own port 5113 and to the before twin's 42010. Every route answered as the suite says: ping, browse (three seeds, ordered by name), 201 with `Location` and body, get by id, 404, two 400s, 409, `/openapi/v1.json` listing `/cakes`, `/cakes/{id}`, `/ping`, and the untouched sample `/api/TodoLists` still 401 without a token. Scalar renders the Cakes group. The repo's demo page in Before mode, driven with Playwright, listed the template's catalog, published a cake through its form, and showed the 409 detail and the 400 title. Two things seen and left alone: the page reports `Location: null` because the template's CORS policy does not expose that header to browsers (the raw response carries it; the twins add `WithExposedHeaders`), and the baker's board shows HTTP 404 because the template has no such endpoint. diff --git a/docs/file-inventory.md b/docs/file-inventory.md index 546b2d0..c771e5a 100644 --- a/docs/file-inventory.md +++ b/docs/file-inventory.md @@ -208,3 +208,45 @@ Shared contract suite (outside both counts): TwinHosts.cs edited (`RabbitMqConta Whole-twin line counts re-run 2026-09-05 after slice 004 (same method): before twin 2,061 raw / 1,705 non-blank (was 1,733 / 1,432; +328 / +273, the eight new files plus the six edits above). After twin 703 raw / 581 non-blank (was 695 / 574; +8 / +7: the RabbitMQ block in `Program.cs` replaced `UseDurableLocalQueues()` and its comment). Whole-twin line counts re-run 2026-09-05 after the before twin's consumer shutdown fix (same method; see `docs/build-log.md`): before twin 2,140 raw / 1,775 non-blank (was 2,061 / 1,705; +79 / +70). Two slice 004 files edited, none created: `src/before/LayerCake.WebApi/Messaging/NotifyBakerConsumer.cs` (idempotent `StopAsync`, in-flight gate, bounded consumer cancel, abort on dispose) and `src/before/LayerCake.Infrastructure/Messaging/RabbitMqConnection.cs` (bounded abort before dispose). After twin unchanged at 703 / 581. + +## Experiment: slice 001 on the Clean Architecture Solution Template (2026-09-06, branch experiment/before-clean-template, not part of the scorecard) + +`Clean.Architecture.Solution.Template` 10.8.0, generated with `dotnet new ca-sln -n LayerCake.CleanTemplate -cf None -db postgresql -o experiments/before-clean-template` (.NET 10 target), not in `LayerCake.slnx`, never under `src/`. Same conventions as slice 001 above: "created" counts `.cs` files created for the feature; pre-existing scaffold files edited to wire the feature in are listed separately; the test host belongs to no count. The template uses no migrations (`EnsureDeletedAsync` + `EnsureCreatedAsync` in its initialiser), so there is no generated code to exclude. Nothing above this section changes and the totals above stand. Full narrative in `docs/build-log.md` (2026-09-06). + +Template (9 files created): +- experiments/before-clean-template/src/Domain/Entities/Cake.cs +- experiments/before-clean-template/src/Application/Cakes/CakeDto.cs (nested AutoMapper profile, the template's idiom) +- experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCake.cs (command + handler in one file, the template's idiom) +- experiments/before-clean-template/src/Application/Cakes/Commands/PublishCake/PublishCakeCommandValidator.cs +- experiments/before-clean-template/src/Application/Cakes/Queries/BrowseCakes/BrowseCakes.cs (query + handler, `ProjectTo`) +- experiments/before-clean-template/src/Application/Cakes/Queries/GetCake/GetCake.cs (query + handler, `ProjectTo` + `Guard.Against.NotFound`) +- experiments/before-clean-template/src/Application/Common/Exceptions/DuplicateCakeNameException.cs +- experiments/before-clean-template/src/Infrastructure/Data/Configurations/CakeConfiguration.cs (unique index on Name) +- experiments/before-clean-template/src/Web/Endpoints/Cakes.cs (one `IEndpointGroup`, all three endpoints, `RoutePrefix` overridden to `/cakes`) + +Template, scaffold `.cs` files edited for the feature (8): +- experiments/before-clean-template/src/Domain/Common/BaseEntity.cs (the template's `int`-keyed base made generic, the path its own comment suggests; behaviour stays on the non-generic root so both `SaveChanges` interceptors are untouched) +- experiments/before-clean-template/src/Domain/Common/BaseAuditableEntity.cs (same split: audit fields on the non-generic root, `Id` on `BaseAuditableEntity`) +- experiments/before-clean-template/src/Domain/Entities/TodoList.cs (`: BaseAuditableEntity`, ripple of the above) +- experiments/before-clean-template/src/Domain/Entities/TodoItem.cs (same ripple) +- experiments/before-clean-template/src/Application/Common/Interfaces/IApplicationDbContext.cs (`DbSet`) +- experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContext.cs (`DbSet`) +- experiments/before-clean-template/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs (the same three seed cakes as both twins) +- experiments/before-clean-template/src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs (409 mapping for the new exception) + +Side by side, the same three endpoints: before twin 19 created / 4 edited (23 touched); template 9 created / 8 edited (17 touched; 13 if the four Guid-key ripple edits are set aside as a one-time cost); after twin 5 created / 1 edited (6 touched). + +Outside the feature count (1): +- experiments/before-clean-template/src/Web/Endpoints/Ping.cs (the shared ping scenario; the twins' ping endpoints predate slice 001 and sit outside its count too) + +Scaffold edits the feature or this machine forced (outside the count, 2; neither is C#): +- experiments/before-clean-template/global.json (SDK floor lowered from 10.0.201 to 10.0.100: only the 10.0.1xx band is installed here and `rollForward: latestFeature` does not cross feature bands) +- experiments/before-clean-template/Directory.Build.props (`NU1902;NU1903` appended to the template's own `WarningsNotAsErrors` list: the generated package graph carries current NuGet advisories, and the template's `TreatWarningsAsErrors` turned 162 audit warnings into restore errors, so `dotnet new ca-sln` + `dotnet build` fails as generated on 2026-09-06) + +Not needed, checked: isolation props (the template ships its own `Directory.Build.props` and `Directory.Packages.props`, which stop the repo's chain), a provider swap (`--database postgresql` exists), `public partial class Program` (the SDK already emits a public `Program`), removing `RequireAuthorization()` (the new group simply never calls it). + +Test host (outside every count): `tests/LayerCake.ContractTests.CleanTemplate/` (csproj + `CleanTemplateHost.cs`: collection, fixture, `CleanTemplateCakes`, `CleanTemplatePing`), its own project and not in `LayerCake.slnx` because the template's MediatR 14.1.0 / AutoMapper 16.1.1 cannot share a test process with the before twin's 12.5.0 / 14.0.0 (see the build log). `tests/LayerCake.ContractTests/` is unchanged on the branch. + +Scaffold snapshot as generated, before any feature code (`.cs` per project, excluding bin/obj): src/AppHost 2, src/Application 33, src/Domain 12, src/Infrastructure 11, src/ServiceDefaults 1, src/Shared 1, src/Web 16 (src total 76); tests/Application.FunctionalTests 14, tests/Application.UnitTests 3, tests/Domain.UnitTests 1, tests/Infrastructure.IntegrationTests 1, tests/TestAppHost 1 (total 96). + +Line counts (this file's method applied to `experiments/before-clean-template/src`, all `.cs` excluding obj/bin, no migrations exist): as generated 2,340 raw / 1,882 non-blank in 76 files; with slice 001 and ping 2,635 / 2,116 in 86 files. The nine feature files alone are 236 raw / 185 non-blank; the eight edits add 39 raw / 33 non-blank; ping is 20 / 16.