Repository: jasontaylordev/CleanArchitecture Branch: main Commit: 40f7fe69c9f0 Files: 311 Total size: 488.1 KB Directory structure: gitextract_y908z1c3/ ├── .aspire/ │ └── settings.json ├── .azdo/ │ └── pipelines/ │ └── azure-dev.yml ├── .devcontainer/ │ ├── README.md │ └── devcontainer.json ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ └── workflows/ │ ├── azure-dev.yml │ ├── build.yml │ ├── codeql.yml │ ├── release.yml │ └── test-templates.yml ├── .gitignore ├── .template.config/ │ ├── dotnetcli.host.json │ ├── ide.host.json │ └── template.json ├── CODE_OF_CONDUCT.md ├── CleanArchitecture.nuspec ├── CleanArchitecture.slnx ├── Directory.Build.props ├── Directory.Packages.props ├── LICENSE ├── README-template.md ├── README.md ├── azure.yaml ├── build/ │ ├── build.ps1 │ ├── repack.ps1 │ └── test.ps1 ├── global.json ├── infra/ │ ├── README.md │ ├── abbreviations.json │ ├── core/ │ │ ├── ai/ │ │ │ ├── cognitiveservices.bicep │ │ │ ├── hub-dependencies.bicep │ │ │ ├── hub.bicep │ │ │ └── project.bicep │ │ ├── config/ │ │ │ └── configstore.bicep │ │ ├── database/ │ │ │ ├── cosmos/ │ │ │ │ ├── cosmos-account.bicep │ │ │ │ ├── mongo/ │ │ │ │ │ ├── cosmos-mongo-account.bicep │ │ │ │ │ └── cosmos-mongo-db.bicep │ │ │ │ └── sql/ │ │ │ │ ├── cosmos-sql-account.bicep │ │ │ │ ├── cosmos-sql-db.bicep │ │ │ │ ├── cosmos-sql-role-assign.bicep │ │ │ │ └── cosmos-sql-role-def.bicep │ │ │ ├── mysql/ │ │ │ │ └── flexibleserver.bicep │ │ │ ├── postgresql/ │ │ │ │ └── flexibleserver.bicep │ │ │ └── sqlserver/ │ │ │ └── sqlserver.bicep │ │ ├── gateway/ │ │ │ └── apim.bicep │ │ ├── host/ │ │ │ ├── ai-environment.bicep │ │ │ ├── aks-agent-pool.bicep │ │ │ ├── aks-managed-cluster.bicep │ │ │ ├── aks.bicep │ │ │ ├── appservice-appsettings.bicep │ │ │ ├── appservice.bicep │ │ │ ├── appserviceplan.bicep │ │ │ ├── container-app-upsert.bicep │ │ │ ├── container-app.bicep │ │ │ ├── container-apps-environment.bicep │ │ │ ├── container-apps.bicep │ │ │ ├── container-registry.bicep │ │ │ ├── functions.bicep │ │ │ └── staticwebapp.bicep │ │ ├── monitor/ │ │ │ ├── applicationinsights-dashboard.bicep │ │ │ ├── applicationinsights.bicep │ │ │ ├── loganalytics.bicep │ │ │ └── monitoring.bicep │ │ ├── networking/ │ │ │ ├── cdn-endpoint.bicep │ │ │ ├── cdn-profile.bicep │ │ │ └── cdn.bicep │ │ ├── search/ │ │ │ └── search-services.bicep │ │ ├── security/ │ │ │ ├── aks-managed-cluster-access.bicep │ │ │ ├── configstore-access.bicep │ │ │ ├── keyvault-access.bicep │ │ │ ├── keyvault-secret.bicep │ │ │ ├── keyvault.bicep │ │ │ ├── registry-access.bicep │ │ │ └── role.bicep │ │ ├── storage/ │ │ │ └── storage-account.bicep │ │ └── testing/ │ │ └── loadtesting.bicep │ ├── main.bicep │ ├── main.parameters.json │ └── services/ │ └── web.bicep ├── renovate.json ├── src/ │ ├── AppHost/ │ │ ├── AppHost.csproj │ │ ├── Program.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── Application/ │ │ ├── Application.csproj │ │ ├── Common/ │ │ │ ├── Behaviours/ │ │ │ │ ├── AuthorizationBehaviour.cs │ │ │ │ ├── LoggingBehaviour.cs │ │ │ │ ├── PerformanceBehaviour.cs │ │ │ │ ├── UnhandledExceptionBehaviour.cs │ │ │ │ └── ValidationBehaviour.cs │ │ │ ├── Exceptions/ │ │ │ │ ├── ForbiddenAccessException.cs │ │ │ │ └── ValidationException.cs │ │ │ ├── Interfaces/ │ │ │ │ ├── IApplicationDbContext.cs │ │ │ │ ├── IIdentityService.cs │ │ │ │ └── IUser.cs │ │ │ ├── Mappings/ │ │ │ │ └── MappingExtensions.cs │ │ │ ├── Models/ │ │ │ │ ├── LookupDto.cs │ │ │ │ ├── PaginatedList.cs │ │ │ │ └── Result.cs │ │ │ └── Security/ │ │ │ └── AuthorizeAttribute.cs │ │ ├── DependencyInjection.cs │ │ ├── GlobalUsings.cs │ │ ├── TodoItems/ │ │ │ ├── Commands/ │ │ │ │ ├── CreateTodoItem/ │ │ │ │ │ ├── CreateTodoItem.cs │ │ │ │ │ └── CreateTodoItemCommandValidator.cs │ │ │ │ ├── DeleteTodoItem/ │ │ │ │ │ └── DeleteTodoItem.cs │ │ │ │ ├── UpdateTodoItem/ │ │ │ │ │ ├── UpdateTodoItem.cs │ │ │ │ │ └── UpdateTodoItemCommandValidator.cs │ │ │ │ └── UpdateTodoItemDetail/ │ │ │ │ └── UpdateTodoItemDetail.cs │ │ │ ├── EventHandlers/ │ │ │ │ ├── LogTodoItemCompleted.cs │ │ │ │ └── LogTodoItemCreated.cs │ │ │ └── Queries/ │ │ │ └── GetTodoItemsWithPagination/ │ │ │ ├── GetTodoItemsWithPagination.cs │ │ │ ├── GetTodoItemsWithPaginationQueryValidator.cs │ │ │ └── TodoItemBriefDto.cs │ │ ├── TodoLists/ │ │ │ ├── Commands/ │ │ │ │ ├── CreateTodoList/ │ │ │ │ │ ├── CreateTodoList.cs │ │ │ │ │ └── CreateTodoListCommandValidator.cs │ │ │ │ ├── DeleteTodoList/ │ │ │ │ │ └── DeleteTodoList.cs │ │ │ │ ├── PurgeTodoLists/ │ │ │ │ │ └── PurgeTodoLists.cs │ │ │ │ └── UpdateTodoList/ │ │ │ │ ├── UpdateTodoList.cs │ │ │ │ └── UpdateTodoListCommandValidator.cs │ │ │ └── Queries/ │ │ │ └── GetTodos/ │ │ │ ├── GetTodos.cs │ │ │ ├── TodoItemDto.cs │ │ │ ├── TodoListDto.cs │ │ │ └── TodosVm.cs │ │ └── WeatherForecasts/ │ │ └── Queries/ │ │ └── GetWeatherForecasts/ │ │ ├── GetWeatherForecastsQuery.cs │ │ └── WeatherForecast.cs │ ├── Domain/ │ │ ├── Common/ │ │ │ ├── BaseAuditableEntity.cs │ │ │ ├── BaseEntity.cs │ │ │ ├── BaseEvent.cs │ │ │ └── ValueObject.cs │ │ ├── Constants/ │ │ │ ├── Policies.cs │ │ │ └── Roles.cs │ │ ├── Domain.csproj │ │ ├── Entities/ │ │ │ ├── TodoItem.cs │ │ │ └── TodoList.cs │ │ ├── Enums/ │ │ │ └── PriorityLevel.cs │ │ ├── Events/ │ │ │ ├── TodoItemCompletedEvent.cs │ │ │ ├── TodoItemCreatedEvent.cs │ │ │ └── TodoItemDeletedEvent.cs │ │ ├── Exceptions/ │ │ │ └── UnsupportedColourException.cs │ │ ├── GlobalUsings.cs │ │ └── ValueObjects/ │ │ └── Colour.cs │ ├── Infrastructure/ │ │ ├── Data/ │ │ │ ├── ApplicationDbContext.cs │ │ │ ├── ApplicationDbContextInitialiser.cs │ │ │ ├── Configurations/ │ │ │ │ ├── TodoItemConfiguration.cs │ │ │ │ └── TodoListConfiguration.cs │ │ │ └── Interceptors/ │ │ │ ├── AuditableEntityInterceptor.cs │ │ │ └── DispatchDomainEventsInterceptor.cs │ │ ├── DependencyInjection.cs │ │ ├── GlobalUsings.cs │ │ ├── Identity/ │ │ │ ├── ApplicationUser.cs │ │ │ ├── IdentityResultExtensions.cs │ │ │ └── IdentityService.cs │ │ └── Infrastructure.csproj │ ├── ServiceDefaults/ │ │ ├── Extensions.cs │ │ └── ServiceDefaults.csproj │ ├── Shared/ │ │ ├── Services.cs │ │ └── Shared.csproj │ └── Web/ │ ├── ClientApp/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── angular.json │ │ ├── default.conf.template │ │ ├── karma.conf.js │ │ ├── nswag.json │ │ ├── package.json │ │ ├── proxy.conf.js │ │ ├── src/ │ │ │ ├── api-authorization/ │ │ │ │ ├── auth.guard.ts │ │ │ │ ├── auth.service.ts │ │ │ │ ├── authorize.interceptor.spec.ts │ │ │ │ ├── authorize.interceptor.ts │ │ │ │ ├── login/ │ │ │ │ │ ├── login.component.html │ │ │ │ │ └── login.component.ts │ │ │ │ └── register/ │ │ │ │ ├── register.component.html │ │ │ │ └── register.component.ts │ │ │ ├── app/ │ │ │ │ ├── app.component.html │ │ │ │ ├── app.component.ts │ │ │ │ ├── app.module.ts │ │ │ │ ├── app.server.module.ts │ │ │ │ ├── counter/ │ │ │ │ │ ├── counter.component.html │ │ │ │ │ ├── counter.component.spec.ts │ │ │ │ │ └── counter.component.ts │ │ │ │ ├── fetch-data/ │ │ │ │ │ ├── fetch-data.component.html │ │ │ │ │ └── fetch-data.component.ts │ │ │ │ ├── home/ │ │ │ │ │ ├── home.component.html │ │ │ │ │ └── home.component.ts │ │ │ │ ├── nav-menu/ │ │ │ │ │ ├── nav-menu.component.html │ │ │ │ │ ├── nav-menu.component.scss │ │ │ │ │ └── nav-menu.component.ts │ │ │ │ ├── theme-toggle/ │ │ │ │ │ ├── theme-toggle.component.html │ │ │ │ │ └── theme-toggle.component.ts │ │ │ │ ├── theme.service.ts │ │ │ │ ├── todo/ │ │ │ │ │ ├── todo.component.html │ │ │ │ │ ├── todo.component.scss │ │ │ │ │ └── todo.component.ts │ │ │ │ └── weather/ │ │ │ │ ├── weather.component.html │ │ │ │ └── weather.component.ts │ │ │ ├── assets/ │ │ │ │ └── .gitkeep │ │ │ ├── environments/ │ │ │ │ ├── environment.prod.ts │ │ │ │ └── environment.ts │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── polyfills.ts │ │ │ ├── styles.scss │ │ │ └── test.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ ├── ClientApp-React/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── aspnetcore-https.cjs │ │ ├── index.html │ │ ├── nswag.json │ │ ├── package.json │ │ ├── public/ │ │ │ └── manifest.webmanifest │ │ ├── src/ │ │ │ ├── App.jsx │ │ │ ├── AppRoutes.jsx │ │ │ ├── components/ │ │ │ │ ├── Counter.jsx │ │ │ │ ├── Home.jsx │ │ │ │ ├── Layout.jsx │ │ │ │ ├── NavMenu.jsx │ │ │ │ ├── ThemeContext.jsx │ │ │ │ ├── ThemeToggle.jsx │ │ │ │ ├── Todo.jsx │ │ │ │ ├── Weather.jsx │ │ │ │ └── api-authorization/ │ │ │ │ ├── AuthContext.jsx │ │ │ │ ├── LoginPage.jsx │ │ │ │ ├── ProtectedRoute.jsx │ │ │ │ └── RegisterPage.jsx │ │ │ ├── main.jsx │ │ │ ├── styles.scss │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── DependencyInjection.cs │ ├── Endpoints/ │ │ ├── TodoItems.cs │ │ ├── TodoLists.cs │ │ ├── Users.cs │ │ └── WeatherForecasts.cs │ ├── GlobalUsings.cs │ ├── Infrastructure/ │ │ ├── ApiExceptionOperationTransformer.cs │ │ ├── BearerSecuritySchemeTransformer.cs │ │ ├── EndpointRouteBuilderExtensions.cs │ │ ├── IEndpointGroup.cs │ │ ├── IdentityApiOperationTransformer.cs │ │ ├── MethodInfoExtensions.cs │ │ ├── ProblemDetailsExceptionHandler.cs │ │ └── WebApplicationExtensions.cs │ ├── Program.cs │ ├── Properties/ │ │ └── launchSettings.json │ ├── Services/ │ │ └── CurrentUser.cs │ ├── Web-webapi.http │ ├── Web.csproj │ ├── Web.http │ ├── appsettings.PostgreSQL.json │ ├── appsettings.SQLServer.json │ ├── appsettings.SQLite.json │ └── appsettings.json ├── templates/ │ └── ca-use-case/ │ ├── .template.config/ │ │ ├── dotnetcli.host.json │ │ └── template.json │ └── FeatureName/ │ ├── Commands/ │ │ └── CleanArchitectureUseCase/ │ │ └── CleanArchitectureUseCase.cs │ └── Queries/ │ └── CleanArchitectureUseCase/ │ └── CleanArchitectureUseCase.cs └── tests/ ├── Application.FunctionalTests/ │ ├── Application.FunctionalTests.csproj │ ├── FunctionalTestSetup.cs │ ├── GlobalUsings.cs │ ├── Infrastructure/ │ │ ├── DatabaseResetter.cs │ │ ├── TestApp.cs │ │ ├── TestBase.cs │ │ └── WebApiFactory.cs │ ├── TodoItems/ │ │ └── Commands/ │ │ ├── CreateTodoItemTests.cs │ │ ├── DeleteTodoItemTests.cs │ │ ├── UpdateTodoItemDetailTests.cs │ │ └── UpdateTodoItemTests.cs │ └── TodoLists/ │ ├── Commands/ │ │ ├── CreateTodoListTests.cs │ │ ├── DeleteTodoListTests.cs │ │ ├── PurgeTodoListsTests.cs │ │ └── UpdateTodoListTests.cs │ └── Queries/ │ └── GetTodosTests.cs ├── Application.UnitTests/ │ ├── Application.UnitTests.csproj │ └── Common/ │ ├── Behaviours/ │ │ └── RequestLoggerTests.cs │ ├── Exceptions/ │ │ └── ValidationExceptionTests.cs │ ├── Mappings/ │ │ └── MappingTests.cs │ └── Models/ │ └── PaginatedListTests.cs ├── Domain.UnitTests/ │ ├── Domain.UnitTests.csproj │ └── ValueObjects/ │ └── ColourTests.cs ├── Infrastructure.IntegrationTests/ │ ├── GlobalUsings.cs │ └── Infrastructure.IntegrationTests.csproj ├── TestAppHost/ │ ├── Program.cs │ └── TestAppHost.csproj └── Web.AcceptanceTests/ ├── AspireSetup.cs ├── Features/ │ ├── Counter.feature │ ├── Home.feature │ ├── Login.feature │ └── Weather.feature ├── GlobalUsings.cs ├── Pages/ │ ├── BasePage.cs │ ├── CounterPage.cs │ ├── HomePage.cs │ ├── LoginPage.cs │ └── WeatherPage.cs ├── PlaywrightSetup.cs ├── StepDefinitions/ │ ├── CounterStepDefinitions.cs │ ├── HomeStepDefinitions.cs │ ├── LoginStepDefinitions.cs │ └── WeatherStepDefinitions.cs └── Web.AcceptanceTests.csproj ================================================ FILE CONTENTS ================================================ ================================================ FILE: .aspire/settings.json ================================================ { "appHostPath": "../src/AppHost/AppHost.csproj" } ================================================ FILE: .azdo/pipelines/azure-dev.yml ================================================ # Run when commits are pushed to mainline branch (main or master) # Set this to the mainline branch you are using trigger: - main - master # Azure Pipelines workflow to deploy to Azure using azd # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd # See below for alternative task to install azd if you can't install above task in your organization pool: vmImage: ubuntu-latest steps: # setup-azd@1 needs to be manually installed in your organization # if you can't install it, you can use the below bash script to install azd # and remove this step - task: setup-azd@1 displayName: Install azd # If you can't install above task in your organization, you can comment it and uncomment below task to install azd # - task: Bash@3 # displayName: Install azd # inputs: # targetType: 'inline' # script: | # curl -fsSL https://aka.ms/install-azd.sh | bash - task: UseDotNet@2 displayName: Setup .NET inputs: useGlobalJson: true # azd delegate auth to az to use service connection with AzureCLI@2 - pwsh: | azd config set auth.useAzCliAuth "true" displayName: Configure AZD to Use AZ CLI Authentication. - task: AzureCLI@2 displayName: Provision Infrastructure inputs: azureSubscription: azconnection scriptType: bash scriptLocation: inlineScript keepAzSessionActive: true inlineScript: | azd provision --no-prompt env: AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) AZURE_ENV_NAME: $(AZURE_ENV_NAME) AZURE_LOCATION: $(AZURE_LOCATION) AZD_INITIAL_ENVIRONMENT_CONFIG: $(secrets.AZD_INITIAL_ENVIRONMENT_CONFIG) - task: AzureCLI@2 displayName: Deploy Application inputs: azureSubscription: azconnection scriptType: bash scriptLocation: inlineScript keepAzSessionActive: true inlineScript: | azd deploy --no-prompt env: AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) AZURE_ENV_NAME: $(AZURE_ENV_NAME) AZURE_LOCATION: $(AZURE_LOCATION) ================================================ FILE: .devcontainer/README.md ================================================ # 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. ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "Azure Developer CLI", "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": { }, "ghcr.io/azure/azure-dev/azd:latest": {} }, "customizations": { "vscode": { "extensions": [ "GitHub.vscode-github-actions", "ms-azuretools.azure-dev", "ms-azuretools.vscode-azurefunctions", "ms-azuretools.vscode-bicep", "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" } } ================================================ FILE: .editorconfig ================================================ 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 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: JasonTaylorDev # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] # patreon: # Replace with a single Patreon username # open_collective: # Replace with a single Open Collective username # ko_fi: # Replace with a single Ko-fi username # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry # liberapay: # Replace with a single Liberapay username # issuehunt: # Replace with a single IssueHunt username # otechie: # Replace with a single Otechie username # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report 🐛 about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Ask a question ❓ url: https://github.com/jasontaylordev/cleanarchitecture/discussions/new about: Ask a question or request support for using the template ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request ✨ about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/azure-dev.yml ================================================ name: Azure deployment on: workflow_dispatch: push: # Run when commits are pushed to mainline branch (main or master) # Set this to the mainline branch you are using branches: - main - master # GitHub Actions workflow to deploy to Azure using azd # To configure required secrets for connecting to Azure, simply run `azd pipeline config` # Set up permissions for deploying with secretless Azure federated credentials # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication permissions: id-token: write contents: read jobs: build: runs-on: ubuntu-latest env: AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} steps: - name: Checkout uses: actions/checkout@v6 - name: Install azd uses: Azure/setup-azd@v2 - name: Setup .NET uses: actions/setup-dotnet@v5 with: global-json-file: 'global.json' - name: Log in with Azure (Federated Credentials) run: | azd auth login ` --client-id "$Env:AZURE_CLIENT_ID" ` --federated-credential-provider "github" ` --tenant-id "$Env:AZURE_TENANT_ID" shell: pwsh - name: Provision Infrastructure run: azd provision --no-prompt env: AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} - name: Deploy Application run: azd deploy --no-prompt ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: pull_request: branches: [ main ] paths-ignore: - '.scripts/**' - .gitignore - CODE_OF_CONDUCT.md - LICENSE - README.md workflow_call: inputs: build-artifacts: type: boolean required: true default: false permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 name: Checkout code - name: Cache NuGet packages uses: actions/cache@v5 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- #if (!UseApiOnly) - name: Install Node & cache npm packages uses: actions/setup-node@v6 with: node-version: '24.x' cache: 'npm' cache-dependency-path: | src/Web/ClientApp/package-lock.json src/Web/ClientApp-React/package-lock.json #endif - name: Install .NET uses: actions/setup-dotnet@v5 - name: Restore solution run: dotnet restore - name: Build solution run: dotnet build --no-restore --configuration Release #if (!UseApiOnly) - name: Install Playwright browsers run: pwsh artifacts/bin/Web.AcceptanceTests/release/playwright.ps1 install --with-deps chromium #endif - name: Test solution run: dotnet test --no-build --configuration Release ================================================ FILE: .github/workflows/codeql.yml ================================================ name: CodeQL on: push: branches: [ main ] paths-ignore: - .gitignore - CODE_OF_CONDUCT.md - LICENSE - README.md pull_request: branches: [ main ] schedule: - cron: '00 0 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 - name: Autobuild uses: github/codeql-action/autobuild@v4 env: SkipNSwag: True npm_config_legacy_peer_deps: true - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: release: types: [published] permissions: contents: read jobs: publish: name: Publish to NuGet.org runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 name: Checkout - uses: nuget/setup-nuget@v2 name: Set up NuGet with: nuget-version: 'latest' - name: Install Mono run: sudo apt-get update && sudo apt-get install -y mono-complete - name: Extract version id: version run: echo "value=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - name: Update template version run: | jq --arg v "${{ steps.version.outputs.value }}" \ '.symbols.caPackageVersion.parameters.value = $v' \ .template.config/template.json > tmp.json && mv tmp.json .template.config/template.json - name: Update nuspec release notes env: RELEASE_NOTES: ${{ github.event.release.body }} run: | python3 - <<'EOF' import os, xml.etree.ElementTree as ET ET.register_namespace('', 'http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd') tree = ET.parse('CleanArchitecture.nuspec') root = tree.getroot() ns = {'n': 'http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd'} root.find('.//n:releaseNotes', ns).text = os.environ.get('RELEASE_NOTES', '') tree.write('CleanArchitecture.nuspec', xml_declaration=True, encoding='utf-8') EOF - name: Pack run: nuget pack CleanArchitecture.nuspec -NoDefaultExcludes -Version ${{ steps.version.outputs.value }} - name: Upload package artifact uses: actions/upload-artifact@v7 with: name: nuget-package path: '*.nupkg' - name: Publish run: nuget push *.nupkg -Source 'https://api.nuget.org/v3/index.json' -ApiKey ${{secrets.NUGET_API_KEY}} -SkipDuplicate ================================================ FILE: .github/workflows/test-templates.yml ================================================ name: Test Templates on: push: branches: [ main ] workflow_dispatch: permissions: contents: read jobs: test-template: name: ${{ matrix.client-framework }}-${{ matrix.database }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: client-framework: [angular, react, none] database: [sqlite, sqlserver, postgresql] steps: - uses: actions/checkout@v6 name: Checkout code - name: Install .NET uses: actions/setup-dotnet@v5 - name: Install Node & cache npm packages if: matrix.client-framework != 'none' uses: actions/setup-node@v6 with: node-version: '24.x' cache: 'npm' cache-dependency-path: | src/Web/ClientApp/package-lock.json src/Web/ClientApp-React/package-lock.json - name: Cache NuGet packages uses: actions/cache@v5 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ matrix.client-framework }}-${{ matrix.database }}-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- - name: Install template run: dotnet new install . - name: Generate solution run: | dotnet new ca-sln \ --client-framework ${{ matrix.client-framework }} \ --database ${{ matrix.database }} \ --name CleanArchitecture \ --output generated \ --no-update-check - name: Build solution working-directory: generated run: dotnet build --configuration Release - name: Build client app if: matrix.client-framework != 'none' working-directory: generated/src/Web/ClientApp run: | npm ci npm run build - name: Install Playwright browsers if: matrix.client-framework != 'none' working-directory: generated run: pwsh artifacts/bin/Web.AcceptanceTests/release/playwright.ps1 install --with-deps chromium - name: Test solution working-directory: generated run: dotnet test --no-build --configuration Release ================================================ FILE: .gitignore ================================================ ## 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 ================================================ FILE: .template.config/dotnetcli.host.json ================================================ { "symbolInfo": { "ClientFramework": { "longName": "client-framework", "shortName": "cf" }, "PipelineProvider": { "longName": "pipeline-provider", "shortName": "pp" }, "Database": { "longName": "database", "shortName": "db" } } } ================================================ FILE: .template.config/ide.host.json ================================================ { "$schema": "http://json.schemastore.org/vs-2017.3.host", "order": 0, "icon": "icon.png", "symbolInfo": [ { "id": "ClientFramework", "name": { "text": "Client Framework" }, "description": { "text": "Select the Client Framework type, or select None for Web API only." }, "isVisible": true }, { "id": "PipelineProvider", "name": { "text": "Pipeline Provider" }, "description": { "text": "Select the pipeline provider." }, "isVisible": true }, { "id": "Database", "name": { "text": "Database" }, "description": { "text": "Select the database type." }, "isVisible": true }, ] } ================================================ FILE: .template.config/template.json ================================================ { "$schema": "http://json.schemastore.org/template", "author": "JasonTaylorDev", "classifications": [ "Angular", "API", "Aspire", "Clean Architecture", "Cloud", "NUnit", "Playwright", "React", "Test", "Web", "Web API"], "name": "Clean Architecture Solution", "description": "A Clean Architecture Solution Template supporting Angular, React, and Web API-only apps built with ASP.NET Core and Aspire.", "identity": "Clean.Architecture.Solution.CSharp", "groupIdentity": "Clean.Architecture.Solution", "shortName": "ca-sln", "tags": { "language": "C#", "type": "project" }, "sourceName": "CleanArchitecture", "preferNameDirectory": true, "symbols": { "kestrelHttpPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the HTTP endpoint in launchSettings.json." }, "kestrelHttpPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 5000, "high": 5300 } }, "kestrelHttpPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "kestrelHttpPort", "fallbackVariableName": "kestrelHttpPortGenerated" }, "replaces": "5000" }, "kestrelHttpsPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the HTTPS endpoint in launchSettings.json." }, "kestrelHttpsPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 7000, "high": 7300 } }, "kestrelHttpsPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "kestrelHttpsPort", "fallbackVariableName": "kestrelHttpsPortGenerated" }, "replaces": "5001" }, "appHostHttpPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the HTTP endpoint in launchSettings.json of the AppHost project." }, "appHostHttpPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 15000, "high": 15300 } }, "appHostHttpPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "appHostHttpPort", "fallbackVariableName": "appHostHttpPortGenerated" }, "replaces": "15000" }, "appHostHttpsPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the HTTPS endpoint in launchSettings.json of the AppHost project." }, "appHostHttpsPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 17000, "high": 17300 } }, "appHostHttpsPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "appHostHttpsPort", "fallbackVariableName": "appHostHttpsPortGenerated" }, "replaces": "17000" }, "appHostOtlpHttpPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the OTLP HTTP endpoint in launchSettings.json of the AppHost project." }, "appHostOtlpHttpPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 19000, "high": 19300 } }, "appHostOtlpHttpPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "appHostOtlpHttpPort", "fallbackVariableName": "appHostOtlpHttpPortGenerated" }, "replaces": "19000" }, "appHostOtlpHttpsPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the OTLP HTTPS endpoint in launchSettings.json of the AppHost project." }, "appHostOtlpHttpsPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 21000, "high": 21300 } }, "appHostOtlpHttpsPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "appHostOtlpHttpsPort", "fallbackVariableName": "appHostOtlpHttpsPortGenerated" }, "replaces": "21000" }, "appHostResourceHttpPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the resource service HTTP endpoint in launchSettings.json of the AppHost project." }, "appHostResourceHttpPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 20000, "high": 20300 } }, "appHostResourceHttpPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "appHostResourceHttpPort", "fallbackVariableName": "appHostResourceHttpPortGenerated" }, "replaces": "20000" }, "appHostResourceHttpsPort": { "type": "parameter", "datatype": "integer", "description": "Port number to use for the resource service HTTPS endpoint in launchSettings.json of the AppHost project." }, "appHostResourceHttpsPortGenerated": { "type": "generated", "generator": "port", "parameters": { "low": 22000, "high": 22300 } }, "appHostResourceHttpsPortReplacer": { "type": "generated", "generator": "coalesce", "parameters": { "sourceVariableName": "appHostResourceHttpsPort", "fallbackVariableName": "appHostResourceHttpsPortGenerated" }, "replaces": "22000" }, "caPackageVersion": { "type": "generated", "generator": "constant", "replaces": "caPackageVersion", "parameters": { "value": "0.0.0" } }, "caRepositoryUrl": { "type": "generated", "generator": "constant", "replaces": "caRepositoryUrl", "parameters": { "value": "https://github.com/jasontaylordev/CleanArchitecture" } }, "caDocsUrl": { "type": "generated", "generator": "constant", "replaces": "caDocsUrl", "parameters": { "value": "https://cleanarchitecture.jasontaylor.dev" } }, "ClientFramework": { "type": "parameter", "datatype": "choice", "choices": [ { "choice": "Angular", "description": "Use Angular" }, { "choice": "React", "description": "Use React" }, { "choice": "None", "description": "Web API only" } ], "defaultValue": "Angular", "description": "The type of client framework to use" }, "UseAngular": { "type": "computed", "value": "(ClientFramework == \"Angular\")" }, "UseReact": { "type": "computed", "value": "(ClientFramework == \"React\")" }, "UseApiOnly": { "type": "computed", "value": "(ClientFramework == \"None\")" }, "Database": { "type": "parameter", "datatype": "choice", "choices": [ { "choice": "postgresql", "description": "PostgreSQL" }, { "choice": "sqlite", "description": "SQLite" }, { "choice": "sqlserver", "description": "SQL Server" } ], "defaultValue": "sqlite", "description": "The database type to use." }, "PipelineProvider": { "type": "parameter", "datatype": "choice", "choices": [ { "choice": "azdo", "description": "Azure Pipelines" }, { "choice": "github", "description": "GitHub Actions" } ], "defaultValue": "github", "description": "The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines)." }, "UseAzurePipelines": { "type": "computed", "value": "(PipelineProvider == \"azdo\")" }, "UseGithubActions": { "type": "computed", "value": "(PipelineProvider == \"github\")" }, "UsePostgreSQL": { "type": "computed", "value": "(Database == \"postgresql\")" }, "UseSqlite": { "type": "computed", "value": "(Database == \"sqlite\")" }, "UseSqlServer": { "type": "computed", "value": "(Database == \"sqlserver\")" } }, "sources": [ { "source": "./", "target": "./", "exclude": [ ".azure/**/*", ".template.config/**/*", "templates/**/*", "**/*.filelist", "**/*.user", "**/*.lock.json", "*.nuspec", "src/Web/appsettings.json", "src/Web/appsettings.PostgreSQL.json", "src/Web/appsettings.SQLite.json", "src/Web/appsettings.SQLServer.json", "tests/Application.FunctionalTests/PostgreSQLTestcontainersTestDatabase.cs", "tests/Application.FunctionalTests/PostgreSQLTestDatabase.cs", "tests/Application.FunctionalTests/SqliteTestDatabase.cs", "tests/Application.FunctionalTests/SqlTestcontainersTestDatabase.cs", "tests/Application.FunctionalTests/SqlTestDatabase.cs", "tests/Application.FunctionalTests/appsettings.json", "tests/Application.FunctionalTests/appsettings.PostgreSQL.json", "tests/Application.FunctionalTests/appsettings.SQLServer.json", ".azdo/**/*", ".github/**/*" ], "rename": { "README-template.md": "README.md" }, "modifiers": [ { "condition": "(!UseApiOnly)", "exclude": [ "src/Web/Infrastructure/BearerSecuritySchemeTransformer.cs" ] }, { "condition": "(UseAngular)", "exclude": [ "src/Web/ClientApp-React/**", "src/Web/Web-webapi.http" ] }, { "condition": "(UseReact)", "exclude": [ "src/Web/ClientApp/**", "src/Web/Web-webapi.http" ], "rename": { "ClientApp-React": "ClientApp" } }, { "condition": "(UseApiOnly)", "exclude": [ "src/Web/ClientApp/**", "src/Web/ClientApp-React/**", "src/Web/Pages/**", "src/Web/Web.http", "tests/Web.AcceptanceTests/**" ], "rename": { "Web-webapi.http": "Web.http" } }, { "condition": "(UseAzurePipelines)", "include": [ ".azdo/**/*" ] }, { "condition": "(UseGithubActions)", "include": [ ".github/**/*" ] }, { "condition": "(UsePostgreSQL)", "include": [ "src/Web/appsettings.PostgreSQL.json", "tests/Application.FunctionalTests/PostgreSQLTestcontainersTestDatabase.cs", "tests/Application.FunctionalTests/PostgreSQLTestDatabase.cs", "tests/Application.FunctionalTests/appsettings.PostgreSQL.json" ], "rename": { "src/Web/appsettings.PostgreSQL.json": "src/Web/appsettings.json", "tests/Application.FunctionalTests/appsettings.PostgreSQL.json": "tests/Application.FunctionalTests/appsettings.json" } }, { "condition": "(UseSqlServer)", "include": [ "src/Web/appsettings.SQLServer.json", "tests/Application.FunctionalTests/SqlTestcontainersTestDatabase.cs", "tests/Application.FunctionalTests/SqlTestDatabase.cs", "tests/Application.FunctionalTests/appsettings.SQLServer.json" ], "rename": { "src/Web/appsettings.SQLServer.json": "src/Web/appsettings.json", "tests/Application.FunctionalTests/appsettings.SQLServer.json": "tests/Application.FunctionalTests/appsettings.json" } }, { "condition": "(UseSqlite)", "include": [ "src/Web/appsettings.SQLite.json", "tests/Application.FunctionalTests/SqliteTestDatabase.cs" ], "rename": { "src/Web/appsettings.SQLite.json": "src/Web/appsettings.json" } } ] } ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behaviour that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behaviour by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behaviour and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behaviour. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviours that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported by contacting the project team at hello@jasontaylor.dev. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CleanArchitecture.nuspec ================================================ Clean.Architecture.Solution.Template 0.0.0 Clean Architecture Solution Template JasonTaylorDev A Clean Architecture Solution Template supporting Angular, React, and Web API-only apps built with ASP.NET Core and Aspire. A Clean Architecture Solution Template supporting Angular, React, and Web API-only apps built with ASP.NET Core and Aspire. https://cleanarchitecture.jasontaylor.dev MIT false clean-architecture project template csharp dotnet angular react webapi aspire icon.png README.md ================================================ FILE: CleanArchitecture.slnx ================================================ ================================================ FILE: Directory.Build.props ================================================  net10.0 true NU1608 $(MSBuildThisFileDirectory)artifacts enable enable ================================================ FILE: Directory.Packages.props ================================================ true ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 JasonTaylorDev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README-template.md ================================================ # CleanArchitecture The project was generated using the [Clean.Architecture.Solution.Template](caRepositoryUrl) version caPackageVersion. ## 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::caPackageVersion ``` ## 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](caDocsUrl). Here you can find additional guidance, request new features, report a bug, and discuss the template with other users. ================================================ FILE: README.md ================================================ # Clean Architecture Solution Template [![Build](https://github.com/jasontaylordev/CleanArchitecture/actions/workflows/build.yml/badge.svg)](https://github.com/jasontaylordev/CleanArchitecture/actions/workflows/build.yml) [![CodeQL](https://github.com/jasontaylordev/CleanArchitecture/actions/workflows/codeql.yml/badge.svg)](https://github.com/jasontaylordev/CleanArchitecture/actions/workflows/codeql.yml) [![Nuget](https://img.shields.io/nuget/v/Clean.Architecture.Solution.Template?label=NuGet)](https://www.nuget.org/packages/Clean.Architecture.Solution.Template) [![Nuget](https://img.shields.io/nuget/dt/Clean.Architecture.Solution.Template?label=Downloads)](https://www.nuget.org/packages/Clean.Architecture.Solution.Template) ![Twitter Follow](https://img.shields.io/twitter/follow/jasontaylordev?label=Follow&style=social) The goal of this template is to provide a straightforward and efficient approach to enterprise application development, leveraging the power of Clean Architecture and ASP.NET Core. Using this template, you can effortlessly create a new app with Angular, React, or Web API only, powered by ASP.NET Core and Aspire. Getting started is easy - simply install the **.NET template** (see below for full details). For full documentation, visit **[cleanarchitecture.jasontaylor.dev](https://cleanarchitecture.jasontaylor.dev)**. If you find this project useful, please give it a star. Thanks! ⭐ ## Getting Started The following prerequisites are required to build and run the solution: - [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (latest version) - [Node.js](https://nodejs.org/) (latest LTS, only required if you are using Angular or React) The easiest way to get started is to install the [.NET template](https://www.nuget.org/packages/Clean.Architecture.Solution.Template): ``` dotnet new install Clean.Architecture.Solution.Template ``` Once installed, create a new solution using the template. You can choose to use Angular, React, or create a Web API-only solution. Specify the client framework using the `-cf` or `--client-framework` option, and provide the output directory where your project will be created. Here are some examples: To create a Single-Page Application (SPA) with Angular and ASP.NET Core: ```bash dotnet new ca-sln --client-framework Angular --output YourProjectName ``` To create a SPA with React and ASP.NET Core: ```bash dotnet new ca-sln -cf React -o YourProjectName ``` To create a ASP.NET Core Web API-only solution: ```bash dotnet new ca-sln -cf None -o YourProjectName ``` Launch the app: ```bash cd src/AppHost dotnet run ``` To learn more, run the following command: ```bash dotnet new ca-sln --help ``` You can create use cases (commands or queries) by navigating to `./src/Application` and running `dotnet new ca-usecase`. Here are some examples: To create a new command: ```bash dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int ``` To create a query: ```bash dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm ``` To learn more, run the following command: ```bash dotnet new ca-usecase --help ``` ## Database The template supports [PostgreSQL](https://www.postgresql.org), [SQLite](https://www.sqlite.org/) (default), and [SQL Server](https://learn.microsoft.com/en-us/sql/sql-server/what-is-sql-server). Specify the database to use with the `--database` option: ```bash dotnet new ca-sln --database [postgresql|sqlite|sqlserver] ``` On application startup, the database is automatically **deleted**, **recreated**, and **seeded** using `ApplicationDbContextInitialiser`. This is a practical strategy for early development, avoiding the overhead of maintaining migrations while keeping the schema and sample data in sync with the domain model. This process includes: - Deleting the existing database - Recreating the schema from the current model - Seeding default roles, users, and data For production environments, consider using EF Core migrations or migration bundles during deployment. For more information, see [Database Initialisation Strategies for EF Core](https://jasontaylor.dev/ef-core-database-initialisation-strategies). ## Deploy This template is structured to follow the Azure Developer CLI (azd). You can learn more about `azd` in the [official documentation](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli). To get started: ```bash # Log in to Azure azd auth login # Provision and deploy to Azure azd up ``` To set up a CI/CD pipeline (GitHub Actions or Azure DevOps): ```bash azd pipeline config ``` ## API Documentation This template includes built-in API documentation using [ASP.NET Core OpenAPI](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview) and [Scalar](https://scalar.com/). Once the application is running, navigate to `/scalar` to explore the API using the Scalar UI. The OpenAPI specification is generated at build time and written to `wwwroot/openapi/v1.json`. ## Technologies * [ASP.NET Core 10](https://docs.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core) * [Aspire](https://aspire.dev) * [Entity Framework Core 10](https://docs.microsoft.com/en-us/ef/core/) * [Angular 21](https://angular.dev/) or [React 19](https://react.dev/) * [MediatR](https://github.com/jbogard/MediatR) * [AutoMapper](https://automapper.org/) * [FluentValidation](https://fluentvalidation.net/) * [NUnit](https://nunit.org/), [Shouldly](https://docs.shouldly.org/), [Moq](https://github.com/devlooped/moq) & [Respawn](https://github.com/jbogard/Respawn) * [Scalar](https://scalar.com/) ## Versions The main branch is now on .NET 10.0. The following previous versions are available: * [9.0](https://github.com/jasontaylordev/CleanArchitecture/tree/net9.0) * [8.0](https://github.com/jasontaylordev/CleanArchitecture/tree/net8.0) * [7.0](https://github.com/jasontaylordev/CleanArchitecture/tree/net7.0) * [6.0](https://github.com/jasontaylordev/CleanArchitecture/tree/net6.0) * [5.0](https://github.com/jasontaylordev/CleanArchitecture/tree/net5.0) * [3.1](https://github.com/jasontaylordev/CleanArchitecture/tree/netcore3.1) ## Learn More * [Clean Architecture Solution Template Documentation](https://cleanarchitecture.jasontaylor.dev) * [Clean Architecture with ASP.NET Core 3.0 (GOTO 2019)](https://youtu.be/dK4Yb6-LxAk) ## Support If you are having problems, please let me know by [raising a new issue](https://github.com/jasontaylordev/CleanArchitecture/issues/new/choose). ## License This project is licensed with the [MIT license](LICENSE). ================================================ FILE: azure.yaml ================================================ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json # Name of the application. name: clean-architecture-azd services: web: language: csharp project: ./src/Web host: appservice ================================================ FILE: build/build.ps1 ================================================ param ( [string]$Target = "Default", [string]$Configuration = "Release" ) $ErrorActionPreference = "Stop" $solution = "CleanArchitecture.slnx" $clientApps = @("./src/Web/ClientApp", "./src/Web/ClientApp-React") Write-Host "Building solution..." dotnet build $solution --configuration $Configuration if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } foreach ($clientApp in $clientApps) { if (Test-Path $clientApp) { Write-Host "Installing client dependencies ($clientApp)..." Push-Location $clientApp try { npm ci if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } npm run build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } finally { Pop-Location } } } Write-Host "Testing solution..." if ($Target -eq "Basic") { $testProjects = Get-ChildItem -Path "tests" -Filter "*.csproj" -Recurse | Where-Object { $_.Name -notlike "*AcceptanceTests*" } foreach ($project in $testProjects) { dotnet test $project.FullName --no-build --configuration $Configuration if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } } else { dotnet test $solution --no-build --configuration $Configuration exit $LASTEXITCODE } ================================================ FILE: build/repack.ps1 ================================================ $ErrorActionPreference = "Stop" $root = Split-Path $PSScriptRoot -Parent $name = "Clean.Architecture.Solution.Template.0.0.0.nupkg" $pkg = Join-Path $root "artifacts\$name" Set-Location $root dotnet new uninstall Clean.Architecture.Solution.Template 2>$null nuget pack "CleanArchitecture.nuspec" -NoDefaultExcludes -OutputDirectory "artifacts" dotnet new install $pkg --force Remove-Item $pkg -Force 2>$null ================================================ FILE: build/test.ps1 ================================================ param ( [string[]]$ClientFramework = @("angular", "react", "none"), [string[]]$Database = @("sqlite", "sqlserver", "postgresql") ) $outputPath = Join-Path (Split-Path $PSScriptRoot -Parent) "artifacts\template-tests" $results = @() function CreateAndTestProject { param ( [string]$clientFramework, [string]$database ) $name = "$clientFramework-$database" $projectPath = Join-Path $outputPath $name try { if (Test-Path $projectPath) { Write-Host "Removing existing directory: $name" Remove-Item -Recurse -Force $projectPath } Write-Host "Creating project: $name" $startTime = Get-Date dotnet new ca-sln --client-framework $clientFramework --database $database --name CleanArchitecture --output $projectPath --no-update-check if ($LASTEXITCODE -ne 0) { throw "dotnet new ca-sln failed for $name" } $exitCode = 0 Push-Location $projectPath try { Write-Host "Building: $name" dotnet build --configuration Release if ($LASTEXITCODE -ne 0) { throw "Build failed for $name" } if ($clientFramework -ne "none") { Write-Host "Building client app: $name" Push-Location "./src/Web/ClientApp" try { npm ci if ($LASTEXITCODE -ne 0) { throw "npm ci failed for $name" } npm run build if ($LASTEXITCODE -ne 0) { throw "npm build failed for $name" } } finally { Pop-Location } } if ($clientFramework -ne "none") { Write-Host "Installing Playwright browsers: $name" pwsh artifacts/bin/Web.AcceptanceTests/release/playwright.ps1 install --with-deps chromium if ($LASTEXITCODE -ne 0) { throw "Playwright install failed for $name" } } Write-Host "Testing: $name" dotnet test --no-build --configuration Release if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE } } finally { Pop-Location } $endTime = Get-Date $duration = $endTime - $startTime $script:results += [PSCustomObject]@{ ClientFramework = $clientFramework Database = $database ExitCode = $exitCode Status = if ($exitCode -eq 0) { "Success" } else { "Failure" } Duration = $duration.ToString("c") } } catch { Write-Host "An error occurred while processing: $name" Write-Host $_.Exception.Message $script:results += [PSCustomObject]@{ ClientFramework = $clientFramework Database = $database ExitCode = -1 Status = "Error" Duration = "00:00:00.0000000" } } } if (-not (Test-Path $outputPath)) { New-Item -ItemType Directory -Path $outputPath | Out-Null } foreach ($cf in $ClientFramework) { foreach ($db in $Database) { CreateAndTestProject -clientFramework $cf -database $db } } $results | Format-Table -Property ClientFramework, Database, Status, Duration -AutoSize if ($results | Where-Object { $_.Status -ne "Success" }) { exit 1 } ================================================ FILE: global.json ================================================ { "sdk": { "version": "10.0.201", "rollForward": "latestFeature" } } ================================================ FILE: infra/README.md ================================================ # Infrastructure (infra) This folder contains infrastructure-as-code and deployment templates used with the **Azure Developer CLI (azd)**. ## What this folder includes - Bicep files for provisioning Azure resources - Configuration for App Service, hosting, Key Vault, and databases - Deployment workflows used by `azd up` - Environment configuration for cloud deployment ## How to use it ### Prerequisites - Azure account - Azure Developer CLI installed (`azd`) ### Deploying with azd ```bash azd auth login azd up ``` ================================================ FILE: infra/abbreviations.json ================================================ { "analysisServicesServers": "as", "apiManagementService": "apim-", "appConfigurationStores": "appcs-", "appManagedEnvironments": "cae-", "appContainerApps": "ca-", "authorizationPolicyDefinitions": "policy-", "automationAutomationAccounts": "aa-", "blueprintBlueprints": "bp-", "blueprintBlueprintsArtifacts": "bpa-", "cacheRedis": "redis-", "cdnProfiles": "cdnp-", "cdnProfilesEndpoints": "cdne-", "cognitiveServicesAccounts": "cog-", "cognitiveServicesFormRecognizer": "cog-fr-", "cognitiveServicesTextAnalytics": "cog-ta-", "cognitiveServicesSpeech": "cog-sp-", "computeAvailabilitySets": "avail-", "computeCloudServices": "cld-", "computeDiskEncryptionSets": "des", "computeDisks": "disk", "computeDisksOs": "osdisk", "computeGalleries": "gal", "computeSnapshots": "snap-", "computeVirtualMachines": "vm", "computeVirtualMachineScaleSets": "vmss-", "containerInstanceContainerGroups": "ci", "containerRegistryRegistries": "cr", "containerServiceManagedClusters": "aks-", "databricksWorkspaces": "dbw-", "dataFactoryFactories": "adf-", "dataLakeAnalyticsAccounts": "dla", "dataLakeStoreAccounts": "dls", "dataMigrationServices": "dms-", "dBforMySQLServers": "mysql-", "devicesIotHubs": "iot-", "devicesProvisioningServices": "provs-", "devicesProvisioningServicesCertificates": "pcert-", "documentDBDatabaseAccounts": "cosmos-", "eventGridDomains": "evgd-", "eventGridDomainsTopics": "evgt-", "eventGridEventSubscriptions": "evgs-", "eventHubNamespaces": "evhns-", "eventHubNamespacesEventHubs": "evh-", "hdInsightClustersHadoop": "hadoop-", "hdInsightClustersHbase": "hbase-", "hdInsightClustersKafka": "kafka-", "hdInsightClustersMl": "mls-", "hdInsightClustersSpark": "spark-", "hdInsightClustersStorm": "storm-", "hybridComputeMachines": "arcs-", "insightsActionGroups": "ag-", "insightsComponents": "appi-", "keyVaultVaults": "kv-", "kubernetesConnectedClusters": "arck", "kustoClusters": "dec", "kustoClustersDatabases": "dedb", "loadTesting": "lt-", "logicIntegrationAccounts": "ia-", "logicWorkflows": "logic-", "machineLearningServicesWorkspaces": "mlw-", "managedIdentityUserAssignedIdentities": "id-", "managementManagementGroups": "mg-", "migrateAssessmentProjects": "migr-", "networkApplicationGateways": "agw-", "networkApplicationSecurityGroups": "asg-", "networkAzureFirewalls": "afw-", "networkBastionHosts": "bas-", "networkConnections": "con-", "networkDnsZones": "dnsz-", "networkExpressRouteCircuits": "erc-", "networkFirewallPolicies": "afwp-", "networkFirewallPoliciesWebApplication": "waf", "networkFirewallPoliciesRuleGroups": "wafrg", "networkFrontDoors": "fd-", "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", "networkLoadBalancersExternal": "lbe-", "networkLoadBalancersInternal": "lbi-", "networkLoadBalancersInboundNatRules": "rule-", "networkLocalNetworkGateways": "lgw-", "networkNatGateways": "ng-", "networkNetworkInterfaces": "nic-", "networkNetworkSecurityGroups": "nsg-", "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", "networkNetworkWatchers": "nw-", "networkPrivateDnsZones": "pdnsz-", "networkPrivateLinkServices": "pl-", "networkPublicIPAddresses": "pip-", "networkPublicIPPrefixes": "ippre-", "networkRouteFilters": "rf-", "networkRouteTables": "rt-", "networkRouteTablesRoutes": "udr-", "networkTrafficManagerProfiles": "traf-", "networkVirtualNetworkGateways": "vgw-", "networkVirtualNetworks": "vnet-", "networkVirtualNetworksSubnets": "snet-", "networkVirtualNetworksVirtualNetworkPeerings": "peer-", "networkVirtualWans": "vwan-", "networkVpnGateways": "vpng-", "networkVpnGatewaysVpnConnections": "vcn-", "networkVpnGatewaysVpnSites": "vst-", "notificationHubsNamespaces": "ntfns-", "notificationHubsNamespacesNotificationHubs": "ntf-", "operationalInsightsWorkspaces": "log-", "portalDashboards": "dash-", "postgreSQLServers": "psql-", "postgreSQLServersDatabases": "psqldb-", "powerBIDedicatedCapacities": "pbi-", "purviewAccounts": "pview-", "recoveryServicesVaults": "rsv-", "resourcesResourceGroups": "rg-", "searchSearchServices": "srch-", "serviceBusNamespaces": "sb-", "serviceBusNamespacesQueues": "sbq-", "serviceBusNamespacesTopics": "sbt-", "serviceEndPointPolicies": "se-", "serviceFabricClusters": "sf-", "signalRServiceSignalR": "sigr", "sqlManagedInstances": "sqlmi-", "sqlServers": "sql-", "sqlServersDataWarehouse": "sqldw-", "sqlServersDatabases": "sqldb-", "sqlServersDatabasesStretch": "sqlstrdb-", "storageStorageAccounts": "st", "storageStorageAccountsVm": "stvm", "storSimpleManagers": "ssimp", "streamAnalyticsCluster": "asa-", "synapseWorkspaces": "syn", "synapseWorkspacesAnalyticsWorkspaces": "synw", "synapseWorkspacesSqlPoolsDedicated": "syndp", "synapseWorkspacesSqlPoolsSpark": "synsp", "timeSeriesInsightsEnvironments": "tsi-", "webServerFarms": "plan-", "webSitesAppService": "app-", "webSitesAppServiceEnvironment": "ase-", "webSitesFunctions": "func-", "webStaticSites": "stapp-" } ================================================ FILE: infra/core/ai/cognitiveservices.bicep ================================================ metadata description = 'Creates an Azure Cognitive Services instance.' param name string param location string = resourceGroup().location param tags object = {} @description('The custom subdomain name used to access the API. Defaults to the value of the name parameter.') param customSubDomainName string = name param disableLocalAuth bool = false param deployments array = [] param kind string = 'OpenAI' @allowed([ 'Enabled', 'Disabled' ]) param publicNetworkAccess string = 'Enabled' param sku object = { name: 'S0' } param allowedIpRules array = [] param networkAcls object = empty(allowedIpRules) ? { defaultAction: 'Allow' } : { ipRules: allowedIpRules defaultAction: 'Deny' } resource account 'Microsoft.CognitiveServices/accounts@2023-05-01' = { name: name location: location tags: tags kind: kind properties: { customSubDomainName: customSubDomainName publicNetworkAccess: publicNetworkAccess networkAcls: networkAcls disableLocalAuth: disableLocalAuth } sku: sku } @batchSize(1) resource deployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = [for deployment in deployments: { parent: account name: deployment.name properties: { model: deployment.model raiPolicyName: contains(deployment, 'raiPolicyName') ? deployment.raiPolicyName : null } sku: contains(deployment, 'sku') ? deployment.sku : { name: 'Standard' capacity: 20 } }] output endpoint string = account.properties.endpoint output endpoints object = account.properties.endpoints output id string = account.id output name string = account.name ================================================ FILE: infra/core/ai/hub-dependencies.bicep ================================================ param location string = resourceGroup().location param tags object = {} @description('Name of the key vault') param keyVaultName string @description('Name of the storage account') param storageAccountName string @description('Name of the OpenAI cognitive services') param openAiName string @description('Array of OpenAI model deployments') param openAiModelDeployments array = [] @description('Name of the Log Analytics workspace') param logAnalyticsName string = '' @description('Name of the Application Insights instance') param applicationInsightsName string = '' @description('Name of the container registry') param containerRegistryName string = '' @description('Name of the Azure Cognitive Search service') param searchServiceName string = '' module keyVault '../security/keyvault.bicep' = { name: 'keyvault' params: { location: location tags: tags name: keyVaultName } } module storageAccount '../storage/storage-account.bicep' = { name: 'storageAccount' params: { location: location tags: tags name: storageAccountName containers: [ { name: 'default' } ] files: [ { name: 'default' } ] queues: [ { name: 'default' } ] tables: [ { name: 'default' } ] corsRules: [ { allowedOrigins: [ 'https://mlworkspace.azure.ai' 'https://ml.azure.com' 'https://*.ml.azure.com' 'https://ai.azure.com' 'https://*.ai.azure.com' 'https://mlworkspacecanary.azure.ai' 'https://mlworkspace.azureml-test.net' ] allowedMethods: [ 'GET' 'HEAD' 'POST' 'PUT' 'DELETE' 'OPTIONS' 'PATCH' ] maxAgeInSeconds: 1800 exposedHeaders: [ '*' ] allowedHeaders: [ '*' ] } ] deleteRetentionPolicy: { allowPermanentDelete: false enabled: false } shareDeleteRetentionPolicy: { enabled: true days: 7 } } } module logAnalytics '../monitor/loganalytics.bicep' = if (!empty(logAnalyticsName)) { name: 'logAnalytics' params: { location: location tags: tags name: logAnalyticsName } } module applicationInsights '../monitor/applicationinsights.bicep' = if (!empty(applicationInsightsName) && !empty(logAnalyticsName)) { name: 'applicationInsights' params: { location: location tags: tags name: applicationInsightsName logAnalyticsWorkspaceId: !empty(logAnalyticsName) ? logAnalytics.outputs.id : '' } } module containerRegistry '../host/container-registry.bicep' = if (!empty(containerRegistryName)) { name: 'containerRegistry' params: { location: location tags: tags name: containerRegistryName } } module cognitiveServices '../ai/cognitiveservices.bicep' = { name: 'cognitiveServices' params: { location: location tags: tags name: openAiName kind: 'AIServices' deployments: openAiModelDeployments } } module searchService '../search/search-services.bicep' = if (!empty(searchServiceName)) { name: 'searchService' params: { location: location tags: tags name: searchServiceName } } output keyVaultId string = keyVault.outputs.id output keyVaultName string = keyVault.outputs.name output keyVaultEndpoint string = keyVault.outputs.endpoint output storageAccountId string = storageAccount.outputs.id output storageAccountName string = storageAccount.outputs.name output containerRegistryId string = !empty(containerRegistryName) ? containerRegistry.outputs.id : '' output containerRegistryName string = !empty(containerRegistryName) ? containerRegistry.outputs.name : '' output containerRegistryEndpoint string = !empty(containerRegistryName) ? containerRegistry.outputs.loginServer : '' output applicationInsightsId string = !empty(applicationInsightsName) ? applicationInsights.outputs.id : '' output applicationInsightsName string = !empty(applicationInsightsName) ? applicationInsights.outputs.name : '' output logAnalyticsWorkspaceId string = !empty(logAnalyticsName) ? logAnalytics.outputs.id : '' output logAnalyticsWorkspaceName string = !empty(logAnalyticsName) ? logAnalytics.outputs.name : '' output openAiId string = cognitiveServices.outputs.id output openAiName string = cognitiveServices.outputs.name output openAiEndpoint string = cognitiveServices.outputs.endpoints['OpenAI Language Model Instance API'] output searchServiceId string = !empty(searchServiceName) ? searchService.outputs.id : '' output searchServiceName string = !empty(searchServiceName) ? searchService.outputs.name : '' output searchServiceEndpoint string = !empty(searchServiceName) ? searchService.outputs.endpoint : '' ================================================ FILE: infra/core/ai/hub.bicep ================================================ @description('The AI Studio Hub Resource name') param name string @description('The display name of the AI Studio Hub Resource') param displayName string = name @description('The storage account ID to use for the AI Studio Hub Resource') param storageAccountId string @description('The key vault ID to use for the AI Studio Hub Resource') param keyVaultId string @description('The application insights ID to use for the AI Studio Hub Resource') param applicationInsightsId string = '' @description('The container registry ID to use for the AI Studio Hub Resource') param containerRegistryId string = '' @description('The OpenAI Cognitive Services account name to use for the AI Studio Hub Resource') param openAiName string @description('The OpenAI Cognitive Services account connection name to use for the AI Studio Hub Resource') param openAiConnectionName string @description('The Azure Cognitive Search service name to use for the AI Studio Hub Resource') param aiSearchName string = '' @description('The Azure Cognitive Search service connection name to use for the AI Studio Hub Resource') param aiSearchConnectionName string @description('The SKU name to use for the AI Studio Hub Resource') param skuName string = 'Basic' @description('The SKU tier to use for the AI Studio Hub Resource') @allowed(['Basic', 'Free', 'Premium', 'Standard']) param skuTier string = 'Basic' @description('The public network access setting to use for the AI Studio Hub Resource') @allowed(['Enabled','Disabled']) param publicNetworkAccess string = 'Enabled' param location string = resourceGroup().location param tags object = {} resource hub 'Microsoft.MachineLearningServices/workspaces@2024-04-01' = { name: name location: location tags: tags sku: { name: skuName tier: skuTier } kind: 'Hub' identity: { type: 'SystemAssigned' } properties: { friendlyName: displayName storageAccount: storageAccountId keyVault: keyVaultId applicationInsights: !empty(applicationInsightsId) ? applicationInsightsId : null containerRegistry: !empty(containerRegistryId) ? containerRegistryId : null hbiWorkspace: false managedNetwork: { isolationMode: 'Disabled' } v1LegacyMode: false publicNetworkAccess: publicNetworkAccess } resource contentSafetyDefaultEndpoint 'endpoints' = { name: 'Azure.ContentSafety' properties: { name: 'Azure.ContentSafety' endpointType: 'Azure.ContentSafety' associatedResourceId: openAi.id } } resource openAiConnection 'connections' = { name: openAiConnectionName properties: { category: 'AzureOpenAI' authType: 'ApiKey' isSharedToAll: true target: openAi.properties.endpoints['OpenAI Language Model Instance API'] metadata: { ApiVersion: '2023-07-01-preview' ApiType: 'azure' ResourceId: openAi.id } credentials: { key: openAi.listKeys().key1 } } } resource searchConnection 'connections' = if (!empty(aiSearchName)) { name: aiSearchConnectionName properties: { category: 'CognitiveSearch' authType: 'ApiKey' isSharedToAll: true target: 'https://${search.name}.search.windows.net/' credentials: { key: !empty(aiSearchName) ? search.listAdminKeys().primaryKey : '' } } } } resource openAi 'Microsoft.CognitiveServices/accounts@2023-05-01' existing = { name: openAiName } resource search 'Microsoft.Search/searchServices@2021-04-01-preview' existing = if (!empty(aiSearchName)) { name: aiSearchName } output name string = hub.name output id string = hub.id output principalId string = hub.identity.principalId ================================================ FILE: infra/core/ai/project.bicep ================================================ @description('The AI Studio Hub Resource name') param name string @description('The display name of the AI Studio Hub Resource') param displayName string = name @description('The name of the AI Studio Hub Resource where this project should be created') param hubName string @description('The name of the key vault resource to grant access to the project') param keyVaultName string @description('The SKU name to use for the AI Studio Hub Resource') param skuName string = 'Basic' @description('The SKU tier to use for the AI Studio Hub Resource') @allowed(['Basic', 'Free', 'Premium', 'Standard']) param skuTier string = 'Basic' @description('The public network access setting to use for the AI Studio Hub Resource') @allowed(['Enabled','Disabled']) param publicNetworkAccess string = 'Enabled' param location string = resourceGroup().location param tags object = {} resource project 'Microsoft.MachineLearningServices/workspaces@2024-04-01' = { name: name location: location tags: tags sku: { name: skuName tier: skuTier } kind: 'Project' identity: { type: 'SystemAssigned' } properties: { friendlyName: displayName hbiWorkspace: false v1LegacyMode: false publicNetworkAccess: publicNetworkAccess hubResourceId: hub.id } } module keyVaultAccess '../security/keyvault-access.bicep' = { name: 'keyvault-access' params: { keyVaultName: keyVaultName principalId: project.identity.principalId } } module mlServiceRoleDataScientist '../security/role.bicep' = { name: 'ml-service-role-data-scientist' params: { principalId: project.identity.principalId roleDefinitionId: 'f6c7c914-8db3-469d-8ca1-694a8f32e121' principalType: 'ServicePrincipal' } } module mlServiceRoleSecretsReader '../security/role.bicep' = { name: 'ml-service-role-secrets-reader' params: { principalId: project.identity.principalId roleDefinitionId: 'ea01e6af-a1c1-4350-9563-ad00f8c72ec5' principalType: 'ServicePrincipal' } } resource hub 'Microsoft.MachineLearningServices/workspaces@2024-04-01' existing = { name: hubName } output id string = project.id output name string = project.name output principalId string = project.identity.principalId ================================================ FILE: infra/core/config/configstore.bicep ================================================ metadata description = 'Creates an Azure App Configuration store.' @description('The name for the Azure App Configuration store') param name string @description('The Azure region/location for the Azure App Configuration store') param location string = resourceGroup().location @description('Custom tags to apply to the Azure App Configuration store') param tags object = {} @description('Specifies the names of the key-value resources. The name is a combination of key and label with $ as delimiter. The label is optional.') param keyValueNames array = [] @description('Specifies the values of the key-value resources.') param keyValueValues array = [] @description('The principal ID to grant access to the Azure App Configuration store') param principalId string resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' = { name: name location: location sku: { name: 'standard' } tags: tags } resource configStoreKeyValue 'Microsoft.AppConfiguration/configurationStores/keyValues@2023-03-01' = [for (item, i) in keyValueNames: { parent: configStore name: item properties: { value: keyValueValues[i] tags: tags } }] module configStoreAccess '../security/configstore-access.bicep' = { name: 'app-configuration-access' params: { configStoreName: name principalId: principalId } dependsOn: [configStore] } output endpoint string = configStore.properties.endpoint ================================================ FILE: infra/core/database/cosmos/cosmos-account.bicep ================================================ metadata description = 'Creates an Azure Cosmos DB account.' param name string param location string = resourceGroup().location param tags object = {} param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' param keyVaultName string @allowed([ 'GlobalDocumentDB', 'MongoDB', 'Parse' ]) param kind string resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = { name: name kind: kind location: location tags: tags properties: { consistencyPolicy: { defaultConsistencyLevel: 'Session' } locations: [ { locationName: location failoverPriority: 0 isZoneRedundant: false } ] databaseAccountOfferType: 'Standard' enableAutomaticFailover: false enableMultipleWriteLocations: false apiProperties: (kind == 'MongoDB') ? { serverVersion: '4.2' } : {} capabilities: [ { name: 'EnableServerless' } ] minimalTlsVersion: 'Tls12' } } resource cosmosConnectionString 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: connectionStringKey properties: { value: cosmos.listConnectionStrings().connectionStrings[0].connectionString } } resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' existing = { name: keyVaultName } output connectionStringKey string = connectionStringKey output endpoint string = cosmos.properties.documentEndpoint output id string = cosmos.id output name string = cosmos.name ================================================ FILE: infra/core/database/cosmos/mongo/cosmos-mongo-account.bicep ================================================ metadata description = 'Creates an Azure Cosmos DB for MongoDB account.' param name string param location string = resourceGroup().location param tags object = {} param keyVaultName string param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' module cosmos '../../cosmos/cosmos-account.bicep' = { name: 'cosmos-account' params: { name: name location: location connectionStringKey: connectionStringKey keyVaultName: keyVaultName kind: 'MongoDB' tags: tags } } output connectionStringKey string = cosmos.outputs.connectionStringKey output endpoint string = cosmos.outputs.endpoint output id string = cosmos.outputs.id ================================================ FILE: infra/core/database/cosmos/mongo/cosmos-mongo-db.bicep ================================================ metadata description = 'Creates an Azure Cosmos DB for MongoDB account with a database.' param accountName string param databaseName string param location string = resourceGroup().location param tags object = {} param collections array = [] param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' param keyVaultName string module cosmos 'cosmos-mongo-account.bicep' = { name: 'cosmos-mongo-account' params: { name: accountName location: location keyVaultName: keyVaultName tags: tags connectionStringKey: connectionStringKey } } resource database 'Microsoft.DocumentDB/databaseAccounts/mongodbDatabases@2022-11-15' = { name: '${accountName}/${databaseName}' tags: tags properties: { resource: { id: databaseName } } resource list 'collections' = [for collection in collections: { name: collection.name properties: { resource: { id: collection.id shardKey: { _id: collection.shardKey } indexes: [ { key: { keys: [ collection.indexKey ] } } ] } } }] dependsOn: [ cosmos ] } output connectionStringKey string = connectionStringKey output databaseName string = databaseName output endpoint string = cosmos.outputs.endpoint ================================================ FILE: infra/core/database/cosmos/sql/cosmos-sql-account.bicep ================================================ metadata description = 'Creates an Azure Cosmos DB for NoSQL account.' param name string param location string = resourceGroup().location param tags object = {} param keyVaultName string module cosmos '../../cosmos/cosmos-account.bicep' = { name: 'cosmos-account' params: { name: name location: location tags: tags keyVaultName: keyVaultName kind: 'GlobalDocumentDB' } } output connectionStringKey string = cosmos.outputs.connectionStringKey output endpoint string = cosmos.outputs.endpoint output id string = cosmos.outputs.id output name string = cosmos.outputs.name ================================================ FILE: infra/core/database/cosmos/sql/cosmos-sql-db.bicep ================================================ metadata description = 'Creates an Azure Cosmos DB for NoSQL account with a database.' param accountName string param databaseName string param location string = resourceGroup().location param tags object = {} param containers array = [] param keyVaultName string param principalIds array = [] module cosmos 'cosmos-sql-account.bicep' = { name: 'cosmos-sql-account' params: { name: accountName location: location tags: tags keyVaultName: keyVaultName } } resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-11-15' = { name: '${accountName}/${databaseName}' properties: { resource: { id: databaseName } } resource list 'containers' = [for container in containers: { name: container.name properties: { resource: { id: container.id partitionKey: { paths: [ container.partitionKey ] } } options: {} } }] dependsOn: [ cosmos ] } module roleDefinition 'cosmos-sql-role-def.bicep' = { name: 'cosmos-sql-role-definition' params: { accountName: accountName } dependsOn: [ cosmos database ] } // We need batchSize(1) here because sql role assignments have to be done sequentially @batchSize(1) module userRole 'cosmos-sql-role-assign.bicep' = [for principalId in principalIds: if (!empty(principalId)) { name: 'cosmos-sql-user-role-${uniqueString(principalId)}' params: { accountName: accountName roleDefinitionId: roleDefinition.outputs.id principalId: principalId } dependsOn: [ cosmos database ] }] output accountId string = cosmos.outputs.id output accountName string = cosmos.outputs.name output connectionStringKey string = cosmos.outputs.connectionStringKey output databaseName string = databaseName output endpoint string = cosmos.outputs.endpoint output roleDefinitionId string = roleDefinition.outputs.id ================================================ FILE: infra/core/database/cosmos/sql/cosmos-sql-role-assign.bicep ================================================ metadata description = 'Creates a SQL role assignment under an Azure Cosmos DB account.' param accountName string param roleDefinitionId string param principalId string = '' resource role 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-11-15' = { parent: cosmos name: guid(roleDefinitionId, principalId, cosmos.id) properties: { principalId: principalId roleDefinitionId: roleDefinitionId scope: cosmos.id } } resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-11-15' existing = { name: accountName } ================================================ FILE: infra/core/database/cosmos/sql/cosmos-sql-role-def.bicep ================================================ metadata description = 'Creates a SQL role definition under an Azure Cosmos DB account.' param accountName string resource roleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2022-11-15' = { parent: cosmos name: guid(cosmos.id, accountName, 'sql-role') properties: { assignableScopes: [ cosmos.id ] permissions: [ { dataActions: [ 'Microsoft.DocumentDB/databaseAccounts/readMetadata' 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*' 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*' ] notDataActions: [] } ] roleName: 'Reader Writer' type: 'CustomRole' } } resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-11-15' existing = { name: accountName } output id string = roleDefinition.id ================================================ FILE: infra/core/database/mysql/flexibleserver.bicep ================================================ metadata description = 'Creates an Azure Database for MySQL - Flexible Server.' param name string param location string = resourceGroup().location param tags object = {} param sku object param storage object param administratorLogin string @secure() param administratorLoginPassword string param highAvailabilityMode string = 'Disabled' param databaseNames array = [] param allowAzureIPsFirewall bool = false param allowAllIPsFirewall bool = false param allowedSingleIPs array = [] // MySQL version param version string resource mysqlServer 'Microsoft.DBforMySQL/flexibleServers@2023-06-30' = { location: location tags: tags name: name sku: sku properties: { version: version administratorLogin: administratorLogin administratorLoginPassword: administratorLoginPassword storage: storage highAvailability: { mode: highAvailabilityMode } } resource database 'databases' = [for name in databaseNames: { name: name }] resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { name: 'allow-all-IPs' properties: { startIpAddress: '0.0.0.0' endIpAddress: '255.255.255.255' } } resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { name: 'allow-all-azure-internal-IPs' properties: { startIpAddress: '0.0.0.0' endIpAddress: '0.0.0.0' } } resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { name: 'allow-single-${replace(ip, '.', '')}' properties: { startIpAddress: ip endIpAddress: ip } }] } output MYSQL_DOMAIN_NAME string = mysqlServer.properties.fullyQualifiedDomainName ================================================ FILE: infra/core/database/postgresql/flexibleserver.bicep ================================================ metadata description = 'Creates an Azure Database for PostgreSQL - Flexible Server.' param name string param location string = resourceGroup().location param tags object = {} param sku object param storage object param appUserLogin string @secure() param appUserLoginPassword string param administratorLogin string @secure() param administratorLoginPassword string param databaseName string param allowAzureIPsFirewall bool = false param allowAllIPsFirewall bool = false param allowedSingleIPs array = [] param keyVaultName string param connectionStringKey string // PostgreSQL version param version string param utcNowString string = utcNow('yyyyMMddHHmm') // Latest official version 2022-12-01 does not have Bicep types available resource postgresServer 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = { location: location tags: tags name: name sku: sku properties: { version: version administratorLogin: administratorLogin administratorLoginPassword: administratorLoginPassword storage: storage highAvailability: { mode: 'Disabled' } } resource database 'databases' = { name: databaseName } resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { name: 'allow-all-IPs' properties: { startIpAddress: '0.0.0.0' endIpAddress: '255.255.255.255' } } resource firewall_azure 'firewallRules' = if (allowAzureIPsFirewall) { name: 'allow-all-azure-internal-IPs' properties: { startIpAddress: '0.0.0.0' endIpAddress: '0.0.0.0' } } resource firewall_single 'firewallRules' = [for ip in allowedSingleIPs: { name: 'allow-single-${replace(ip, '.', '')}' properties: { startIpAddress: ip endIpAddress: ip } }] } resource psqlDeploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { name: '${name}-deployment-script' location: location kind: 'AzureCLI' properties: { azCliVersion: '2.37.0' retentionInterval: 'PT1H' // Retain the script resource for 1 hour after it ends running timeout: 'PT5M' // Five minutes cleanupPreference: 'OnSuccess' forceUpdateTag: utcNowString environmentVariables: [ { name: 'APPUSERLOGIN' value: appUserLogin } { name: 'APPUSERPASSWORD' secureValue: appUserLoginPassword } { name: 'DBNAME' value: databaseName } { name: 'DBSERVER' value: name } { name: 'ADMINLOGIN' value: administratorLogin } { name: 'ADMINLOGINPASSWORD' secureValue: administratorLoginPassword } ] scriptContent: ''' apk add postgresql-client cat << EOF > create_user.sql CREATE ROLE "$APPUSERLOGIN" WITH LOGIN PASSWORD '$APPUSERPASSWORD'; GRANT ALL PRIVILEGES ON DATABASE $DBNAME TO "$APPUSERLOGIN"; EOF psql "host=$DBSERVER.postgres.database.azure.com user=$ADMINLOGIN dbname=$DBNAME port=5432 password=$ADMINLOGINPASSWORD sslmode=require" < create_user.sql ''' } dependsOn: [ postgresServer ] } resource administratorLoginPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: 'dbAdminPassword' properties: { value: administratorLoginPassword } } resource appUserLoginPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: 'dbAppUserPassword' properties: { value: appUserLoginPassword } } resource sqlAzureConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: connectionStringKey properties: { value: '${connectionString}; Password=${appUserLoginPassword}' } } resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' existing = { name: keyVaultName } var connectionString = 'Host=${postgresServer.properties.fullyQualifiedDomainName};Port=5432;Database=${databaseName};Username=${appUserLogin}' output connectionStringKey string = connectionStringKey ================================================ FILE: infra/core/database/sqlserver/sqlserver.bicep ================================================ metadata description = 'Creates an Azure SQL Server instance.' param name string param location string = resourceGroup().location param tags object = {} param logAnalyticsWorkspaceId string = '' param appUser string = 'appUser' param databaseName string param keyVaultName string param sqlAdmin string = 'sqlAdmin' param connectionStringKey string = 'AZURE-SQL-CONNECTION-STRING' @secure() param sqlAdminPassword string @secure() param appUserPassword string param utcNowString string = utcNow('yyyyMMddHHmm') resource sqlServer 'Microsoft.Sql/servers@2022-05-01-preview' = { name: name location: location tags: tags properties: { version: '12.0' minimalTlsVersion: '1.2' publicNetworkAccess: 'Enabled' administratorLogin: sqlAdmin administratorLoginPassword: sqlAdminPassword } resource firewall 'firewallRules' = { name: 'Azure Services' properties: { // Allow all clients // Note: range [0.0.0.0-0.0.0.0] means "allow all Azure-hosted clients only". // This is not sufficient, because we also want to allow direct access from developer machine, for debugging purposes. startIpAddress: '0.0.0.1' endIpAddress: '255.255.255.254' } } } resource sqlServerAuditingSettings 'Microsoft.Sql/servers/auditingSettings@2023-08-01-preview' = { parent: sqlServer name: 'default' properties: { state: 'Enabled' isAzureMonitorTargetEnabled: true } } resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { parent: sqlServer name: databaseName location: location } resource sqlDatabaseDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!(empty(logAnalyticsWorkspaceId))) { scope: sqlDatabase name: 'sqlDatabaseDiagnosticSettings' properties: { workspaceId: logAnalyticsWorkspaceId logs: [ { category: 'SQLInsights' enabled: true } { category: 'AutomaticTuning' enabled: true } { category: 'QueryStoreRuntimeStatistics' enabled: true } { category: 'QueryStoreWaitStatistics' enabled: true } { category: 'Errors' enabled: true } { category: 'DatabaseWaitStatistics' enabled: true } { category: 'Timeouts' enabled: true } { category: 'Blocks' enabled: true } { category: 'Deadlocks' enabled: true } ] metrics: [ { category: 'Basic' enabled: true } { category: 'InstanceAndAppAdvanced' enabled: true } { category: 'WorkloadManagement' enabled: true } ] } } resource sqlDeploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { name: '${name}-deployment-script' location: location kind: 'AzureCLI' properties: { azCliVersion: '2.37.0' retentionInterval: 'PT1H' // Retain the script resource for 1 hour after it ends running timeout: 'PT5M' // Five minutes cleanupPreference: 'OnSuccess' forceUpdateTag: utcNowString environmentVariables: [ { name: 'APPUSERNAME' value: appUser } { name: 'APPUSERPASSWORD' secureValue: appUserPassword } { name: 'DBNAME' value: databaseName } { name: 'DBSERVER' value: sqlServer.properties.fullyQualifiedDomainName } { name: 'SQLCMDPASSWORD' secureValue: sqlAdminPassword } { name: 'SQLADMIN' value: sqlAdmin } ] scriptContent: ''' wget https://github.com/microsoft/go-sqlcmd/releases/download/v0.8.1/sqlcmd-v0.8.1-linux-x64.tar.bz2 tar x -f sqlcmd-v0.8.1-linux-x64.tar.bz2 -C . cat < ./initDb.sql drop user if exists ${APPUSERNAME} go create user ${APPUSERNAME} with password = '${APPUSERPASSWORD}' go alter role db_owner add member ${APPUSERNAME} go SCRIPT_END ./sqlcmd -S ${DBSERVER} -d ${DBNAME} -U ${SQLADMIN} -i ./initDb.sql ''' } } resource sqlAdminPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: 'dbAdminPassword' properties: { value: sqlAdminPassword } } resource appUserPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: 'dbAppUserPassword' properties: { value: appUserPassword } } resource sqlAzureConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { parent: keyVault name: connectionStringKey properties: { value: '${connectionString}; Password=${appUserPassword}' } } resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' existing = { name: keyVaultName } var connectionString = 'Server=${sqlServer.properties.fullyQualifiedDomainName}; Database=${sqlDatabase.name}; User=${appUser}' output connectionStringKey string = connectionStringKey output databaseName string = sqlDatabase.name ================================================ FILE: infra/core/gateway/apim.bicep ================================================ metadata description = 'Creates an Azure API Management instance.' param name string param location string = resourceGroup().location param tags object = {} @description('The email address of the owner of the service') @minLength(1) param publisherEmail string = 'noreply@microsoft.com' @description('The name of the owner of the service') @minLength(1) param publisherName string = 'n/a' @description('The pricing tier of this API Management service') @allowed([ 'Consumption' 'Developer' 'Standard' 'Premium' ]) param sku string = 'Consumption' @description('The instance size of this API Management service.') @allowed([ 0, 1, 2 ]) param skuCount int = 0 @description('Azure Application Insights Name') param applicationInsightsName string resource apimService 'Microsoft.ApiManagement/service@2021-08-01' = { name: name location: location tags: union(tags, { 'azd-service-name': name }) sku: { name: sku capacity: (sku == 'Consumption') ? 0 : ((sku == 'Developer') ? 1 : skuCount) } properties: { publisherEmail: publisherEmail publisherName: publisherName // Custom properties are not supported for Consumption SKU customProperties: sku == 'Consumption' ? {} : { 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_GCM_SHA256': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_CBC_SHA256': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_CBC_SHA256': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_256_CBC_SHA': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TLS_RSA_WITH_AES_128_CBC_SHA': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TripleDes168': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls10': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls11': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Ssl30': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Tls10': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Tls11': 'false' 'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Ssl30': 'false' } } } resource apimLogger 'Microsoft.ApiManagement/service/loggers@2021-12-01-preview' = if (!empty(applicationInsightsName)) { name: 'app-insights-logger' parent: apimService properties: { credentials: { instrumentationKey: applicationInsights.properties.InstrumentationKey } description: 'Logger to Azure Application Insights' isBuffered: false loggerType: 'applicationInsights' resourceId: applicationInsights.id } } resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { name: applicationInsightsName } output apimServiceName string = apimService.name ================================================ FILE: infra/core/host/ai-environment.bicep ================================================ @minLength(1) @description('Primary location for all resources') param location string @description('The AI Hub resource name.') param hubName string @description('The AI Project resource name.') param projectName string @description('The Key Vault resource name.') param keyVaultName string @description('The Storage Account resource name.') param storageAccountName string @description('The Open AI resource name.') param openAiName string @description('The Open AI connection name.') param openAiConnectionName string @description('The Open AI model deployments.') param openAiModelDeployments array = [] @description('The Log Analytics resource name.') param logAnalyticsName string = '' @description('The Application Insights resource name.') param applicationInsightsName string = '' @description('The Container Registry resource name.') param containerRegistryName string = '' @description('The Azure Search resource name.') param searchServiceName string = '' @description('The Azure Search connection name.') param searchConnectionName string = '' param tags object = {} module hubDependencies '../ai/hub-dependencies.bicep' = { name: 'hubDependencies' params: { location: location tags: tags keyVaultName: keyVaultName storageAccountName: storageAccountName containerRegistryName: containerRegistryName applicationInsightsName: applicationInsightsName logAnalyticsName: logAnalyticsName openAiName: openAiName openAiModelDeployments: openAiModelDeployments searchServiceName: searchServiceName } } module hub '../ai/hub.bicep' = { name: 'hub' params: { location: location tags: tags name: hubName displayName: hubName keyVaultId: hubDependencies.outputs.keyVaultId storageAccountId: hubDependencies.outputs.storageAccountId containerRegistryId: hubDependencies.outputs.containerRegistryId applicationInsightsId: hubDependencies.outputs.applicationInsightsId openAiName: hubDependencies.outputs.openAiName openAiConnectionName: openAiConnectionName aiSearchName: hubDependencies.outputs.searchServiceName aiSearchConnectionName: searchConnectionName } } module project '../ai/project.bicep' = { name: 'project' params: { location: location tags: tags name: projectName displayName: projectName hubName: hub.outputs.name keyVaultName: hubDependencies.outputs.keyVaultName } } // Outputs // Resource Group output resourceGroupName string = resourceGroup().name // Hub output hubName string = hub.outputs.name output hubPrincipalId string = hub.outputs.principalId // Project output projectName string = project.outputs.name output projectPrincipalId string = project.outputs.principalId // Key Vault output keyVaultName string = hubDependencies.outputs.keyVaultName output keyVaultEndpoint string = hubDependencies.outputs.keyVaultEndpoint // Application Insights output applicationInsightsName string = hubDependencies.outputs.applicationInsightsName output logAnalyticsWorkspaceName string = hubDependencies.outputs.logAnalyticsWorkspaceName // Container Registry output containerRegistryName string = hubDependencies.outputs.containerRegistryName output containerRegistryEndpoint string = hubDependencies.outputs.containerRegistryEndpoint // Storage Account output storageAccountName string = hubDependencies.outputs.storageAccountName // Open AI output openAiName string = hubDependencies.outputs.openAiName output openAiEndpoint string = hubDependencies.outputs.openAiEndpoint // Search output searchServiceName string = hubDependencies.outputs.searchServiceName output searchServiceEndpoint string = hubDependencies.outputs.searchServiceEndpoint ================================================ FILE: infra/core/host/aks-agent-pool.bicep ================================================ metadata description = 'Adds an agent pool to an Azure Kubernetes Service (AKS) cluster.' param clusterName string @description('The agent pool name') param name string @description('The agent pool configuration') param config object resource aksCluster 'Microsoft.ContainerService/managedClusters@2023-11-01' existing = { name: clusterName } resource nodePool 'Microsoft.ContainerService/managedClusters/agentPools@2023-11-01' = { parent: aksCluster name: name properties: config } ================================================ FILE: infra/core/host/aks-managed-cluster.bicep ================================================ metadata description = 'Creates an Azure Kubernetes Service (AKS) cluster with a system agent pool.' @description('The name for the AKS managed cluster') param name string @description('The name of the resource group for the managed resources of the AKS cluster') param nodeResourceGroupName string = '' @description('The Azure region/location for the AKS resources') param location string = resourceGroup().location @description('Custom tags to apply to the AKS resources') param tags object = {} @description('Kubernetes Version') param kubernetesVersion string = '1.29' @description('Whether RBAC is enabled for local accounts') param enableRbac bool = true // Add-ons @description('Whether web app routing (preview) add-on is enabled') param webAppRoutingAddon bool = true // AAD Integration @description('Enable Azure Active Directory integration') param enableAad bool = false @description('Enable RBAC using AAD') param enableAzureRbac bool = false @description('The Tenant ID associated to the Azure Active Directory') param aadTenantId string = tenant().tenantId @description('The load balancer SKU to use for ingress into the AKS cluster') @allowed([ 'basic', 'standard' ]) param loadBalancerSku string = 'standard' @description('Network plugin used for building the Kubernetes network.') @allowed([ 'azure', 'kubenet', 'none' ]) param networkPlugin string = 'azure' @description('Network policy used for building the Kubernetes network.') @allowed([ 'azure', 'calico' ]) param networkPolicy string = 'azure' @description('If set to true, getting static credentials will be disabled for this cluster.') param disableLocalAccounts bool = false @description('The managed cluster SKU.') @allowed([ 'Free', 'Paid', 'Standard' ]) param sku string = 'Free' @description('Configuration of AKS add-ons') param addOns object = {} @description('The log analytics workspace id used for logging & monitoring') param workspaceId string = '' @description('The node pool configuration for the System agent pool') param systemPoolConfig object @description('The DNS prefix to associate with the AKS cluster') param dnsPrefix string = '' resource aks 'Microsoft.ContainerService/managedClusters@2023-11-01' = { name: name location: location tags: tags identity: { type: 'SystemAssigned' } sku: { name: 'Base' tier: sku } properties: { nodeResourceGroup: !empty(nodeResourceGroupName) ? nodeResourceGroupName : 'rg-mc-${name}' kubernetesVersion: kubernetesVersion dnsPrefix: empty(dnsPrefix) ? '${name}-dns' : dnsPrefix enableRBAC: enableRbac aadProfile: enableAad ? { managed: true enableAzureRBAC: enableAzureRbac tenantID: aadTenantId } : null agentPoolProfiles: [ systemPoolConfig ] networkProfile: { loadBalancerSku: loadBalancerSku networkPlugin: networkPlugin networkPolicy: networkPolicy } disableLocalAccounts: disableLocalAccounts && enableAad addonProfiles: addOns ingressProfile: { webAppRouting: { enabled: webAppRoutingAddon } } } } var aksDiagCategories = [ 'cluster-autoscaler' 'kube-controller-manager' 'kube-audit-admin' 'guard' ] // TODO: Update diagnostics to be its own module // Blocking issue: https://github.com/Azure/bicep/issues/622 // Unable to pass in a `resource` scope or unable to use string interpolation in resource types resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { name: 'aks-diagnostics' scope: aks properties: { workspaceId: workspaceId logs: [for category in aksDiagCategories: { category: category enabled: true }] metrics: [ { category: 'AllMetrics' enabled: true } ] } } @description('The resource name of the AKS cluster') output clusterName string = aks.name @description('The AKS cluster identity') output clusterIdentity object = { clientId: aks.properties.identityProfile.kubeletidentity.clientId objectId: aks.properties.identityProfile.kubeletidentity.objectId resourceId: aks.properties.identityProfile.kubeletidentity.resourceId } ================================================ FILE: infra/core/host/aks.bicep ================================================ metadata description = 'Creates an Azure Kubernetes Service (AKS) cluster with a system agent pool as well as an additional user agent pool.' @description('The name for the AKS managed cluster') param name string @description('The name for the Azure container registry (ACR)') param containerRegistryName string @description('The name of the connected log analytics workspace') param logAnalyticsName string = '' @description('The name of the keyvault to grant access') param keyVaultName string @description('The Azure region/location for the AKS resources') param location string = resourceGroup().location @description('Custom tags to apply to the AKS resources') param tags object = {} @description('AKS add-ons configuration') param addOns object = { azurePolicy: { enabled: true config: { version: 'v2' } } keyVault: { enabled: true config: { enableSecretRotation: 'true' rotationPollInterval: '2m' } } openServiceMesh: { enabled: false config: {} } omsAgent: { enabled: true config: {} } applicationGateway: { enabled: false config: {} } } @description('The managed cluster SKU.') @allowed([ 'Free', 'Paid', 'Standard' ]) param sku string = 'Free' @description('The load balancer SKU to use for ingress into the AKS cluster') @allowed([ 'basic', 'standard' ]) param loadBalancerSku string = 'standard' @description('Network plugin used for building the Kubernetes network.') @allowed([ 'azure', 'kubenet', 'none' ]) param networkPlugin string = 'azure' @description('Network policy used for building the Kubernetes network.') @allowed([ 'azure', 'calico' ]) param networkPolicy string = 'azure' @description('The DNS prefix to associate with the AKS cluster') param dnsPrefix string = '' @description('The name of the resource group for the managed resources of the AKS cluster') param nodeResourceGroupName string = '' @allowed([ 'CostOptimised' 'Standard' 'HighSpec' 'Custom' ]) @description('The System Pool Preset sizing') param systemPoolType string = 'CostOptimised' @allowed([ '' 'CostOptimised' 'Standard' 'HighSpec' 'Custom' ]) @description('The User Pool Preset sizing') param agentPoolType string = '' // Configure system / user agent pools @description('Custom configuration of system node pool') param systemPoolConfig object = {} @description('Custom configuration of user node pool') param agentPoolConfig object = {} @description('Id of the user or app to assign application roles') param principalId string = '' @description('The type of principal to assign application roles') @allowed(['Device','ForeignGroup','Group','ServicePrincipal','User']) param principalType string = 'User' @description('Kubernetes Version') param kubernetesVersion string = '1.29' @description('The Tenant ID associated to the Azure Active Directory') param aadTenantId string = tenant().tenantId @description('Whether RBAC is enabled for local accounts') param enableRbac bool = true @description('If set to true, getting static credentials will be disabled for this cluster.') param disableLocalAccounts bool = false @description('Enable RBAC using AAD') param enableAzureRbac bool = false // Add-ons @description('Whether web app routing (preview) add-on is enabled') param webAppRoutingAddon bool = true // Configure AKS add-ons var omsAgentConfig = (!empty(logAnalyticsName) && !empty(addOns.omsAgent) && addOns.omsAgent.enabled) ? union( addOns.omsAgent, { config: { logAnalyticsWorkspaceResourceID: logAnalytics.id } } ) : {} var addOnsConfig = union( (!empty(addOns.azurePolicy) && addOns.azurePolicy.enabled) ? { azurepolicy: addOns.azurePolicy } : {}, (!empty(addOns.keyVault) && addOns.keyVault.enabled) ? { azureKeyvaultSecretsProvider: addOns.keyVault } : {}, (!empty(addOns.openServiceMesh) && addOns.openServiceMesh.enabled) ? { openServiceMesh: addOns.openServiceMesh } : {}, (!empty(addOns.omsAgent) && addOns.omsAgent.enabled) ? { omsagent: omsAgentConfig } : {}, (!empty(addOns.applicationGateway) && addOns.applicationGateway.enabled) ? { ingressApplicationGateway: addOns.applicationGateway } : {} ) // Link to existing log analytics workspace when available resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' existing = if (!empty(logAnalyticsName)) { name: logAnalyticsName } var systemPoolSpec = !empty(systemPoolConfig) ? systemPoolConfig : nodePoolPresets[systemPoolType] // Create the primary AKS cluster resources and system node pool module managedCluster 'aks-managed-cluster.bicep' = { name: 'managed-cluster' params: { name: name location: location tags: tags systemPoolConfig: union( { name: 'npsystem', mode: 'System' }, nodePoolBase, systemPoolSpec ) nodeResourceGroupName: nodeResourceGroupName sku: sku dnsPrefix: dnsPrefix kubernetesVersion: kubernetesVersion addOns: addOnsConfig workspaceId: !empty(logAnalyticsName) ? logAnalytics.id : '' enableAad: enableAzureRbac && aadTenantId != '' disableLocalAccounts: disableLocalAccounts aadTenantId: aadTenantId enableRbac: enableRbac enableAzureRbac: enableAzureRbac webAppRoutingAddon: webAppRoutingAddon loadBalancerSku: loadBalancerSku networkPlugin: networkPlugin networkPolicy: networkPolicy } } var hasAgentPool = !empty(agentPoolConfig) || !empty(agentPoolType) var agentPoolSpec = hasAgentPool && !empty(agentPoolConfig) ? agentPoolConfig : empty(agentPoolType) ? {} : nodePoolPresets[agentPoolType] // Create additional user agent pool when specified module agentPool 'aks-agent-pool.bicep' = if (hasAgentPool) { name: 'aks-node-pool' params: { clusterName: managedCluster.outputs.clusterName name: 'npuserpool' config: union({ name: 'npuser', mode: 'User' }, nodePoolBase, agentPoolSpec) } } // Creates container registry (ACR) module containerRegistry 'container-registry.bicep' = { name: 'container-registry' params: { name: containerRegistryName location: location tags: tags workspaceId: !empty(logAnalyticsName) ? logAnalytics.id : '' } } // Grant ACR Pull access from cluster managed identity to container registry module containerRegistryAccess '../security/registry-access.bicep' = { name: 'cluster-container-registry-access' params: { containerRegistryName: containerRegistry.outputs.name principalId: managedCluster.outputs.clusterIdentity.objectId } } // Give AKS cluster access to the specified principal module clusterAccess '../security/aks-managed-cluster-access.bicep' = if (!empty(principalId) && (enableAzureRbac || disableLocalAccounts)) { name: 'cluster-access' params: { clusterName: managedCluster.outputs.clusterName principalId: principalId principalType: principalType } } // Give the AKS Cluster access to KeyVault module clusterKeyVaultAccess '../security/keyvault-access.bicep' = { name: 'cluster-keyvault-access' params: { keyVaultName: keyVaultName principalId: managedCluster.outputs.clusterIdentity.objectId } } // Helpers for node pool configuration var nodePoolBase = { osType: 'Linux' maxPods: 30 type: 'VirtualMachineScaleSets' upgradeSettings: { maxSurge: '33%' } } var nodePoolPresets = { CostOptimised: { vmSize: 'Standard_B4ms' count: 1 minCount: 1 maxCount: 3 enableAutoScaling: true availabilityZones: [] } Standard: { vmSize: 'Standard_DS2_v2' count: 3 minCount: 3 maxCount: 5 enableAutoScaling: true availabilityZones: [ '1' '2' '3' ] } HighSpec: { vmSize: 'Standard_D4s_v3' count: 3 minCount: 3 maxCount: 5 enableAutoScaling: true availabilityZones: [ '1' '2' '3' ] } } // Module outputs @description('The resource name of the AKS cluster') output clusterName string = managedCluster.outputs.clusterName @description('The AKS cluster identity') output clusterIdentity object = managedCluster.outputs.clusterIdentity @description('The resource name of the ACR') output containerRegistryName string = containerRegistry.outputs.name @description('The login server for the container registry') output containerRegistryLoginServer string = containerRegistry.outputs.loginServer ================================================ FILE: infra/core/host/appservice-appsettings.bicep ================================================ metadata description = 'Updates app settings for an Azure App Service.' @description('The name of the app service resource within the current resource group scope') param name string @description('The app settings to be applied to the app service') @secure() param appSettings object resource appService 'Microsoft.Web/sites@2022-03-01' existing = { name: name } resource settings 'Microsoft.Web/sites/config@2022-03-01' = { name: 'appsettings' parent: appService properties: appSettings } ================================================ FILE: infra/core/host/appservice.bicep ================================================ metadata description = 'Creates an Azure App Service in an existing Azure App Service plan.' param name string param location string = resourceGroup().location param tags object = {} // Reference Properties param applicationInsightsName string = '' param appServicePlanId string param keyVaultName string = '' param managedIdentity bool = !empty(keyVaultName) param logAnalyticsWorkspaceId string = '' // Runtime Properties @allowed([ 'dotnet', 'dotnetcore', 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' ]) param runtimeName string param runtimeNameAndVersion string = '${runtimeName}|${runtimeVersion}' param runtimeVersion string // Microsoft.Web/sites Properties param kind string = 'app,linux' // Microsoft.Web/sites/config param allowedOrigins array = [] param alwaysOn bool = true param appCommandLine string = '' @secure() param appSettings object = {} param clientAffinityEnabled bool = false param enableOryxBuild bool = contains(kind, 'linux') param functionAppScaleLimit int = -1 param linuxFxVersion string = runtimeNameAndVersion param minimumElasticInstanceCount int = -1 param numberOfWorkers int = -1 param scmDoBuildDuringDeployment bool = false param use32BitWorkerProcess bool = false param ftpsState string = 'FtpsOnly' param healthCheckPath string = '' param virtualNetworkSubnetId string = '' resource appService 'Microsoft.Web/sites@2022-03-01' = { name: name location: location tags: tags kind: kind properties: { serverFarmId: appServicePlanId siteConfig: { linuxFxVersion: linuxFxVersion alwaysOn: alwaysOn ftpsState: ftpsState minTlsVersion: '1.2' appCommandLine: appCommandLine numberOfWorkers: numberOfWorkers != -1 ? numberOfWorkers : null minimumElasticInstanceCount: minimumElasticInstanceCount != -1 ? minimumElasticInstanceCount : null use32BitWorkerProcess: use32BitWorkerProcess functionAppScaleLimit: functionAppScaleLimit != -1 ? functionAppScaleLimit : null healthCheckPath: healthCheckPath cors: { allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) } } clientAffinityEnabled: clientAffinityEnabled httpsOnly: true virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null } identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } resource basicPublishingCredentialsPoliciesFtp 'basicPublishingCredentialsPolicies' = { name: 'ftp' properties: { allow: false } } resource basicPublishingCredentialsPoliciesScm 'basicPublishingCredentialsPolicies' = { name: 'scm' properties: { allow: false } } } resource webAppDiagSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!(empty(logAnalyticsWorkspaceId))) { name: '${appService.name}-diagnosticSettings' scope: appService properties: { workspaceId: logAnalyticsWorkspaceId logs: [ { category: 'AppServiceHTTPLogs' categoryGroup: null enabled: true retentionPolicy: { days: 7 enabled: true } } { category: 'AppServiceConsoleLogs' categoryGroup: null enabled: true retentionPolicy: { days: 7 enabled: true } } { category: 'AppServiceAppLogs' categoryGroup: null enabled: true retentionPolicy: { days: 7 enabled: true } } ] metrics: [ { category: 'AllMetrics' enabled: true retentionPolicy: { days: 7 enabled: true } } ] } } // Updates to the single Microsoft.sites/web/config resources that need to be performed sequentially // sites/web/config 'appsettings' module configAppSettings 'appservice-appsettings.bicep' = { name: '${name}-appSettings' params: { name: appService.name appSettings: union(appSettings, { SCM_DO_BUILD_DURING_DEPLOYMENT: string(scmDoBuildDuringDeployment) ENABLE_ORYX_BUILD: string(enableOryxBuild) }, runtimeName == 'python' && appCommandLine == '' ? { PYTHON_ENABLE_GUNICORN_MULTIWORKERS: 'true'} : {}, !empty(applicationInsightsName) ? { APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString } : {}, !empty(keyVaultName) ? { AZURE_KEY_VAULT_ENDPOINT: keyVault.properties.vaultUri } : {}) } } // sites/web/config 'logs' resource configLogs 'Microsoft.Web/sites/config@2022-03-01' = { name: 'logs' parent: appService properties: { applicationLogs: { fileSystem: { level: 'Verbose' } } detailedErrorMessages: { enabled: true } failedRequestsTracing: { enabled: true } httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } } dependsOn: [configAppSettings] } resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' existing = if (!(empty(keyVaultName))) { name: keyVaultName } resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { name: applicationInsightsName } output identityPrincipalId string = managedIdentity ? appService.identity.principalId : '' output name string = appService.name output uri string = 'https://${appService.properties.defaultHostName}' ================================================ FILE: infra/core/host/appserviceplan.bicep ================================================ metadata description = 'Creates an Azure App Service plan.' param name string param location string = resourceGroup().location param tags object = {} param logAnalyticsWorkspaceId string = '' param kind string = '' param reserved bool = true param sku object resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { name: name location: location tags: tags sku: sku kind: kind properties: { reserved: reserved } } resource appServicePlanDiagSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!(empty(logAnalyticsWorkspaceId))) { name: '${appServicePlan.name}-diagnosticSettings' scope: appServicePlan properties: { workspaceId: logAnalyticsWorkspaceId metrics: [ { category: 'AllMetrics' enabled: true retentionPolicy: { days: 7 enabled: true } } ] } } output id string = appServicePlan.id output name string = appServicePlan.name ================================================ FILE: infra/core/host/container-app-upsert.bicep ================================================ metadata description = 'Creates or updates an existing Azure Container App.' param name string param location string = resourceGroup().location param tags object = {} @description('The environment name for the container apps') param containerAppsEnvironmentName string @description('The number of CPU cores allocated to a single container instance, e.g., 0.5') param containerCpuCoreCount string = '0.5' @description('The maximum number of replicas to run. Must be at least 1.') @minValue(1) param containerMaxReplicas int = 10 @description('The amount of memory allocated to a single container instance, e.g., 1Gi') param containerMemory string = '1.0Gi' @description('The minimum number of replicas to run. Must be at least 1.') @minValue(1) param containerMinReplicas int = 1 @description('The name of the container') param containerName string = 'main' @description('The name of the container registry') param containerRegistryName string = '' @description('Hostname suffix for container registry. Set when deploying to sovereign clouds') param containerRegistryHostSuffix string = 'azurecr.io' @allowed([ 'http', 'grpc' ]) @description('The protocol used by Dapr to connect to the app, e.g., HTTP or gRPC') param daprAppProtocol string = 'http' @description('Enable or disable Dapr for the container app') param daprEnabled bool = false @description('The Dapr app ID') param daprAppId string = containerName @description('Specifies if the resource already exists') param exists bool = false @description('Specifies if Ingress is enabled for the container app') param ingressEnabled bool = true @description('The type of identity for the resource') @allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) param identityType string = 'None' @description('The name of the user-assigned identity') param identityName string = '' @description('The name of the container image') param imageName string = '' @description('The secrets required for the container') @secure() param secrets object = {} @description('The environment variables for the container') param env array = [] @description('Specifies if the resource ingress is exposed externally') param external bool = true @description('The service binds associated with the container') param serviceBinds array = [] @description('The target port for the container') param targetPort int = 80 resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { name: name } module app 'container-app.bicep' = { name: '${deployment().name}-update' params: { name: name location: location tags: tags identityType: identityType identityName: identityName ingressEnabled: ingressEnabled containerName: containerName containerAppsEnvironmentName: containerAppsEnvironmentName containerRegistryName: containerRegistryName containerRegistryHostSuffix: containerRegistryHostSuffix containerCpuCoreCount: containerCpuCoreCount containerMemory: containerMemory containerMinReplicas: containerMinReplicas containerMaxReplicas: containerMaxReplicas daprEnabled: daprEnabled daprAppId: daprAppId daprAppProtocol: daprAppProtocol secrets: secrets external: external env: env imageName: !empty(imageName) ? imageName : exists ? existingApp.properties.template.containers[0].image : '' targetPort: targetPort serviceBinds: serviceBinds } } output defaultDomain string = app.outputs.defaultDomain output imageName string = app.outputs.imageName output name string = app.outputs.name output uri string = app.outputs.uri ================================================ FILE: infra/core/host/container-app.bicep ================================================ metadata description = 'Creates a container app in an Azure Container App environment.' param name string param location string = resourceGroup().location param tags object = {} @description('Allowed origins') param allowedOrigins array = [] @description('Name of the environment for container apps') param containerAppsEnvironmentName string @description('CPU cores allocated to a single container instance, e.g., 0.5') param containerCpuCoreCount string = '0.5' @description('The maximum number of replicas to run. Must be at least 1.') @minValue(1) param containerMaxReplicas int = 10 @description('Memory allocated to a single container instance, e.g., 1Gi') param containerMemory string = '1.0Gi' @description('The minimum number of replicas to run. Must be at least 1.') param containerMinReplicas int = 1 @description('The name of the container') param containerName string = 'main' @description('The name of the container registry') param containerRegistryName string = '' @description('Hostname suffix for container registry. Set when deploying to sovereign clouds') param containerRegistryHostSuffix string = 'azurecr.io' @description('The protocol used by Dapr to connect to the app, e.g., http or grpc') @allowed([ 'http', 'grpc' ]) param daprAppProtocol string = 'http' @description('The Dapr app ID') param daprAppId string = containerName @description('Enable Dapr') param daprEnabled bool = false @description('The environment variables for the container') param env array = [] @description('Specifies if the resource ingress is exposed externally') param external bool = true @description('The name of the user-assigned identity') param identityName string = '' @description('The type of identity for the resource') @allowed([ 'None', 'SystemAssigned', 'UserAssigned' ]) param identityType string = 'None' @description('The name of the container image') param imageName string = '' @description('Specifies if Ingress is enabled for the container app') param ingressEnabled bool = true param revisionMode string = 'Single' @description('The secrets required for the container') @secure() param secrets object = {} @description('The service binds associated with the container') param serviceBinds array = [] @description('The name of the container apps add-on to use. e.g. redis') param serviceType string = '' @description('The target port for the container') param targetPort int = 80 resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = if (!empty(identityName)) { name: identityName } // Private registry support requires both an ACR name and a User Assigned managed identity var usePrivateRegistry = !empty(identityName) && !empty(containerRegistryName) // Automatically set to `UserAssigned` when an `identityName` has been set var normalizedIdentityType = !empty(identityName) ? 'UserAssigned' : identityType module containerRegistryAccess '../security/registry-access.bicep' = if (usePrivateRegistry) { name: '${deployment().name}-registry-access' params: { containerRegistryName: containerRegistryName principalId: usePrivateRegistry ? userIdentity.properties.principalId : '' } } resource app 'Microsoft.App/containerApps@2023-05-02-preview' = { name: name location: location tags: tags // It is critical that the identity is granted ACR pull access before the app is created // otherwise the container app will throw a provision error // This also forces us to use an user assigned managed identity since there would no way to // provide the system assigned identity with the ACR pull access before the app is created dependsOn: usePrivateRegistry ? [ containerRegistryAccess ] : [] identity: { type: normalizedIdentityType userAssignedIdentities: !empty(identityName) && normalizedIdentityType == 'UserAssigned' ? { '${userIdentity.id}': {} } : null } properties: { managedEnvironmentId: containerAppsEnvironment.id configuration: { activeRevisionsMode: revisionMode ingress: ingressEnabled ? { external: external targetPort: targetPort transport: 'auto' corsPolicy: { allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins) } } : null dapr: daprEnabled ? { enabled: true appId: daprAppId appProtocol: daprAppProtocol appPort: ingressEnabled ? targetPort : 0 } : { enabled: false } secrets: [for secret in items(secrets): { name: secret.key value: secret.value }] service: !empty(serviceType) ? { type: serviceType } : null registries: usePrivateRegistry ? [ { server: '${containerRegistryName}.${containerRegistryHostSuffix}' identity: userIdentity.id } ] : [] } template: { serviceBinds: !empty(serviceBinds) ? serviceBinds : null containers: [ { image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' name: containerName env: env resources: { cpu: json(containerCpuCoreCount) memory: containerMemory } } ] scale: { minReplicas: containerMinReplicas maxReplicas: containerMaxReplicas } } } } resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = { name: containerAppsEnvironmentName } output defaultDomain string = containerAppsEnvironment.properties.defaultDomain output identityPrincipalId string = normalizedIdentityType == 'None' ? '' : (empty(identityName) ? app.identity.principalId : userIdentity.properties.principalId) output imageName string = imageName output name string = app.name output serviceBind object = !empty(serviceType) ? { serviceId: app.id, name: name } : {} output uri string = ingressEnabled ? 'https://${app.properties.configuration.ingress.fqdn}' : '' ================================================ FILE: infra/core/host/container-apps-environment.bicep ================================================ metadata description = 'Creates an Azure Container Apps environment.' param name string param location string = resourceGroup().location param tags object = {} @description('Name of the Application Insights resource') param applicationInsightsName string = '' @description('Specifies if Dapr is enabled') param daprEnabled bool = false @description('Name of the Log Analytics workspace') param logAnalyticsWorkspaceName string resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = { name: name location: location tags: tags properties: { appLogsConfiguration: { destination: 'log-analytics' logAnalyticsConfiguration: { customerId: logAnalyticsWorkspace.properties.customerId sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey } } daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : '' } } resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { name: logAnalyticsWorkspaceName } resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)) { name: applicationInsightsName } output defaultDomain string = containerAppsEnvironment.properties.defaultDomain output id string = containerAppsEnvironment.id output name string = containerAppsEnvironment.name ================================================ FILE: infra/core/host/container-apps.bicep ================================================ metadata description = 'Creates an Azure Container Registry and an Azure Container Apps environment.' param name string param location string = resourceGroup().location param tags object = {} param containerAppsEnvironmentName string param containerRegistryName string param containerRegistryResourceGroupName string = '' param containerRegistryAdminUserEnabled bool = false param logAnalyticsWorkspaceName string param applicationInsightsName string = '' param daprEnabled bool = false module containerAppsEnvironment 'container-apps-environment.bicep' = { name: '${name}-container-apps-environment' params: { name: containerAppsEnvironmentName location: location tags: tags logAnalyticsWorkspaceName: logAnalyticsWorkspaceName applicationInsightsName: applicationInsightsName daprEnabled: daprEnabled } } module containerRegistry 'container-registry.bicep' = { name: '${name}-container-registry' scope: !empty(containerRegistryResourceGroupName) ? resourceGroup(containerRegistryResourceGroupName) : resourceGroup() params: { name: containerRegistryName location: location adminUserEnabled: containerRegistryAdminUserEnabled tags: tags } } output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain output environmentName string = containerAppsEnvironment.outputs.name output environmentId string = containerAppsEnvironment.outputs.id output registryLoginServer string = containerRegistry.outputs.loginServer output registryName string = containerRegistry.outputs.name ================================================ FILE: infra/core/host/container-registry.bicep ================================================ metadata description = 'Creates an Azure Container Registry.' param name string param location string = resourceGroup().location param tags object = {} @description('Indicates whether admin user is enabled') param adminUserEnabled bool = false @description('Indicates whether anonymous pull is enabled') param anonymousPullEnabled bool = false @description('Azure ad authentication as arm policy settings') param azureADAuthenticationAsArmPolicy object = { status: 'enabled' } @description('Indicates whether data endpoint is enabled') param dataEndpointEnabled bool = false @description('Encryption settings') param encryption object = { status: 'disabled' } @description('Export policy settings') param exportPolicy object = { status: 'enabled' } @description('Metadata search settings') param metadataSearch string = 'Disabled' @description('Options for bypassing network rules') param networkRuleBypassOptions string = 'AzureServices' @description('Public network access setting') param publicNetworkAccess string = 'Enabled' @description('Quarantine policy settings') param quarantinePolicy object = { status: 'disabled' } @description('Retention policy settings') param retentionPolicy object = { days: 7 status: 'disabled' } @description('Scope maps setting') param scopeMaps array = [] @description('SKU settings') param sku object = { name: 'Basic' } @description('Soft delete policy settings') param softDeletePolicy object = { retentionDays: 7 status: 'disabled' } @description('Trust policy settings') param trustPolicy object = { type: 'Notary' status: 'disabled' } @description('Zone redundancy setting') param zoneRedundancy string = 'Disabled' @description('The log analytics workspace ID used for logging and monitoring') param workspaceId string = '' // 2023-11-01-preview needed for metadataSearch resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { name: name location: location tags: tags sku: sku properties: { adminUserEnabled: adminUserEnabled anonymousPullEnabled: anonymousPullEnabled dataEndpointEnabled: dataEndpointEnabled encryption: encryption metadataSearch: metadataSearch networkRuleBypassOptions: networkRuleBypassOptions policies:{ quarantinePolicy: quarantinePolicy trustPolicy: trustPolicy retentionPolicy: retentionPolicy exportPolicy: exportPolicy azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy softDeletePolicy: softDeletePolicy } publicNetworkAccess: publicNetworkAccess zoneRedundancy: zoneRedundancy } resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: { name: scopeMap.name properties: scopeMap.properties }] } // TODO: Update diagnostics to be its own module // Blocking issue: https://github.com/Azure/bicep/issues/622 // Unable to pass in a `resource` scope or unable to use string interpolation in resource types resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) { name: 'registry-diagnostics' scope: containerRegistry properties: { workspaceId: workspaceId logs: [ { category: 'ContainerRegistryRepositoryEvents' enabled: true } { category: 'ContainerRegistryLoginEvents' enabled: true } ] metrics: [ { category: 'AllMetrics' enabled: true timeGrain: 'PT1M' } ] } } output id string = containerRegistry.id output loginServer string = containerRegistry.properties.loginServer output name string = containerRegistry.name ================================================ FILE: infra/core/host/functions.bicep ================================================ metadata description = 'Creates an Azure Function in an existing Azure App Service plan.' param name string param location string = resourceGroup().location param tags object = {} // Reference Properties param applicationInsightsName string = '' param appServicePlanId string param keyVaultName string = '' param managedIdentity bool = !empty(keyVaultName) || storageManagedIdentity param storageAccountName string param storageManagedIdentity bool = false param virtualNetworkSubnetId string = '' // Runtime Properties @allowed([ 'dotnet', 'dotnetcore', 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' ]) param runtimeName string param runtimeNameAndVersion string = '${runtimeName}|${runtimeVersion}' param runtimeVersion string // Function Settings @allowed([ '~4', '~3', '~2', '~1' ]) param extensionVersion string = '~4' // Microsoft.Web/sites Properties param kind string = 'functionapp,linux' // Microsoft.Web/sites/config param allowedOrigins array = [] param alwaysOn bool = true param appCommandLine string = '' @secure() param appSettings object = {} param clientAffinityEnabled bool = false param enableOryxBuild bool = contains(kind, 'linux') param functionAppScaleLimit int = -1 param linuxFxVersion string = runtimeNameAndVersion param minimumElasticInstanceCount int = -1 param numberOfWorkers int = -1 param scmDoBuildDuringDeployment bool = true param use32BitWorkerProcess bool = false param healthCheckPath string = '' module functions 'appservice.bicep' = { name: '${name}-functions' params: { name: name location: location tags: tags allowedOrigins: allowedOrigins alwaysOn: alwaysOn appCommandLine: appCommandLine applicationInsightsName: applicationInsightsName appServicePlanId: appServicePlanId appSettings: union(appSettings, { AzureWebJobsStorage__accountName: storageManagedIdentity ? storage.name : null AzureWebJobsStorage: storageManagedIdentity ? null : 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' FUNCTIONS_EXTENSION_VERSION: extensionVersion FUNCTIONS_WORKER_RUNTIME: runtimeName }) clientAffinityEnabled: clientAffinityEnabled enableOryxBuild: enableOryxBuild functionAppScaleLimit: functionAppScaleLimit healthCheckPath: healthCheckPath keyVaultName: keyVaultName kind: kind linuxFxVersion: linuxFxVersion managedIdentity: managedIdentity minimumElasticInstanceCount: minimumElasticInstanceCount numberOfWorkers: numberOfWorkers runtimeName: runtimeName runtimeVersion: runtimeVersion runtimeNameAndVersion: runtimeNameAndVersion scmDoBuildDuringDeployment: scmDoBuildDuringDeployment use32BitWorkerProcess: use32BitWorkerProcess virtualNetworkSubnetId: virtualNetworkSubnetId } } module storageOwnerRole '../../core/security/role.bicep' = if (storageManagedIdentity) { name: 'search-index-contrib-role-api' params: { principalId: functions.outputs.identityPrincipalId // Search Index Data Contributor roleDefinitionId: '8ebe5a00-799e-43f5-93ac-243d3dce84a7' principalType: 'ServicePrincipal' } } resource storage 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { name: storageAccountName } output identityPrincipalId string = managedIdentity ? functions.outputs.identityPrincipalId : '' output name string = functions.outputs.name output uri string = functions.outputs.uri ================================================ FILE: infra/core/host/staticwebapp.bicep ================================================ metadata description = 'Creates an Azure Static Web Apps instance.' param name string param location string = resourceGroup().location param tags object = {} param sku object = { name: 'Free' tier: 'Free' } resource web 'Microsoft.Web/staticSites@2022-03-01' = { name: name location: location tags: tags sku: sku properties: { provider: 'Custom' } } output name string = web.name output uri string = 'https://${web.properties.defaultHostname}' ================================================ FILE: infra/core/monitor/applicationinsights-dashboard.bicep ================================================ metadata description = 'Creates a dashboard for an Application Insights instance.' param name string param applicationInsightsName string param location string = resourceGroup().location param tags object = {} // 2020-09-01-preview because that is the latest valid version resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { name: name location: location tags: tags properties: { lenses: [ { order: 0 parts: [ { position: { x: 0 y: 0 colSpan: 2 rowSpan: 1 } metadata: { inputs: [ { name: 'id' value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } { name: 'Version' value: '1.0' } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' asset: { idInputName: 'id' type: 'ApplicationInsights' } defaultMenuItemId: 'overview' } } { position: { x: 2 y: 0 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ComponentId' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'Version' value: '1.0' } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' asset: { idInputName: 'ComponentId' type: 'ApplicationInsights' } defaultMenuItemId: 'ProactiveDetection' } } { position: { x: 3 y: 0 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ComponentId' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'ResourceId' value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' asset: { idInputName: 'ComponentId' type: 'ApplicationInsights' } } } { position: { x: 4 y: 0 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ComponentId' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'TimeContext' value: { durationMs: 86400000 endTime: null createdTime: '2018-05-04T01:20:33.345Z' isInitialTime: true grain: 1 useDashboardTimeRange: false } } { name: 'Version' value: '1.0' } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' asset: { idInputName: 'ComponentId' type: 'ApplicationInsights' } } } { position: { x: 5 y: 0 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ComponentId' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'TimeContext' value: { durationMs: 86400000 endTime: null createdTime: '2018-05-08T18:47:35.237Z' isInitialTime: true grain: 1 useDashboardTimeRange: false } } { name: 'ConfigurationId' value: '78ce933e-e864-4b05-a27b-71fd55a6afad' } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' asset: { idInputName: 'ComponentId' type: 'ApplicationInsights' } } } { position: { x: 0 y: 1 colSpan: 3 rowSpan: 1 } metadata: { inputs: [] type: 'Extension/HubsExtension/PartType/MarkdownPart' settings: { content: { settings: { content: '# Usage' title: '' subtitle: '' } } } } } { position: { x: 3 y: 1 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ComponentId' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'TimeContext' value: { durationMs: 86400000 endTime: null createdTime: '2018-05-04T01:22:35.782Z' isInitialTime: true grain: 1 useDashboardTimeRange: false } } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' asset: { idInputName: 'ComponentId' type: 'ApplicationInsights' } } } { position: { x: 4 y: 1 colSpan: 3 rowSpan: 1 } metadata: { inputs: [] type: 'Extension/HubsExtension/PartType/MarkdownPart' settings: { content: { settings: { content: '# Reliability' title: '' subtitle: '' } } } } } { position: { x: 7 y: 1 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ResourceId' value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } { name: 'DataModel' value: { version: '1.0.0' timeContext: { durationMs: 86400000 createdTime: '2018-05-04T23:42:40.072Z' isInitialTime: false grain: 1 useDashboardTimeRange: false } } isOptional: true } { name: 'ConfigurationId' value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' isAdapter: true asset: { idInputName: 'ResourceId' type: 'ApplicationInsights' } defaultMenuItemId: 'failures' } } { position: { x: 8 y: 1 colSpan: 3 rowSpan: 1 } metadata: { inputs: [] type: 'Extension/HubsExtension/PartType/MarkdownPart' settings: { content: { settings: { content: '# Responsiveness\r\n' title: '' subtitle: '' } } } } } { position: { x: 11 y: 1 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ResourceId' value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } { name: 'DataModel' value: { version: '1.0.0' timeContext: { durationMs: 86400000 createdTime: '2018-05-04T23:43:37.804Z' isInitialTime: false grain: 1 useDashboardTimeRange: false } } isOptional: true } { name: 'ConfigurationId' value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' isAdapter: true asset: { idInputName: 'ResourceId' type: 'ApplicationInsights' } defaultMenuItemId: 'performance' } } { position: { x: 12 y: 1 colSpan: 3 rowSpan: 1 } metadata: { inputs: [] type: 'Extension/HubsExtension/PartType/MarkdownPart' settings: { content: { settings: { content: '# Browser' title: '' subtitle: '' } } } } } { position: { x: 15 y: 1 colSpan: 1 rowSpan: 1 } metadata: { inputs: [ { name: 'ComponentId' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'MetricsExplorerJsonDefinitionId' value: 'BrowserPerformanceTimelineMetrics' } { name: 'TimeContext' value: { durationMs: 86400000 createdTime: '2018-05-08T12:16:27.534Z' isInitialTime: false grain: 1 useDashboardTimeRange: false } } { name: 'CurrentFilter' value: { eventTypes: [ 4 1 3 5 2 6 13 ] typeFacets: {} isPermissive: false } } { name: 'id' value: { Name: applicationInsights.name SubscriptionId: subscription().subscriptionId ResourceGroup: resourceGroup().name } } { name: 'Version' value: '1.0' } ] #disable-next-line BCP036 type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' asset: { idInputName: 'ComponentId' type: 'ApplicationInsights' } defaultMenuItemId: 'browser' } } { position: { x: 0 y: 2 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'sessions/count' aggregationType: 5 namespace: 'microsoft.insights/components/kusto' metricVisualization: { displayName: 'Sessions' color: '#47BDF5' } } { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'users/count' aggregationType: 5 namespace: 'microsoft.insights/components/kusto' metricVisualization: { displayName: 'Users' color: '#7E58FF' } } ] title: 'Unique sessions and users' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } openBladeOnClick: { openBlade: true destinationBlade: { extensionName: 'HubsExtension' bladeName: 'ResourceMenuBlade' parameters: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' menuid: 'segmentationUsers' } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 4 y: 2 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'requests/failed' aggregationType: 7 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Failed requests' color: '#EC008C' } } ] title: 'Failed requests' visualization: { chartType: 3 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } openBladeOnClick: { openBlade: true destinationBlade: { extensionName: 'HubsExtension' bladeName: 'ResourceMenuBlade' parameters: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' menuid: 'failures' } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 8 y: 2 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'requests/duration' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Server response time' color: '#00BCF2' } } ] title: 'Server response time' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } openBladeOnClick: { openBlade: true destinationBlade: { extensionName: 'HubsExtension' bladeName: 'ResourceMenuBlade' parameters: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' menuid: 'performance' } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 12 y: 2 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'browserTimings/networkDuration' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Page load network connect time' color: '#7E58FF' } } { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'browserTimings/processingDuration' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Client processing time' color: '#44F1C8' } } { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'browserTimings/sendDuration' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Send request time' color: '#EB9371' } } { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'browserTimings/receiveDuration' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Receiving response time' color: '#0672F1' } } ] title: 'Average page load time breakdown' visualization: { chartType: 3 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 0 y: 5 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'availabilityResults/availabilityPercentage' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Availability' color: '#47BDF5' } } ] title: 'Average availability' visualization: { chartType: 3 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } openBladeOnClick: { openBlade: true destinationBlade: { extensionName: 'HubsExtension' bladeName: 'ResourceMenuBlade' parameters: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' menuid: 'availability' } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 4 y: 5 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'exceptions/server' aggregationType: 7 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Server exceptions' color: '#47BDF5' } } { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'dependencies/failed' aggregationType: 7 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Dependency failures' color: '#7E58FF' } } ] title: 'Server exceptions and Dependency failures' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 8 y: 5 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'performanceCounters/processorCpuPercentage' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Processor time' color: '#47BDF5' } } { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'performanceCounters/processCpuPercentage' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Process CPU' color: '#7E58FF' } } ] title: 'Average processor and process CPU utilization' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 12 y: 5 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'exceptions/browser' aggregationType: 7 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Browser exceptions' color: '#47BDF5' } } ] title: 'Browser exceptions' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 0 y: 8 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'availabilityResults/count' aggregationType: 7 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Availability test results count' color: '#47BDF5' } } ] title: 'Availability test results count' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 4 y: 8 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'performanceCounters/processIOBytesPerSecond' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Process IO rate' color: '#47BDF5' } } ] title: 'Average process I/O rate' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } { position: { x: 8 y: 8 colSpan: 4 rowSpan: 3 } metadata: { inputs: [ { name: 'options' value: { chart: { metrics: [ { resourceMetadata: { id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' } name: 'performanceCounters/memoryAvailableBytes' aggregationType: 4 namespace: 'microsoft.insights/components' metricVisualization: { displayName: 'Available memory' color: '#47BDF5' } } ] title: 'Average available memory' visualization: { chartType: 2 legendVisualization: { isVisible: true position: 2 hideSubtitle: false } axisVisualization: { x: { isVisible: true axisType: 2 } y: { isVisible: true axisType: 1 } } } } } } { name: 'sharedTimeRange' isOptional: true } ] #disable-next-line BCP036 type: 'Extension/HubsExtension/PartType/MonitorChartPart' settings: {} } } ] } ] } } resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { name: applicationInsightsName } ================================================ FILE: infra/core/monitor/applicationinsights.bicep ================================================ metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' param name string param dashboardName string = '' param location string = resourceGroup().location param tags object = {} param logAnalyticsWorkspaceId string resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { name: name location: location tags: tags kind: 'web' properties: { Application_Type: 'web' WorkspaceResourceId: logAnalyticsWorkspaceId } } module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { name: 'application-insights-dashboard' params: { name: dashboardName location: location applicationInsightsName: applicationInsights.name } } output connectionString string = applicationInsights.properties.ConnectionString output id string = applicationInsights.id output instrumentationKey string = applicationInsights.properties.InstrumentationKey output name string = applicationInsights.name ================================================ FILE: infra/core/monitor/loganalytics.bicep ================================================ metadata description = 'Creates a Log Analytics workspace.' param name string param location string = resourceGroup().location param tags object = {} resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { name: name location: location tags: tags properties: any({ retentionInDays: 30 features: { searchVersion: 1 } sku: { name: 'PerGB2018' } }) } output id string = logAnalytics.id output name string = logAnalytics.name ================================================ FILE: infra/core/monitor/monitoring.bicep ================================================ metadata description = 'Creates an Application Insights instance and a Log Analytics workspace.' param logAnalyticsName string param applicationInsightsName string param applicationInsightsDashboardName string = '' param location string = resourceGroup().location param tags object = {} module logAnalytics 'loganalytics.bicep' = { name: 'loganalytics' params: { name: logAnalyticsName location: location tags: tags } } module applicationInsights 'applicationinsights.bicep' = { name: 'applicationinsights' params: { name: applicationInsightsName location: location tags: tags dashboardName: applicationInsightsDashboardName logAnalyticsWorkspaceId: logAnalytics.outputs.id } } output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString output applicationInsightsId string = applicationInsights.outputs.id output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey output applicationInsightsName string = applicationInsights.outputs.name output logAnalyticsWorkspaceId string = logAnalytics.outputs.id output logAnalyticsWorkspaceName string = logAnalytics.outputs.name ================================================ FILE: infra/core/networking/cdn-endpoint.bicep ================================================ metadata description = 'Adds an endpoint to an Azure CDN profile.' param name string param location string = resourceGroup().location param tags object = {} @description('The name of the CDN profile resource') @minLength(1) param cdnProfileName string @description('Delivery policy rules') param deliveryPolicyRules array = [] @description('The origin URL for the endpoint') @minLength(1) param originUrl string resource endpoint 'Microsoft.Cdn/profiles/endpoints@2022-05-01-preview' = { parent: cdnProfile name: name location: location tags: tags properties: { originHostHeader: originUrl isHttpAllowed: false isHttpsAllowed: true queryStringCachingBehavior: 'UseQueryString' optimizationType: 'GeneralWebDelivery' origins: [ { name: replace(originUrl, '.', '-') properties: { hostName: originUrl originHostHeader: originUrl priority: 1 weight: 1000 enabled: true } } ] deliveryPolicy: { rules: deliveryPolicyRules } } } resource cdnProfile 'Microsoft.Cdn/profiles@2022-05-01-preview' existing = { name: cdnProfileName } output id string = endpoint.id output name string = endpoint.name output uri string = 'https://${endpoint.properties.hostName}' ================================================ FILE: infra/core/networking/cdn-profile.bicep ================================================ metadata description = 'Creates an Azure CDN profile.' param name string param location string = resourceGroup().location param tags object = {} @description('The pricing tier of this CDN profile') @allowed([ 'Custom_Verizon' 'Premium_AzureFrontDoor' 'Premium_Verizon' 'StandardPlus_955BandWidth_ChinaCdn' 'StandardPlus_AvgBandWidth_ChinaCdn' 'StandardPlus_ChinaCdn' 'Standard_955BandWidth_ChinaCdn' 'Standard_Akamai' 'Standard_AvgBandWidth_ChinaCdn' 'Standard_AzureFrontDoor' 'Standard_ChinaCdn' 'Standard_Microsoft' 'Standard_Verizon' ]) param sku string = 'Standard_Microsoft' resource profile 'Microsoft.Cdn/profiles@2022-05-01-preview' = { name: name location: location tags: tags sku: { name: sku } } output id string = profile.id output name string = profile.name ================================================ FILE: infra/core/networking/cdn.bicep ================================================ metadata description = 'Creates an Azure CDN profile with a single endpoint.' param location string = resourceGroup().location param tags object = {} @description('Name of the CDN endpoint resource') param cdnEndpointName string @description('Name of the CDN profile resource') param cdnProfileName string @description('Delivery policy rules') param deliveryPolicyRules array = [] @description('Origin URL for the CDN endpoint') param originUrl string module cdnProfile 'cdn-profile.bicep' = { name: 'cdn-profile' params: { name: cdnProfileName location: location tags: tags } } module cdnEndpoint 'cdn-endpoint.bicep' = { name: 'cdn-endpoint' params: { name: cdnEndpointName location: location tags: tags cdnProfileName: cdnProfile.outputs.name originUrl: originUrl deliveryPolicyRules: deliveryPolicyRules } } output endpointName string = cdnEndpoint.outputs.name output endpointId string = cdnEndpoint.outputs.id output profileName string = cdnProfile.outputs.name output profileId string = cdnProfile.outputs.id output uri string = cdnEndpoint.outputs.uri ================================================ FILE: infra/core/search/search-services.bicep ================================================ metadata description = 'Creates an Azure AI Search instance.' param name string param location string = resourceGroup().location param tags object = {} param sku object = { name: 'standard' } param authOptions object = {} param disableLocalAuth bool = false param disabledDataExfiltrationOptions array = [] param encryptionWithCmk object = { enforcement: 'Unspecified' } @allowed([ 'default' 'highDensity' ]) param hostingMode string = 'default' param networkRuleSet object = { bypass: 'None' ipRules: [] } param partitionCount int = 1 @allowed([ 'enabled' 'disabled' ]) param publicNetworkAccess string = 'enabled' param replicaCount int = 1 @allowed([ 'disabled' 'free' 'standard' ]) param semanticSearch string = 'disabled' var searchIdentityProvider = (sku.name == 'free') ? null : { type: 'SystemAssigned' } resource search 'Microsoft.Search/searchServices@2021-04-01-preview' = { name: name location: location tags: tags // The free tier does not support managed identity identity: searchIdentityProvider properties: { authOptions: disableLocalAuth ? null : authOptions disableLocalAuth: disableLocalAuth disabledDataExfiltrationOptions: disabledDataExfiltrationOptions encryptionWithCmk: encryptionWithCmk hostingMode: hostingMode networkRuleSet: networkRuleSet partitionCount: partitionCount publicNetworkAccess: publicNetworkAccess replicaCount: replicaCount semanticSearch: semanticSearch } sku: sku } output id string = search.id output endpoint string = 'https://${name}.search.windows.net/' output name string = search.name output principalId string = !empty(searchIdentityProvider) ? search.identity.principalId : '' ================================================ FILE: infra/core/security/aks-managed-cluster-access.bicep ================================================ metadata description = 'Assigns RBAC role to the specified AKS cluster and principal.' @description('The AKS cluster name used as the target of the role assignments.') param clusterName string @description('The principal ID to assign the role to.') param principalId string @description('The principal type to assign the role to.') @allowed(['Device','ForeignGroup','Group','ServicePrincipal','User']) param principalType string = 'User' var aksClusterAdminRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b') resource aksRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { scope: aksCluster // Use when specifying a scope that is different than the deployment scope name: guid(subscription().id, resourceGroup().id, principalId, aksClusterAdminRole) properties: { roleDefinitionId: aksClusterAdminRole principalType: principalType principalId: principalId } } resource aksCluster 'Microsoft.ContainerService/managedClusters@2023-11-01' existing = { name: clusterName } ================================================ FILE: infra/core/security/configstore-access.bicep ================================================ @description('Name of Azure App Configuration store') param configStoreName string @description('The principal ID of the service principal to assign the role to') param principalId string resource configStore 'Microsoft.AppConfiguration/configurationStores@2023-03-01' existing = { name: configStoreName } var configStoreDataReaderRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '516239f1-63e1-4d78-a4de-a74fb236a071') resource configStoreDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(subscription().id, resourceGroup().id, principalId, configStoreDataReaderRole) scope: configStore properties: { roleDefinitionId: configStoreDataReaderRole principalId: principalId principalType: 'ServicePrincipal' } } ================================================ FILE: infra/core/security/keyvault-access.bicep ================================================ metadata description = 'Assigns an Azure Key Vault access policy.' param name string = 'add' param keyVaultName string param permissions object = { secrets: [ 'get', 'list' ] } param principalId string resource keyVaultAccessPolicies 'Microsoft.KeyVault/vaults/accessPolicies@2022-11-01' = { parent: keyVault name: name properties: { accessPolicies: [ { objectId: principalId tenantId: subscription().tenantId permissions: permissions } ] } } resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' existing = { name: keyVaultName } ================================================ FILE: infra/core/security/keyvault-secret.bicep ================================================ metadata description = 'Creates or updates a secret in an Azure Key Vault.' param name string param tags object = {} param keyVaultName string param contentType string = 'string' @description('The value of the secret. Provide only derived values like blob storage access, but do not hard code any secrets in your templates') @secure() param secretValue string param enabled bool = true param exp int = 0 param nbf int = 0 resource keyVaultSecret 'Microsoft.KeyVault/vaults/secrets@2022-11-01' = { name: name tags: tags parent: keyVault properties: { attributes: { enabled: enabled exp: exp nbf: nbf } contentType: contentType value: secretValue } } resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' existing = { name: keyVaultName } ================================================ FILE: infra/core/security/keyvault.bicep ================================================ metadata description = 'Creates an Azure Key Vault.' param name string param location string = resourceGroup().location param tags object = {} param logAnalyticsWorkspaceId string = '' param principalId string = '' @description('Allow the key vault to be used during resource creation.') param enabledForDeployment bool = false @description('Allow the key vault to be used for template deployment.') param enabledForTemplateDeployment bool = false resource keyVault 'Microsoft.KeyVault/vaults@2022-11-01' = { name: name location: location tags: tags properties: { tenantId: subscription().tenantId sku: { family: 'A', name: 'standard' } accessPolicies: !empty(principalId) ? [ { objectId: principalId permissions: { secrets: [ 'get', 'list' ] } tenantId: subscription().tenantId } ] : [] enabledForDeployment: enabledForDeployment enabledForTemplateDeployment: enabledForTemplateDeployment } } resource keyVault_DiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!(empty(logAnalyticsWorkspaceId))) { scope: keyVault name: 'keyVaultDiagnosticSettings' properties: { workspaceId: logAnalyticsWorkspaceId logs: [ { category: 'AuditEvent' enabled: true } ] metrics: [ { category: 'AllMetrics' enabled: true } ] } } output endpoint string = keyVault.properties.vaultUri output id string = keyVault.id output name string = keyVault.name ================================================ FILE: infra/core/security/registry-access.bicep ================================================ metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.' param containerRegistryName string param principalId string var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { scope: containerRegistry // Use when specifying a scope that is different than the deployment scope name: guid(subscription().id, resourceGroup().id, principalId, acrPullRole) properties: { roleDefinitionId: acrPullRole principalType: 'ServicePrincipal' principalId: principalId } } resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = { name: containerRegistryName } ================================================ FILE: infra/core/security/role.bicep ================================================ metadata description = 'Creates a role assignment for a service principal.' param principalId string @allowed([ 'Device' 'ForeignGroup' 'Group' 'ServicePrincipal' 'User' ]) param principalType string = 'ServicePrincipal' param roleDefinitionId string resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId) properties: { principalId: principalId principalType: principalType roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionId) } } ================================================ FILE: infra/core/storage/storage-account.bicep ================================================ metadata description = 'Creates an Azure storage account.' param name string param location string = resourceGroup().location param tags object = {} @allowed([ 'Cool' 'Hot' 'Premium' ]) param accessTier string = 'Hot' param allowBlobPublicAccess bool = true param allowCrossTenantReplication bool = true param allowSharedKeyAccess bool = true param containers array = [] param corsRules array = [] param defaultToOAuthAuthentication bool = false param deleteRetentionPolicy object = {} @allowed([ 'AzureDnsZone', 'Standard' ]) param dnsEndpointType string = 'Standard' param files array = [] param isHnsEnabled bool = false param kind string = 'StorageV2' param minimumTlsVersion string = 'TLS1_2' param queues array = [] param shareDeleteRetentionPolicy object = {} param supportsHttpsTrafficOnly bool = true param tables array = [] param networkAcls object = { bypass: 'AzureServices' defaultAction: 'Allow' } @allowed([ 'Enabled', 'Disabled' ]) param publicNetworkAccess string = 'Enabled' param sku object = { name: 'Standard_LRS' } resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { name: name location: location tags: tags kind: kind sku: sku properties: { accessTier: accessTier allowBlobPublicAccess: allowBlobPublicAccess allowCrossTenantReplication: allowCrossTenantReplication allowSharedKeyAccess: allowSharedKeyAccess defaultToOAuthAuthentication: defaultToOAuthAuthentication dnsEndpointType: dnsEndpointType isHnsEnabled: isHnsEnabled minimumTlsVersion: minimumTlsVersion networkAcls: networkAcls publicNetworkAccess: publicNetworkAccess supportsHttpsTrafficOnly: supportsHttpsTrafficOnly } resource blobServices 'blobServices' = if (!empty(containers)) { name: 'default' properties: { cors: { corsRules: corsRules } deleteRetentionPolicy: deleteRetentionPolicy } resource container 'containers' = [for container in containers: { name: container.name properties: { publicAccess: contains(container, 'publicAccess') ? container.publicAccess : 'None' } }] } resource fileServices 'fileServices' = if (!empty(files)) { name: 'default' properties: { cors: { corsRules: corsRules } shareDeleteRetentionPolicy: shareDeleteRetentionPolicy } } resource queueServices 'queueServices' = if (!empty(queues)) { name: 'default' properties: { } resource queue 'queues' = [for queue in queues: { name: queue.name properties: { metadata: {} } }] } resource tableServices 'tableServices' = if (!empty(tables)) { name: 'default' properties: {} } } output id string = storage.id output name string = storage.name output primaryEndpoints object = storage.properties.primaryEndpoints ================================================ FILE: infra/core/testing/loadtesting.bicep ================================================ param name string param location string = resourceGroup().location param managedIdentity bool = false param tags object = {} resource loadTest 'Microsoft.LoadTestService/loadTests@2022-12-01' = { name: name location: location tags: tags identity: { type: managedIdentity ? 'SystemAssigned' : 'None' } properties: { } } output loadTestingName string = loadTest.name ================================================ FILE: infra/main.bicep ================================================ targetScope = 'subscription' @minLength(1) @maxLength(64) @description('Name of the the environment which is used to generate a short unique hash used in all resources.') param environmentName string @minLength(1) @description('Primary location for all resources') param location string @description('Id of the user or app to assign application roles') param principalId string // Optional parameters to override the default azd resource naming conventions. // Add the following to main.parameters.json to provide values: // "resourceGroupName": { // "value": "myGroupName" // } param resourceGroupName string = '' param logAnalyticsName string = '' param applicationInsightsName string = '' param applicationInsightsDashboardName string = '' param keyVaultName string = '' param appServiceName string = '' param dbServerName string = '' param dbName string = '' @secure() param dbAdminPassword string @secure() param dbAppUserPassword string var abbrs = loadJsonContent('./abbreviations.json') // Tags that should be applied to all resources. // // Note that 'azd-service-name' tags should be applied separately to service host resources. // Example usage: // tags: union(tags, { 'azd-service-name': }) var tags = { 'azd-env-name': environmentName } // Generate a unique token to be used in naming resources. var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) // Name of the service defined in azure.yaml // A tag named azd-service-name with this value should be applied to the service host resource, such as: // Microsoft.Web/sites for appservice, function // Example usage: // tags: union(tags, { 'azd-service-name': apiServiceName }) var webServiceName = 'web' // Organize resources in a resource group resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' location: location tags: tags } // Add resources to be provisioned below. module monitoring 'core/monitor/monitoring.bicep' = { name: 'monitoring' params: { location: location tags: tags logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' } scope: rg } module keyVault 'core/security/keyvault.bicep' = { name: 'keyvault' params: { location: location tags: tags name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' principalId: principalId } scope: rg } module web 'services/web.bicep' = { name: 'web' params: { name: !empty(appServiceName) ? appServiceName : '${abbrs.webSitesAppService}${resourceToken}' location: location tags: tags serviceName: webServiceName applicationInsightsName: monitoring.outputs.applicationInsightsName keyVaultName: keyVault.outputs.name } scope: rg } //#if (UsePostgreSQL) module pgsqldatabase 'core/database/postgresql/flexibleserver.bicep' = { name: 'pgsql-database' params: { name: !empty(dbServerName) ? dbServerName : '${abbrs.postgreSQLServers}${resourceToken}' location: location tags: tags sku: { name: 'Standard_B1ms' tier: 'Burstable' } storage: { storageSizeGB: 32 } version: '14' appUserLogin: 'appUser' appUserLoginPassword: dbAppUserPassword administratorLogin: 'pgsqlAdmin' administratorLoginPassword: dbAdminPassword databaseName:!empty(dbName) ? dbName : '${abbrs.postgreSQLServersDatabases}${resourceToken}' allowAzureIPsFirewall: true keyVaultName: keyVault.outputs.name connectionStringKey: 'ConnectionStrings--CleanArchitectureDb' } scope: rg } //#endif //#if (UseSqlServer) module database 'core/database/sqlserver/sqlserver.bicep' = { name: 'database' params: { name: !empty(dbServerName) ? dbServerName : '${abbrs.sqlServers}${resourceToken}' location: location tags: tags databaseName: !empty(dbName) ? dbName : '${abbrs.sqlServersDatabases}${resourceToken}' keyVaultName: keyVault.outputs.name connectionStringKey: 'ConnectionStrings--CleanArchitectureDb' sqlAdminPassword: dbAdminPassword appUserPassword: dbAppUserPassword } scope: rg } //#endif module webKeyVaultAccess 'core/security/keyvault-access.bicep' = { name: 'webKeyVaultAccess' params: { keyVaultName: keyVault.outputs.name principalId: web.outputs.identityPrincipalId } scope: rg } // Add outputs from the deployment here, if needed. // // This allows the outputs to be referenced by other bicep deployments in the deployment pipeline, // or by the local machine as a way to reference created resources in Azure for local development. // Secrets should not be added here. // // Outputs are automatically saved in the local azd environment .env file. // To see these outputs, run `azd env get-values`, or `azd env get-values --output json` for json output. output AZURE_LOCATION string = location output AZURE_TENANT_ID string = tenant().tenantId output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.endpoint output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString //#if (UseSqlServer) output AZURE_SQL_CONNECTION_STRING_KEY string = database.outputs.connectionStringKey //#endif //#if (UsePostgreSQL) output AZURE_PSQL_CONNECTION_STRING_KEY string = pgsqldatabase.outputs.connectionStringKey //#endif output WEB_BASE_URI string = web.outputs.uri ================================================ FILE: infra/main.parameters.json ================================================ { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { "environmentName": { "value": "${AZURE_ENV_NAME}" }, "location": { "value": "${AZURE_LOCATION}" }, "principalId": { "value": "${AZURE_PRINCIPAL_ID}" }, "dbAdminPassword": { "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} dbAdminPassword)" }, "dbAppUserPassword": { "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} dbAppUserPassword)" } } } ================================================ FILE: infra/services/web.bicep ================================================ param name string param location string = resourceGroup().location param tags object = {} param serviceName string = 'web' param applicationInsightsName string = '' param keyVaultName string = '' module appServicePlan '../core/host/appserviceplan.bicep' = { name: 'appServicePlan' params: { name: name location: location tags: tags sku: { name: 'B1' } } } module appService '../core/host/appservice.bicep' = { name: 'appService' params: { name: name location: location tags: union(tags, { 'azd-service-name': serviceName }) appServicePlanId: appServicePlan.outputs.id applicationInsightsName: applicationInsightsName keyVaultName: keyVaultName runtimeName: 'dotnetcore' runtimeVersion: '10.0' healthCheckPath: '/health' appSettings: { ASPNETCORE_ENVIRONMENT: 'Development' } } } output name string = appService.outputs.name output uri string = appService.outputs.uri output identityPrincipalId string = appService.outputs.identityPrincipalId ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", ":semanticCommits", ":separateMajorReleases", "group:allNonMajor" ], "labels": [ "dependencies" ], "schedule": [ "before 6am on monday" ], "timezone": "Australia/Brisbane", "packageRules": [ { "description": "Automerge non-major updates", "matchUpdateTypes": [ "minor", "patch", "digest" ], "automerge": true }, { "description": "Group GitHub Actions updates", "matchManagers": [ "github-actions" ], "groupName": "GitHub Actions", "automerge": true }, { "description": "Group .NET updates", "matchManagers": [ "nuget" ], "groupName": ".NET dependencies" }, { "description": "Group Angular updates", "groupName": "Angular", "matchPackageNames": [ "/^@angular/" ] }, { "description": "Group React updates", "groupName": "React", "matchPackageNames": [ "/^react/", "/^@types/react/" ] }, { "description": "Group testing packages", "groupName": "Testing", "matchPackageNames": [ "/jest/", "/karma/", "/jasmine/", "/playwright/", "/nunit/", "/moq/", "/shouldly/" ] } ] } ================================================ FILE: src/AppHost/AppHost.csproj ================================================  Exe CleanArchitecture.AppHost CleanArchitecture.AppHost ================================================ FILE: src/AppHost/Program.cs ================================================ using CleanArchitecture.Shared; var builder = DistributedApplication.CreateBuilder(args); #if (UsePostgreSQL) var databaseServer = builder .AddPostgres(Services.DatabaseServer) .AddDatabase(Services.Database); #elif (UseSqlServer) var databaseServer = builder.AddSqlServer(Services.DatabaseServer) .AddDatabase(Services.Database); #else var databaseServer = builder .AddSqlite(Services.Database); #endif var web = builder.AddProject(Services.WebApi) .WithReference(databaseServer) .WaitFor(databaseServer) .WithUrlForEndpoint("http", url => { url.DisplayText = "Scalar API Reference"; url.Url = "/scalar"; }); #if (!UseApiOnly) builder.AddJavaScriptApp(Services.WebFrontend, "./../Web/ClientApp") .WithRunScript("start") .WithReference(web) .WaitFor(web) .WithHttpEndpoint(env: "PORT") .WithExternalHttpEndpoints() .PublishAsDockerFile(); #endif builder.Build().Run(); ================================================ FILE: src/AppHost/Properties/launchSettings.json ================================================ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://cleanarchitecture.dev.localhost:17000;http://cleanarchitecture.dev.localhost:15000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://cleanarchitecture.dev.localhost:15000", "environmentVariables": { "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } }, "manifest": { "commandName": "Project", "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", "dotnetRunMessages": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Production", "DOTNET_ENVIRONMENT": "Production" } } } } ================================================ FILE: src/AppHost/appsettings.Development.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } } ================================================ FILE: src/AppHost/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } } } ================================================ FILE: src/Application/Application.csproj ================================================  CleanArchitecture.Application CleanArchitecture.Application ================================================ FILE: src/Application/Common/Behaviours/AuthorizationBehaviour.cs ================================================ using System.Reflection; using CleanArchitecture.Application.Common.Exceptions; using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Security; namespace CleanArchitecture.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(); } } ================================================ FILE: src/Application/Common/Behaviours/LoggingBehaviour.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using MediatR.Pipeline; using Microsoft.Extensions.Logging; namespace CleanArchitecture.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("CleanArchitecture Request: {Name} {@UserId} {@UserName} {@Request}", requestName, userId, userName, request); } } ================================================ FILE: src/Application/Common/Behaviours/PerformanceBehaviour.cs ================================================ using System.Diagnostics; using CleanArchitecture.Application.Common.Interfaces; using Microsoft.Extensions.Logging; namespace CleanArchitecture.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("CleanArchitecture Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}", requestName, elapsedMilliseconds, userId, userName, request); } return response; } } ================================================ FILE: src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs ================================================ using Microsoft.Extensions.Logging; namespace CleanArchitecture.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, "CleanArchitecture Request: Unhandled Exception for Request {Name} {@Request}", requestName, request); throw; } } } ================================================ FILE: src/Application/Common/Behaviours/ValidationBehaviour.cs ================================================ using ValidationException = CleanArchitecture.Application.Common.Exceptions.ValidationException; namespace CleanArchitecture.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(); } } ================================================ FILE: src/Application/Common/Exceptions/ForbiddenAccessException.cs ================================================ namespace CleanArchitecture.Application.Common.Exceptions; public class ForbiddenAccessException : Exception { public ForbiddenAccessException() : base() { } } ================================================ FILE: src/Application/Common/Exceptions/ValidationException.cs ================================================ using FluentValidation.Results; namespace CleanArchitecture.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; } } ================================================ FILE: src/Application/Common/Interfaces/IApplicationDbContext.cs ================================================ using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.Common.Interfaces; public interface IApplicationDbContext { DbSet TodoLists { get; } DbSet TodoItems { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } ================================================ FILE: src/Application/Common/Interfaces/IIdentityService.cs ================================================ using CleanArchitecture.Application.Common.Models; namespace CleanArchitecture.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); } ================================================ FILE: src/Application/Common/Interfaces/IUser.cs ================================================ namespace CleanArchitecture.Application.Common.Interfaces; public interface IUser { string? Id { get; } List? Roles { get; } } ================================================ FILE: src/Application/Common/Mappings/MappingExtensions.cs ================================================ using CleanArchitecture.Application.Common.Models; namespace CleanArchitecture.Application.Common.Mappings; public static class MappingExtensions { public static Task> PaginatedListAsync(this IQueryable queryable, int pageNumber, int pageSize, CancellationToken cancellationToken = default) where TDestination : class => PaginatedList.CreateAsync(queryable.AsNoTracking(), pageNumber, pageSize, cancellationToken); public static Task> ProjectToListAsync(this IQueryable queryable, IConfigurationProvider configuration, CancellationToken cancellationToken = default) where TDestination : class => queryable.ProjectTo(configuration).AsNoTracking().ToListAsync(cancellationToken); } ================================================ FILE: src/Application/Common/Models/LookupDto.cs ================================================ using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.Common.Models; public class LookupDto { public int Id { get; init; } public string? Title { get; init; } private class Mapping : Profile { public Mapping() { CreateMap(); CreateMap(); } } } ================================================ FILE: src/Application/Common/Models/PaginatedList.cs ================================================ namespace CleanArchitecture.Application.Common.Models; public class PaginatedList { public IReadOnlyCollection Items { get; } public int PageNumber { get; } public int TotalPages { get; } public int TotalCount { get; } public PaginatedList(IReadOnlyCollection items, int count, int pageNumber, int pageSize) { PageNumber = pageNumber; TotalPages = (int)Math.Ceiling(count / (double)pageSize); TotalCount = count; Items = items; } public bool HasPreviousPage => PageNumber > 1 && PageNumber <= TotalPages + 1; public bool HasNextPage => PageNumber < TotalPages; public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize, CancellationToken cancellationToken = default) { var count = await source.CountAsync(cancellationToken); var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); return new PaginatedList(items, count, pageNumber, pageSize); } } ================================================ FILE: src/Application/Common/Models/Result.cs ================================================ namespace CleanArchitecture.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); } } ================================================ FILE: src/Application/Common/Security/AuthorizeAttribute.cs ================================================ namespace CleanArchitecture.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; } ================================================ FILE: src/Application/DependencyInjection.cs ================================================ using System.Reflection; using CleanArchitecture.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<,>)); }); } } ================================================ FILE: src/Application/GlobalUsings.cs ================================================ global using Ardalis.GuardClauses; global using AutoMapper; global using AutoMapper.QueryableExtensions; global using Microsoft.EntityFrameworkCore; global using FluentValidation; global using MediatR; ================================================ FILE: src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Events; namespace CleanArchitecture.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 }; entity.AddDomainEvent(new TodoItemCreatedEvent(entity)); _context.TodoItems.Add(entity); await _context.SaveChangesAsync(cancellationToken); return entity.Id; } } ================================================ FILE: src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs ================================================ namespace CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; public class CreateTodoItemCommandValidator : AbstractValidator { public CreateTodoItemCommandValidator() { RuleFor(v => v.Title) .MaximumLength(200) .NotEmpty(); } } ================================================ FILE: src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Events; namespace CleanArchitecture.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(new object[] { request.Id }, cancellationToken); Guard.Against.NotFound(request.Id, entity); _context.TodoItems.Remove(entity); entity.AddDomainEvent(new TodoItemDeletedEvent(entity)); await _context.SaveChangesAsync(cancellationToken); } } ================================================ FILE: src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.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(new object[] { request.Id }, cancellationToken); Guard.Against.NotFound(request.Id, entity); entity.Title = request.Title; entity.Done = request.Done; await _context.SaveChangesAsync(cancellationToken); } } ================================================ FILE: src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs ================================================ namespace CleanArchitecture.Application.TodoItems.Commands.UpdateTodoItem; public class UpdateTodoItemCommandValidator : AbstractValidator { public UpdateTodoItemCommandValidator() { RuleFor(v => v.Title) .MaximumLength(200) .NotEmpty(); } } ================================================ FILE: src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.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(new object[] { 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); } } ================================================ FILE: src/Application/TodoItems/EventHandlers/LogTodoItemCompleted.cs ================================================ using CleanArchitecture.Domain.Events; using Microsoft.Extensions.Logging; namespace CleanArchitecture.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("CleanArchitecture Domain Event: {DomainEvent}", notification.GetType().Name); return Task.CompletedTask; } } ================================================ FILE: src/Application/TodoItems/EventHandlers/LogTodoItemCreated.cs ================================================ using CleanArchitecture.Domain.Events; using Microsoft.Extensions.Logging; namespace CleanArchitecture.Application.TodoItems.EventHandlers; public class LogTodoItemCreated : INotificationHandler { private readonly ILogger _logger; public LogTodoItemCreated(ILogger logger) { _logger = logger; } public Task Handle(TodoItemCreatedEvent notification, CancellationToken cancellationToken) { _logger.LogInformation("CleanArchitecture Domain Event: {DomainEvent}", notification.GetType().Name); return Task.CompletedTask; } } ================================================ FILE: src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Mappings; using CleanArchitecture.Application.Common.Models; namespace CleanArchitecture.Application.TodoItems.Queries.GetTodoItemsWithPagination; public record GetTodoItemsWithPaginationQuery : IRequest> { public int ListId { get; init; } public int PageNumber { get; init; } = 1; public int PageSize { get; init; } = 10; } public class GetTodoItemsWithPaginationQueryHandler : IRequestHandler> { private readonly IApplicationDbContext _context; private readonly IMapper _mapper; public GetTodoItemsWithPaginationQueryHandler(IApplicationDbContext context, IMapper mapper) { _context = context; _mapper = mapper; } public async Task> Handle(GetTodoItemsWithPaginationQuery request, CancellationToken cancellationToken) { return await _context.TodoItems .Where(x => x.ListId == request.ListId) .OrderBy(x => x.Title) .ProjectTo(_mapper.ConfigurationProvider) .PaginatedListAsync(request.PageNumber, request.PageSize, cancellationToken); } } ================================================ FILE: src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs ================================================ namespace CleanArchitecture.Application.TodoItems.Queries.GetTodoItemsWithPagination; public class GetTodoItemsWithPaginationQueryValidator : AbstractValidator { public GetTodoItemsWithPaginationQueryValidator() { RuleFor(x => x.ListId) .NotEmpty().WithMessage("ListId is required."); RuleFor(x => x.PageNumber) .GreaterThanOrEqualTo(1).WithMessage("PageNumber at least greater than or equal to 1."); RuleFor(x => x.PageSize) .GreaterThanOrEqualTo(1).WithMessage("PageSize at least greater than or equal to 1."); } } ================================================ FILE: src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs ================================================ using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.TodoItems.Queries.GetTodoItemsWithPagination; public class TodoItemBriefDto { public int Id { get; init; } public int ListId { get; init; } public string? Title { get; init; } public bool Done { get; init; } private class Mapping : Profile { public Mapping() { CreateMap(); } } } ================================================ FILE: src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; public record CreateTodoListCommand : IRequest { public string? Title { 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(); entity.Title = request.Title; _context.TodoLists.Add(entity); await _context.SaveChangesAsync(cancellationToken); return entity.Id; } } ================================================ FILE: src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.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); } } ================================================ FILE: src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.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); } } ================================================ FILE: src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Security; using CleanArchitecture.Domain.Constants; namespace CleanArchitecture.Application.TodoLists.Commands.PurgeTodoLists; [Authorize(Roles = Roles.Administrator)] [Authorize(Policy = Policies.CanPurge)] public record PurgeTodoListsCommand : IRequest; public class PurgeTodoListsCommandHandler : IRequestHandler { private readonly IApplicationDbContext _context; public PurgeTodoListsCommandHandler(IApplicationDbContext context) { _context = context; } public async Task Handle(PurgeTodoListsCommand request, CancellationToken cancellationToken) { _context.TodoLists.RemoveRange(_context.TodoLists); await _context.SaveChangesAsync(cancellationToken); } } ================================================ FILE: src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.Application.TodoLists.Commands.UpdateTodoList; public record UpdateTodoListCommand : IRequest { public int Id { get; init; } public string? Title { 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(new object[] { request.Id }, cancellationToken); Guard.Against.NotFound(request.Id, entity); entity.Title = request.Title; await _context.SaveChangesAsync(cancellationToken); } } ================================================ FILE: src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.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); } } ================================================ FILE: src/Application/TodoLists/Queries/GetTodos/GetTodos.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Models; using CleanArchitecture.Application.Common.Security; using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.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(), Lists = await _context.TodoLists .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .OrderBy(t => t.Title) .ToListAsync(cancellationToken) }; } } ================================================ FILE: src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs ================================================ using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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)); } } } ================================================ FILE: src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs ================================================ using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.TodoLists.Queries.GetTodos; public class TodoListDto { public TodoListDto() { Items = Array.Empty(); } 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(); } } } ================================================ FILE: src/Application/TodoLists/Queries/GetTodos/TodosVm.cs ================================================ using CleanArchitecture.Application.Common.Models; namespace CleanArchitecture.Application.TodoLists.Queries.GetTodos; public class TodosVm { public IReadOnlyCollection PriorityLevels { get; init; } = Array.Empty(); public IReadOnlyCollection Lists { get; init; } = Array.Empty(); } ================================================ FILE: src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs ================================================ namespace CleanArchitecture.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)] }); } } ================================================ FILE: src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs ================================================ namespace CleanArchitecture.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; } ================================================ FILE: src/Domain/Common/BaseAuditableEntity.cs ================================================ namespace CleanArchitecture.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; } } ================================================ FILE: src/Domain/Common/BaseEntity.cs ================================================ using System.ComponentModel.DataAnnotations.Schema; namespace CleanArchitecture.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(); } } ================================================ FILE: src/Domain/Common/BaseEvent.cs ================================================ using MediatR; namespace CleanArchitecture.Domain.Common; public abstract class BaseEvent : INotification { } ================================================ FILE: src/Domain/Common/ValueObject.cs ================================================ namespace CleanArchitecture.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); } } ================================================ FILE: src/Domain/Constants/Policies.cs ================================================ namespace CleanArchitecture.Domain.Constants; public abstract class Policies { public const string CanPurge = nameof(CanPurge); } ================================================ FILE: src/Domain/Constants/Roles.cs ================================================ namespace CleanArchitecture.Domain.Constants; public abstract class Roles { public const string Administrator = nameof(Administrator); } ================================================ FILE: src/Domain/Domain.csproj ================================================  CleanArchitecture.Domain CleanArchitecture.Domain ================================================ FILE: src/Domain/Entities/TodoItem.cs ================================================ namespace CleanArchitecture.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; } public DateTime? Reminder { 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!; } ================================================ FILE: src/Domain/Entities/TodoList.cs ================================================ namespace CleanArchitecture.Domain.Entities; public class TodoList : BaseAuditableEntity { public string? Title { get; set; } public Colour Colour { get; set; } = Colour.White; public IList Items { get; private set; } = new List(); } ================================================ FILE: src/Domain/Enums/PriorityLevel.cs ================================================ namespace CleanArchitecture.Domain.Enums; public enum PriorityLevel { None = 0, Low = 1, Medium = 2, High = 3 } ================================================ FILE: src/Domain/Events/TodoItemCompletedEvent.cs ================================================ namespace CleanArchitecture.Domain.Events; public class TodoItemCompletedEvent : BaseEvent { public TodoItemCompletedEvent(TodoItem item) { Item = item; } public TodoItem Item { get; } } ================================================ FILE: src/Domain/Events/TodoItemCreatedEvent.cs ================================================ namespace CleanArchitecture.Domain.Events; public class TodoItemCreatedEvent : BaseEvent { public TodoItemCreatedEvent(TodoItem item) { Item = item; } public TodoItem Item { get; } } ================================================ FILE: src/Domain/Events/TodoItemDeletedEvent.cs ================================================ namespace CleanArchitecture.Domain.Events; public class TodoItemDeletedEvent : BaseEvent { public TodoItemDeletedEvent(TodoItem item) { Item = item; } public TodoItem Item { get; } } ================================================ FILE: src/Domain/Exceptions/UnsupportedColourException.cs ================================================ namespace CleanArchitecture.Domain.Exceptions; public class UnsupportedColourException : Exception { public UnsupportedColourException(string code) : base($"Colour \"{code}\" is unsupported.") { } } ================================================ FILE: src/Domain/GlobalUsings.cs ================================================ global using CleanArchitecture.Domain.Common; global using CleanArchitecture.Domain.Entities; global using CleanArchitecture.Domain.Enums; global using CleanArchitecture.Domain.Events; global using CleanArchitecture.Domain.Exceptions; global using CleanArchitecture.Domain.ValueObjects; ================================================ FILE: src/Domain/ValueObjects/Colour.cs ================================================ namespace CleanArchitecture.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 White => new("#FFFFFF"); public static Colour Red => new("#FF5733"); public static Colour Orange => new("#FFC300"); public static Colour Yellow => new("#FFFF66"); public static Colour Green => new("#CCFF99"); public static Colour Blue => new("#6666FF"); public static Colour Purple => new("#9966CC"); public static Colour Grey => new("#999999"); 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; } protected static IEnumerable SupportedColours { get { yield return White; yield return Red; yield return Orange; yield return Yellow; yield return Green; yield return Blue; yield return Purple; yield return Grey; } } protected override IEnumerable GetEqualityComponents() { yield return Code; } } ================================================ FILE: src/Infrastructure/Data/ApplicationDbContext.cs ================================================ using System.Reflection; using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Infrastructure.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace CleanArchitecture.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()); } } ================================================ FILE: src/Infrastructure/Data/ApplicationDbContextInitialiser.cs ================================================ using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Infrastructure.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace CleanArchitecture.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 = "Todo List", 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(); } } } ================================================ FILE: src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs ================================================ using CleanArchitecture.Domain.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CleanArchitecture.Infrastructure.Data.Configurations; public class TodoItemConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.Property(t => t.Title) .HasMaxLength(200) .IsRequired(); } } ================================================ FILE: src/Infrastructure/Data/Configurations/TodoListConfiguration.cs ================================================ using CleanArchitecture.Domain.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CleanArchitecture.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); } } ================================================ FILE: src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; namespace CleanArchitecture.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)); } ================================================ FILE: src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs ================================================ using CleanArchitecture.Domain.Common; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; namespace CleanArchitecture.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); } } ================================================ FILE: src/Infrastructure/DependencyInjection.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Domain.Constants; using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.Infrastructure.Data.Interceptors; using CleanArchitecture.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()); #if (UsePostgreSQL) options.UseNpgsql(connectionString); #elif (UseSqlServer) options.UseSqlServer(connectionString); #else options.UseSqlite(connectionString); #endif options.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); }); #if UsePostgreSQL builder.EnrichNpgsqlDbContext(); #elif (UseSqlServer) builder.EnrichSqlServerDbContext(); #endif builder.Services.AddScoped(provider => provider.GetRequiredService()); builder.Services.AddScoped(); #if (UseApiOnly) builder.Services.AddAuthentication() .AddBearerToken(IdentityConstants.BearerScheme); builder.Services.AddAuthorizationBuilder(); builder.Services .AddIdentityCore() .AddRoles() .AddEntityFrameworkStores() .AddApiEndpoints(); #else builder.Services.AddAuthentication(options => { options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme; }) .AddIdentityCookies(); builder.Services.AddAuthorizationBuilder(); builder.Services .AddIdentityCore() .AddRoles() .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders() .AddApiEndpoints(); #endif builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddTransient(); builder.Services.AddAuthorization(options => options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator))); } } ================================================ FILE: src/Infrastructure/GlobalUsings.cs ================================================ global using Ardalis.GuardClauses; global using CleanArchitecture.Shared; ================================================ FILE: src/Infrastructure/Identity/ApplicationUser.cs ================================================ using Microsoft.AspNetCore.Identity; namespace CleanArchitecture.Infrastructure.Identity; public class ApplicationUser : IdentityUser { } ================================================ FILE: src/Infrastructure/Identity/IdentityResultExtensions.cs ================================================ using CleanArchitecture.Application.Common.Models; using Microsoft.AspNetCore.Identity; namespace CleanArchitecture.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)); } } ================================================ FILE: src/Infrastructure/Identity/IdentityService.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace CleanArchitecture.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(); } } ================================================ FILE: src/Infrastructure/Infrastructure.csproj ================================================  CleanArchitecture.Infrastructure CleanArchitecture.Infrastructure ================================================ FILE: src/ServiceDefaults/Extensions.cs ================================================ 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; } } ================================================ FILE: src/ServiceDefaults/ServiceDefaults.csproj ================================================  true CleanArchitecture.ServiceDefaults CleanArchitecture.ServiceDefaults ================================================ FILE: src/Shared/Services.cs ================================================ namespace CleanArchitecture.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 = "CleanArchitectureDb"; } ================================================ FILE: src/Shared/Shared.csproj ================================================  CleanArchitecture.Shared CleanArchitecture.Shared net10.0 enable enable ================================================ FILE: src/Web/ClientApp/.editorconfig ================================================ # Editor configuration, see http://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.ts] quote_type = single [*.md] max_line_length = off trim_trailing_whitespace = false [*.{razor,cshtml}] charset = utf-8-bom ================================================ FILE: src/Web/ClientApp/.gitignore ================================================ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output /dist /dist-server /tmp /out-tsc /.angular # dependencies /node_modules # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # misc /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log yarn-error.log testem.log /typings # System Files .DS_Store Thumbs.db ================================================ FILE: src/Web/ClientApp/Dockerfile ================================================ FROM node:24 as build WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/default.conf.template /etc/nginx/templates/default.conf.template COPY --from=build /app/dist/weather/browser /usr/share/nginx/html # Expose the default nginx port EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ================================================ FILE: src/Web/ClientApp/README.md ================================================ # CleanArchitecture.Web This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 21.1.5. ## Development server Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. ## Code scaffolding Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. ## Build Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. ## Running unit tests Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). ## Running end-to-end tests Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. ## Further help To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. ================================================ FILE: src/Web/ClientApp/angular.json ================================================ { "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects", "projects": { "CleanArchitecture.Web": { "projectType": "application", "schematics": { "@schematics/angular:application": { "strict": true } }, "root": "", "sourceRoot": "src", "prefix": "app", "architect": { "build": { "builder": "@angular-devkit/build-angular:application", "options": { "outputPath": "dist", "index": "src/index.html", "browser": "src/main.ts", "polyfills": [ "zone.js" ], "tsConfig": "tsconfig.app.json", "allowedCommonJsDependencies": [ "oidc-client" ], "assets": [ "src/assets", { "glob": "favicon.png", "input": "src", "output": "/" } ], "styles": [ "src/styles.scss" ], "scripts": [] }, "configurations": { "production": { "budgets": [ { "type": "initial", "maximumWarning": "1mb", "maximumError": "5mb" }, { "type": "anyComponentStyle", "maximumWarning": "2kb", "maximumError": "4kb" } ], "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" } ], "outputHashing": "all" }, "development": { "optimization": false, "extractLicenses": false, "sourceMap": true, "namedChunks": true } }, "defaultConfiguration": "production" }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", "configurations": { "production": { "buildTarget": "CleanArchitecture.Web:build:production" }, "development": { "proxyConfig": "proxy.conf.js", "buildTarget": "CleanArchitecture.Web:build:development" } }, "defaultConfiguration": "development" }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n", "options": { "buildTarget": "CleanArchitecture.Web:build" } }, "test": { "builder": "@angular-devkit/build-angular:karma", "options": { "polyfills": [ "zone.js", "zone.js/testing" ], "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", "assets": [ "src/assets" ], "styles": [ "src/styles.scss" ], "scripts": [] } } } } }, "cli": { "analytics": false } } ================================================ FILE: src/Web/ClientApp/default.conf.template ================================================ server { listen ${PORT}; listen [::]:${PORT}; server_name localhost; access_log /var/log/nginx/server.access.log main; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass ${services__web__https__0}; proxy_http_version 1.1; proxy_ssl_server_name on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ================================================ FILE: src/Web/ClientApp/karma.conf.js ================================================ // Karma configuration file, see link for more information // https://karma-runner.github.io/1.0/config/configuration-file.html module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular-devkit/build-angular'], plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage'), require('@angular-devkit/build-angular/plugins/karma') ], client: { jasmine: { // you can add configuration options for Jasmine here // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html // for example, you can disable the random execution with `random: false` // or set a specific seed with `seed: 4321` }, clearContext: false // leave Jasmine Spec Runner output visible in browser }, jasmineHtmlReporter: { suppressAll: true // removes the duplicated traces }, coverageReporter: { dir: require('path').join(__dirname, './coverage/angularapp'), subdir: '.', reporters: [ { type: 'html' }, { type: 'text-summary' } ] }, reporters: ['progress', 'kjhtml'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], singleRun: false, restartOnFileChange: true }); }; ================================================ FILE: src/Web/ClientApp/nswag.json ================================================ { "runtime": "Net100", "defaultVariables": null, "documentGenerator": { "fromDocument": { "json": null, "url": "../wwwroot/openapi/v1.json", "output": null, "newLineBehavior": "Auto" } }, "codeGenerators": { "openApiToTypeScriptClient": { "className": "{controller}Client", "moduleName": "", "namespace": "", "typeScriptVersion": 4.3, "template": "Angular", "promiseType": "Promise", "httpClass": "HttpClient", "withCredentials": false, "useSingletonProvider": true, "injectionTokenType": "InjectionToken", "rxJsVersion": 7.0, "dateTimeType": "Date", "nullValue": "Undefined", "generateClientClasses": true, "generateClientInterfaces": true, "generateOptionalParameters": false, "exportTypes": true, "wrapDtoExceptions": false, "exceptionClass": "SwaggerException", "clientBaseClass": null, "wrapResponses": false, "wrapResponseMethods": [], "generateResponseClasses": true, "responseClass": "SwaggerResponse", "protectedMethods": [], "configurationClass": null, "useTransformOptionsMethod": false, "useTransformResultMethod": false, "generateDtoTypes": true, "operationGenerationMode": "MultipleClientsFromFirstTagAndOperationId", "includedOperationIds": [], "excludedOperationIds": [], "markOptionalProperties": true, "generateCloneMethod": false, "typeStyle": "Class", "enumStyle": "Enum", "useLeafType": false, "classTypes": [], "extendedClasses": [], "extensionCode": null, "generateDefaultValues": true, "excludedTypeNames": [], "excludedParameterNames": [], "handleReferences": false, "generateTypeCheckFunctions": false, "generateConstructorInterface": true, "convertConstructorInterfaceData": false, "importRequiredTypes": true, "useGetBaseUrlMethod": false, "baseUrlTokenName": "API_BASE_URL", "queryNullValue": "", "useAbortSignal": false, "inlineNamedDictionaries": false, "inlineNamedAny": false, "includeHttpContext": false, "templateDirectory": null, "serviceHost": null, "serviceSchemes": null, "output": "src/app/web-api-client.ts", "newLineBehavior": "Auto" } } } ================================================ FILE: src/Web/ClientApp/package.json ================================================ { "name": "cleanarchitecture.web", "version": "0.0.0", "scripts": { "ng": "ng", "start": "run-script-os", "prestart": "npm run generate-api", "start:windows": "ng serve --port %PORT%", "start:default": "ng serve --port $PORT", "prebuild": "npm run generate-api", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", "generate-api": "nswag run /runtime:Net100" }, "private": true, "dependencies": { "@angular/animations": "^21.1.5", "@angular/common": "^21.1.5", "@angular/compiler": "^21.1.5", "@angular/core": "^21.1.5", "@angular/forms": "^21.1.5", "@angular/platform-browser": "^21.1.5", "@angular/platform-browser-dynamic": "^21.1.5", "@angular/platform-server": "^21.1.5", "@angular/router": "^21.1.5", "@picocss/pico": "^2.0.0", "lucide-angular": "^0.577.0", "run-script-os": "^1.1.6", "rxjs": "~7.8.1", "tslib": "^2.5.0", "zone.js": "~0.16.0" }, "devDependencies": { "@angular-devkit/build-angular": "^21.1.5", "@angular/cli": "^21.1.5", "@angular/compiler-cli": "^21.1.5", "@types/jasmine": "~6.0.0", "@types/node": "^24.0.0", "jasmine-core": "~6.1.0", "karma": "~6.4.2", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.2.0", "nswag": "latest", "typescript": "~5.9.3" } } ================================================ FILE: src/Web/ClientApp/proxy.conf.js ================================================ const { env } = require('process'); const target = env["services__webapi__https__0"] || env["services__webapi__http__0"]; const PROXY_CONFIG = [ { context: [ "/api", "/openapi", "/scalar", "/weatherforecast", "/WeatherForecast" ], target: target, secure: env["NODE_ENV"] !== "development", } ]; module.exports = PROXY_CONFIG; ================================================ FILE: src/Web/ClientApp/src/api-authorization/auth.guard.ts ================================================ import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router'; import { Observable } from 'rxjs'; import { tap, take } from 'rxjs/operators'; import { AuthService } from './auth.service'; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { constructor(private authService: AuthService, private router: Router) {} canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return this.authService.isAuthenticated$.pipe( take(1), tap(isAuthenticated => { if (!isAuthenticated) { this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); } }) ); } } ================================================ FILE: src/Web/ClientApp/src/api-authorization/auth.service.ts ================================================ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { tap, catchError, map } from 'rxjs/operators'; import { LoginRequest, RegisterRequest, UsersClient } from '../app/web-api-client'; @Injectable({ providedIn: 'root' }) export class AuthService { private _isAuthenticated = new BehaviorSubject(false); isAuthenticated$ = this._isAuthenticated.asObservable(); constructor(private usersClient: UsersClient) {} initialize(): Observable { return this.usersClient.infoGET().pipe( map(() => true), catchError(() => of(false)), tap(isAuth => this._isAuthenticated.next(isAuth)) ); } login(email: string, password: string): Observable { return this.usersClient.login(true, undefined, new LoginRequest({ email, password })).pipe( tap(() => this._isAuthenticated.next(true)), map(() => void 0) ); } register(email: string, password: string): Observable { return this.usersClient.register(new RegisterRequest({ email, password })); } logout(): Observable { return this.usersClient.logout({}).pipe( tap(() => this._isAuthenticated.next(false)) ); } } ================================================ FILE: src/Web/ClientApp/src/api-authorization/authorize.interceptor.spec.ts ================================================ import { TestBed, inject } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { AuthorizeInterceptor } from './authorize.interceptor'; describe('AuthorizeInterceptor', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ AuthorizeInterceptor, provideRouter([]) ] }); }); it('should be created', inject([AuthorizeInterceptor], (service: AuthorizeInterceptor) => { expect(service).toBeTruthy(); })); }); ================================================ FILE: src/Web/ClientApp/src/api-authorization/authorize.interceptor.ts ================================================ import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { Router } from '@angular/router'; @Injectable({ providedIn: 'root' }) export class AuthorizeInterceptor implements HttpInterceptor { constructor(private router: Router) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { const authReq = req.clone({ withCredentials: true }); return next.handle(authReq).pipe( catchError(error => { if (error instanceof HttpErrorResponse && error.status === 401 && !error.url?.includes('/manage/info') && !this.router.url.startsWith('/login')) { this.router.navigate(['/login'], { queryParams: { returnUrl: window.location.pathname } }); } return throwError(() => error); }) ); } } ================================================ FILE: src/Web/ClientApp/src/api-authorization/login/login.component.html ================================================

Log in

@if (invalid) { Invalid email or password. }

Don't have an account? Register

================================================ FILE: src/Web/ClientApp/src/api-authorization/login/login.component.ts ================================================ import { Component, ChangeDetectorRef } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { AuthService } from '../auth.service'; import { firstValueFrom } from 'rxjs'; @Component({ standalone: false, selector: 'app-login', templateUrl: './login.component.html' }) export class LoginComponent { email = ''; password = ''; invalid = false; constructor( private authService: AuthService, private router: Router, private route: ActivatedRoute, private cdr: ChangeDetectorRef ) {} async login() { this.invalid = false; try { await firstValueFrom(this.authService.login(this.email, this.password)); const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/'; await this.router.navigateByUrl(returnUrl); } catch { this.invalid = true; this.cdr.detectChanges(); } } } ================================================ FILE: src/Web/ClientApp/src/api-authorization/register/register.component.html ================================================

Register

@if (error) {

{{ error }}

}
{{ emailTouched && !emailValid ? 'Please enter a valid email address.' : '' }} {{ passwordTouched && !passwordValid ? 'Password must be at least ' + minPasswordLength + ' characters.' : '' }}

Already have an account? Log in

================================================ FILE: src/Web/ClientApp/src/api-authorization/register/register.component.ts ================================================ import { Component, ChangeDetectorRef } from '@angular/core'; import { Router } from '@angular/router'; import { AuthService } from '../auth.service'; import { firstValueFrom } from 'rxjs'; const MIN_PASSWORD_LENGTH = 6; @Component({ standalone: false, selector: 'app-register', templateUrl: './register.component.html' }) export class RegisterComponent { email = ''; password = ''; emailTouched = false; passwordTouched = false; error = ''; readonly minPasswordLength = MIN_PASSWORD_LENGTH; get emailValid() { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email); } get passwordValid() { return this.password.length >= MIN_PASSWORD_LENGTH; } constructor(private authService: AuthService, private router: Router, private cdr: ChangeDetectorRef) {} async register() { this.error = ''; this.emailTouched = true; this.passwordTouched = true; if (!this.emailValid || !this.passwordValid) return; try { await firstValueFrom(this.authService.register(this.email, this.password)); await this.router.navigate(['/login']); } catch { this.error = 'Registration failed. Please try again.'; this.cdr.detectChanges(); } } } ================================================ FILE: src/Web/ClientApp/src/app/app.component.html ================================================
================================================ FILE: src/Web/ClientApp/src/app/app.component.ts ================================================ import { Component } from '@angular/core'; @Component({ standalone: false, selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { title = 'app'; } ================================================ FILE: src/Web/ClientApp/src/app/app.module.ts ================================================ import { APP_ID, NgModule, inject, provideAppInitializer } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { LucideAngularModule, Sun, Moon, Laptop, Plus, Settings, MoreHorizontal } from 'lucide-angular'; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { AppComponent } from './app.component'; import { NavMenuComponent } from './nav-menu/nav-menu.component'; import { HomeComponent } from './home/home.component'; import { CounterComponent } from './counter/counter.component'; import { WeatherComponent } from './weather/weather.component'; import { TasksComponent } from './todo/todo.component'; import { ThemeToggleComponent } from './theme-toggle/theme-toggle.component'; import { API_BASE_URL } from './web-api-client'; import { AuthorizeInterceptor } from 'src/api-authorization/authorize.interceptor'; import { LoginComponent } from 'src/api-authorization/login/login.component'; import { RegisterComponent } from 'src/api-authorization/register/register.component'; import { AuthGuard } from 'src/api-authorization/auth.guard'; import { AuthService } from 'src/api-authorization/auth.service'; export function getApiBaseUrl(): string { const url = document.getElementsByTagName('base')[0].href; return url.endsWith('/') ? url.slice(0, -1) : url; } @NgModule({ declarations: [ AppComponent, NavMenuComponent, HomeComponent, CounterComponent, WeatherComponent, TasksComponent, ThemeToggleComponent, LoginComponent, RegisterComponent ], bootstrap: [AppComponent], imports: [ BrowserModule, FormsModule, LucideAngularModule.pick({ Sun, Moon, Laptop, Plus, Settings, MoreHorizontal }), RouterModule.forRoot([ { path: '', component: HomeComponent, pathMatch: 'full' }, { path: 'counter', component: CounterComponent }, { path: 'weather', component: WeatherComponent, canActivate: [AuthGuard] }, { path: 'todo', component: TasksComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginComponent }, { path: 'register', component: RegisterComponent } ]) ], providers: [ { provide: APP_ID, useValue: 'ng-cli-universal' }, { provide: HTTP_INTERCEPTORS, useClass: AuthorizeInterceptor, multi: true }, { provide: API_BASE_URL, useFactory: getApiBaseUrl, deps: [] }, provideAppInitializer(() => inject(AuthService).initialize()), provideHttpClient(withInterceptorsFromDi()) ] }) export class AppModule { } ================================================ FILE: src/Web/ClientApp/src/app/app.server.module.ts ================================================ import { NgModule } from '@angular/core'; import { ServerModule } from '@angular/platform-server'; import { AppComponent } from './app.component'; import { AppModule } from './app.module'; @NgModule({ imports: [AppModule, ServerModule], bootstrap: [AppComponent] }) export class AppServerModule { } ================================================ FILE: src/Web/ClientApp/src/app/counter/counter.component.html ================================================

Counter

This is a simple example of an Angular component.

Current count: {{ currentCount }}

================================================ FILE: src/Web/ClientApp/src/app/counter/counter.component.spec.ts ================================================ import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { CounterComponent } from './counter.component'; describe('CounterComponent', () => { let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ CounterComponent ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(CounterComponent); fixture.detectChanges(); }); it('should display a title', waitForAsync(() => { const titleText = fixture.nativeElement.querySelector('h1').textContent; expect(titleText).toEqual('Counter'); })); it('should start with count 0, then increments by 1 when clicked', waitForAsync(() => { const countElement = fixture.nativeElement.querySelector('strong'); expect(countElement.textContent).toEqual('0'); const incrementButton = fixture.nativeElement.querySelector('button'); incrementButton.click(); fixture.detectChanges(); expect(countElement.textContent).toEqual('1'); })); }); ================================================ FILE: src/Web/ClientApp/src/app/counter/counter.component.ts ================================================ import { Component } from '@angular/core'; @Component({ standalone: false, selector: 'app-counter-component', templateUrl: './counter.component.html' }) export class CounterComponent { public currentCount = 0; public incrementCounter() { this.currentCount++; } } ================================================ FILE: src/Web/ClientApp/src/app/fetch-data/fetch-data.component.html ================================================

Weather forecast

This component demonstrates fetching data from the server.

@if (!forecasts?.length) {

Loading...

} @else{ @for (forecast of forecasts; track $index) { }
Date Temp. (C) Temp. (F) Summary
{{ forecast.date | date }} {{ forecast.temperatureC }} {{ forecast.temperatureF }} {{ forecast.summary }}
} ================================================ FILE: src/Web/ClientApp/src/app/fetch-data/fetch-data.component.ts ================================================ import { ChangeDetectorRef, Component } from '@angular/core'; import { WeatherForecastsClient, WeatherForecast } from '../web-api-client'; @Component({ standalone: false, selector: 'app-fetch-data', templateUrl: './fetch-data.component.html' }) export class FetchDataComponent { public forecasts: WeatherForecast[] = []; constructor(private client: WeatherForecastsClient, private cdr: ChangeDetectorRef) { client.getWeatherForecasts().subscribe({ next: result => { this.forecasts = result; this.cdr.detectChanges(); }, error: error => console.error(error) }); } } ================================================ FILE: src/Web/ClientApp/src/app/home/home.component.html ================================================

Welcome

A full-stack application with an Angular frontend and an ASP.NET Core backend, built with:

To help you get started:

  • Client-side navigation. Click Counter then Back to return here.
  • Angular CLI integration. In development mode, there's no need to run ng serve. It runs in the background automatically, so your client-side resources are dynamically built on demand and the page refreshes when you modify any file.
  • Efficient production builds. In production mode, development-time features are disabled, and your dotnet publish configuration automatically invokes ng build to produce minified, ahead-of-time compiled JavaScript files.

The ClientApp subdirectory is a standard Angular CLI application. Open a command prompt there to run any ng command (e.g., ng test), or use npm to install extra packages.

================================================ FILE: src/Web/ClientApp/src/app/home/home.component.ts ================================================ import { Component } from '@angular/core'; @Component({ standalone: false, selector: 'app-home', templateUrl: './home.component.html', }) export class HomeComponent { } ================================================ FILE: src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html ================================================
================================================ FILE: src/Web/ClientApp/src/app/nav-menu/nav-menu.component.scss ================================================ // Styles handled globally in styles.scss ================================================ FILE: src/Web/ClientApp/src/app/nav-menu/nav-menu.component.ts ================================================ import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { AuthService } from 'src/api-authorization/auth.service'; @Component({ standalone: false, selector: 'app-nav-menu', templateUrl: './nav-menu.component.html', styleUrls: ['./nav-menu.component.scss'] }) export class NavMenuComponent { isAuthenticated$ = this.authService.isAuthenticated$; constructor(private authService: AuthService, private router: Router) {} logout(event: Event): void { event.preventDefault(); this.authService.logout().subscribe({ next: () => this.router.navigate(['/login']) }); } } ================================================ FILE: src/Web/ClientApp/src/app/theme-toggle/theme-toggle.component.html ================================================ ================================================ FILE: src/Web/ClientApp/src/app/theme-toggle/theme-toggle.component.ts ================================================ import { Component, computed } from '@angular/core'; import { ThemeService, Theme } from '../theme.service'; @Component({ standalone: false, selector: 'app-theme-toggle', templateUrl: './theme-toggle.component.html' }) export class ThemeToggleComponent { constructor(public themeService: ThemeService) {} icon = computed(() => { switch (this.themeService.theme()) { case 'light': return 'sun'; case 'dark': return 'moon'; default: return 'laptop'; } }); label = computed(() => { switch (this.themeService.theme()) { case 'light': return 'Light'; case 'dark': return 'Dark'; default: return 'Auto'; } }); cycle(): void { const next: Record = { auto: 'light', light: 'dark', dark: 'auto' }; this.themeService.setTheme(next[this.themeService.theme()]); } } ================================================ FILE: src/Web/ClientApp/src/app/theme.service.ts ================================================ import { Injectable, signal } from '@angular/core'; export type Theme = 'auto' | 'light' | 'dark'; @Injectable({ providedIn: 'root' }) export class ThemeService { private readonly STORAGE_KEY = 'picoColorScheme'; theme = signal('auto'); constructor() { const stored = localStorage.getItem(this.STORAGE_KEY) as Theme | null; const initial: Theme = stored ?? 'auto'; this.theme.set(initial); this.applyTheme(initial); } setTheme(value: Theme): void { this.theme.set(value); localStorage.setItem(this.STORAGE_KEY, value); this.applyTheme(value); } private applyTheme(theme: Theme): void { if (theme === 'auto') { document.documentElement.removeAttribute('data-theme'); } else { document.documentElement.setAttribute('data-theme', theme); } } } ================================================ FILE: src/Web/ClientApp/src/app/todo/todo.component.html ================================================

Tasks

Manage your todo lists and tasks.

@if (!lists()) { Loading… } @else {

Lists

    @for (list of lists(); track $index) {
  • {{ list.title }} {{ remainingItems(list) }}
  • }
@if (selectedList()) {

{{ selectedList()!.title }}

@for (item of selectedList()!.items; track $index; let i = $index) {
@if (item === editingItem()) { } @else { {{ item.title }} } @if (item.id !== 0) { }
}
@if (addingItem()) { } @else { New task… }
}
}

New List

@if (newListError()) { {{ newListError() }} }

List Options

Delete "{{ selectedList()?.title }}"?

All items will be permanently deleted.

Item Details

================================================ FILE: src/Web/ClientApp/src/app/todo/todo.component.scss ================================================ // Styles handled globally in styles.scss ================================================ FILE: src/Web/ClientApp/src/app/todo/todo.component.ts ================================================ import { Component, OnInit, ViewChild, ElementRef, signal, computed, effect } from '@angular/core'; import { TodoListsClient, TodoItemsClient, TodoListDto, TodoItemDto, LookupDto, CreateTodoListCommand, UpdateTodoListCommand, CreateTodoItemCommand, UpdateTodoItemCommand, UpdateTodoItemDetailCommand } from '../web-api-client'; @Component({ standalone: false, selector: 'app-todo-component', templateUrl: './todo.component.html', styleUrls: ['./todo.component.scss'] }) export class TasksComponent implements OnInit { @ViewChild('newListDialog') newListDialogRef: ElementRef; @ViewChild('listOptionsDialog') listOptionsDialogRef: ElementRef; @ViewChild('deleteListDialog') deleteListDialogRef: ElementRef; @ViewChild('itemDetailsDialog') itemDetailsDialogRef: ElementRef; lists = signal(null); priorityLevels = signal([]); selectedListId = signal(null); selectedList = computed(() => this.lists()?.find(l => l.id === this.selectedListId()) ?? null); selectedItem = signal(null); editingItem = signal(null); newListEditor: any = {}; newListError = signal(''); listOptionsEditor: any = {}; itemDetailsEditor: any = {}; newItemTitle = ''; addingItem = signal(false); private originalTitle = ''; constructor( private listsClient: TodoListsClient, private itemsClient: TodoItemsClient ) { effect(() => { this.selectedListId(); this.newItemTitle = ''; this.addingItem.set(false); }); } ngOnInit(): void { this.listsClient.getTodoLists().subscribe({ next: result => { this.lists.set(result.lists); this.priorityLevels.set(result.priorityLevels); if (result.lists.length) { this.selectedListId.set(result.lists[0].id); } }, error: error => console.error(error) }); } // Lists remainingItems(list: TodoListDto): number { return list.items.filter(t => !t.done).length; } showNewListDialog(): void { this.newListEditor = {}; this.newListError.set(''); this.newListDialogRef.nativeElement.showModal(); setTimeout(() => document.getElementById('title')?.focus(), 50); } newListCancelled(): void { this.newListDialogRef.nativeElement.close(); this.newListEditor = {}; this.newListError.set(''); } addList(): void { const list = { id: 0, title: this.newListEditor.title, items: [] } as TodoListDto; this.listsClient.createTodoList(list as CreateTodoListCommand).subscribe({ next: result => { list.id = result; this.lists.update(ls => [...ls, list]); this.selectedListId.set(list.id); this.newListDialogRef.nativeElement.close(); this.newListEditor = {}; this.newListError.set(''); }, error: error => { const errors = JSON.parse(error.response).errors; if (errors && errors.Title) { this.newListError.set(errors.Title[0]); } setTimeout(() => document.getElementById('title')?.focus(), 50); } }); } showListOptionsDialog(): void { this.listOptionsEditor = { id: this.selectedList()!.id, title: this.selectedList()!.title }; this.listOptionsDialogRef.nativeElement.showModal(); } closeListOptionsDialog(): void { this.listOptionsDialogRef.nativeElement.close(); this.listOptionsEditor = {}; } updateListOptions(): void { const id = this.selectedList()!.id; const newTitle = this.listOptionsEditor.title; this.listsClient.updateTodoList(id, this.listOptionsEditor as UpdateTodoListCommand).subscribe({ next: () => { this.lists.update(ls => ls.map(l => l.id === id ? { ...l, title: newTitle } as TodoListDto : l)); this.closeListOptionsDialog(); }, error: error => console.error(error) }); } confirmDeleteList(): void { this.closeListOptionsDialog(); this.deleteListDialogRef.nativeElement.showModal(); } closeDeleteListDialog(): void { this.deleteListDialogRef.nativeElement.close(); } deleteListConfirmed(): void { const deletedId = this.selectedList()!.id; this.listsClient.deleteTodoList(deletedId).subscribe({ next: () => { this.closeDeleteListDialog(); this.lists.update(ls => ls.filter(l => l.id !== deletedId)); const remaining = this.lists()!; this.selectedListId.set(remaining.length ? remaining[0].id : null); }, error: error => console.error(error) }); } // Items showItemDetailsDialog(item: TodoItemDto): void { this.selectedItem.set(item); this.itemDetailsEditor = { ...item }; this.itemDetailsDialogRef.nativeElement.showModal(); } closeItemDetailsDialog(): void { this.itemDetailsDialogRef.nativeElement.close(); this.selectedItem.set(null); this.itemDetailsEditor = {}; } updateItemDetails(): void { const currentItem = this.selectedItem()!; const isMoving = currentItem.listId !== this.itemDetailsEditor.listId; this.itemsClient.updateTodoItemDetail(currentItem.id, this.itemDetailsEditor as UpdateTodoItemDetailCommand).subscribe({ next: () => { this.lists.update(ls => ls.map(l => { if (l.id === currentItem.listId && isMoving) { return { ...l, items: l.items.filter(i => i.id !== currentItem.id) } as TodoListDto; } if (l.id === this.itemDetailsEditor.listId && isMoving) { const moved = { ...currentItem, listId: this.itemDetailsEditor.listId, priority: this.itemDetailsEditor.priority, note: this.itemDetailsEditor.note } as TodoItemDto; return { ...l, items: [...l.items, moved] } as TodoListDto; } if (l.id === currentItem.listId) { return { ...l, items: l.items.map(i => i.id === currentItem.id ? { ...i, priority: this.itemDetailsEditor.priority, note: this.itemDetailsEditor.note } as TodoItemDto : i )} as TodoListDto; } return l; })); this.closeItemDetailsDialog(); }, error: error => console.error(error) }); } startAddingItem(): void { this.addingItem.set(true); setTimeout(() => document.getElementById('newItemInput')?.focus(), 50); } cancelNewItem(): void { this.addingItem.set(false); this.newItemTitle = ''; } commitNewItem(): void { this.addingItem.set(false); if (!this.newItemTitle.trim()) { this.newItemTitle = ''; return; } const listId = this.selectedListId()!; const title = this.newItemTitle.trim(); this.itemsClient.createTodoItem({ title, listId } as CreateTodoItemCommand).subscribe({ next: result => { this.lists.update(ls => ls.map(l => l.id === listId ? { ...l, items: [...l.items, { id: result, listId, title, done: false, priority: this.priorityLevels()[0].id } as TodoItemDto] } as TodoListDto : l )); this.newItemTitle = ''; }, error: error => console.error(error) }); } editItem(item: TodoItemDto, inputId: string): void { this.originalTitle = item.title; this.editingItem.set(item); setTimeout(() => document.getElementById(inputId)?.focus(), 100); } cancelEdit(): void { if (this.editingItem()) { this.editingItem()!.title = this.originalTitle; } this.editingItem.set(null); } updateItem(item: TodoItemDto): void { if (!item.title.trim()) { this.deleteItem(item); return; } if (item.id === 0) { const listId = this.selectedListId()!; this.itemsClient .createTodoItem({ title: item.title, listId } as CreateTodoItemCommand) .subscribe({ next: result => { this.lists.update(ls => ls.map(l => l.id === listId ? { ...l, items: l.items.map(i => i === item ? { ...i, id: result } as TodoItemDto : i) } as TodoListDto : l )); }, error: error => console.error(error) }); } else { this.itemsClient.updateTodoItem(item.id, item as UpdateTodoItemCommand).subscribe({ next: () => console.log('Update succeeded.'), error: error => console.error(error) }); } this.editingItem.set(null); } deleteItem(item: TodoItemDto): void { if (this.itemDetailsDialogRef?.nativeElement.open) { this.itemDetailsDialogRef.nativeElement.close(); } const listId = this.selectedListId()!; if (item.id === 0) { const currentItem = this.selectedItem()!; this.lists.update(ls => ls.map(l => l.id === listId ? { ...l, items: l.items.filter(i => i !== currentItem) } as TodoListDto : l )); this.editingItem.set(null); } else { this.itemsClient.deleteTodoItem(item.id).subscribe({ next: () => { this.lists.update(ls => ls.map(l => l.id === listId ? { ...l, items: l.items.filter(i => i.id !== item.id) } as TodoListDto : l )); }, error: error => console.error(error) }); } } } ================================================ FILE: src/Web/ClientApp/src/app/weather/weather.component.html ================================================

Weather

This component demonstrates fetching data from the server.

@if (loading) { Fetching your weather forecast… } @if (error) {

{{ error }}

} @if (!loading && !error) { @for (forecast of forecasts; track forecast.date) { }
Date Temp. (C) Temp. (F) Summary
{{ forecast.date | date }} {{ forecast.temperatureC }} {{ forecast.temperatureF }} {{ forecast.summary }}
} ================================================ FILE: src/Web/ClientApp/src/app/weather/weather.component.ts ================================================ import { ChangeDetectorRef, Component } from '@angular/core'; import { WeatherForecastsClient, WeatherForecast } from '../web-api-client'; @Component({ standalone: false, selector: 'app-weather', templateUrl: './weather.component.html' }) export class WeatherComponent { forecasts: WeatherForecast[] = []; loading = true; error: string | null = null; constructor(private client: WeatherForecastsClient, private cdr: ChangeDetectorRef) { client.getWeatherForecasts().subscribe({ next: result => { this.forecasts = result; this.loading = false; this.cdr.detectChanges(); }, error: () => { this.error = 'Unable to load weather forecasts. Please try again later.'; this.loading = false; this.cdr.detectChanges(); } }); } } ================================================ FILE: src/Web/ClientApp/src/assets/.gitkeep ================================================ ================================================ FILE: src/Web/ClientApp/src/environments/environment.prod.ts ================================================ export const environment = { production: true }; ================================================ FILE: src/Web/ClientApp/src/environments/environment.ts ================================================ // This file can be replaced during build by using the `fileReplacements` array. // `ng build` replaces `environment.ts` with `environment.prod.ts`. // The list of file replacements can be found in `angular.json`. export const environment = { production: false }; /* * For easier debugging in development mode, you can import the following file * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. * * This import should be commented out in production mode because it will have a negative impact * on performance if an error is thrown. */ // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. ================================================ FILE: src/Web/ClientApp/src/index.html ================================================ Clean Architecture Loading... ================================================ FILE: src/Web/ClientApp/src/main.ts ================================================ import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; export function getBaseUrl() { return document.getElementsByTagName('base')[0].href; } const providers = [ { provide: 'BASE_URL', useFactory: getBaseUrl, deps: [] } ]; if (environment.production) { enableProdMode(); } platformBrowserDynamic(providers).bootstrapModule(AppModule) .catch(err => console.log(err)); ================================================ FILE: src/Web/ClientApp/src/polyfills.ts ================================================ /** * This file includes polyfills needed by Angular and is loaded before the app. * You can add your own extra polyfills to this file. * * This file is divided into 2 sections: * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. * 2. Application imports. Files imported after ZoneJS that should be loaded before your main * file. * * The current setup is for so-called "evergreen" browsers; the last versions of browsers that * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. * * Learn more in https://angular.io/guide/browser-support */ /*************************************************************************************************** * BROWSER POLYFILLS */ /** * IE11 requires the following for NgClass support on SVG elements */ // import 'classlist.js'; // Run `npm install --save classlist.js`. /** * Web Animations `@angular/platform-browser/animations` * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). */ // import 'web-animations-js'; // Run `npm install --save web-animations-js`. /** * By default, zone.js will patch all possible macroTask and DomEvents * user can disable parts of macroTask/DomEvents patch by setting following flags * because those flags need to be set before `zone.js` being loaded, and webpack * will put import in the top of bundle, so user need to create a separate file * in this directory (for example: zone-flags.ts), and put the following flags * into that file, and then add the following code before importing zone.js. * import './zone-flags'; * * The flags allowed in zone-flags.ts are listed here. * * The following flags will work for all browsers. * * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames * * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js * with the following flag, it will bypass `zone.js` patch for IE/Edge * * (window as any).__Zone_enable_cross_context_check = true; * */ /*************************************************************************************************** * Zone JS is required by default for Angular itself. */ import 'zone.js'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS */ ================================================ FILE: src/Web/ClientApp/src/styles.scss ================================================ @use "sass:map"; @use "@picocss/pico/scss/index" as * with ( $theme-color: "violet", $semantic-root-element: "app-root", $enable-semantic-container: true, $enable-classes: false ); // ── Base font size & typography ─────────────────────────────────────────────── html { font-size: 95%; } :root { --pico-font-family-sans-serif: "Inter", system-ui, sans-serif; --pico-font-family-headings: "Outfit", sans-serif; --pico-font-family-monospace: "JetBrains Mono", monospace; --pico-border-radius: 0.375rem; --pico-line-height: 1.5; } // ── Header ──────────────────────────────────────────────────────────────────── :root { --header-height: 4.5rem; } app-nav-menu > header { position: fixed; top: 0; left: 0; right: 0; width: 100%; max-width: none; height: var(--header-height); padding: 0; margin: 0; z-index: 100; background: color-mix(in srgb, var(--pico-background-color) 85%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--pico-muted-border-color); } app-nav-menu > header nav { height: 100%; display: flex; align-items: center; width: 100%; margin-inline: auto; padding-inline: var(--pico-block-spacing-horizontal); @media (min-width: 576px) { max-width: 510px; padding-inline: 0; } @media (min-width: 768px) { max-width: 700px; } @media (min-width: 1024px) { max-width: 950px; } @media (min-width: 1280px) { max-width: 1200px; } @media (min-width: 1536px) { max-width: 1450px; } } // ── Main content ────────────────────────────────────────────────────────────── app-root > main { padding-top: calc(var(--header-height) + var(--pico-block-spacing-vertical) * 2); } // ── Nav brand ───────────────────────────────────────────────────────────────── app-nav-menu > header nav > ul:first-child a { font-family: "Outfit", sans-serif; font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--pico-contrast); } // ── Login / Register centered layout ────────────────────────────────────────── app-root > main:has(> app-login, > app-register) { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; > app-login, > app-register { display: contents; } > app-login > article, > app-register > article { width: 100%; max-width: map.get(map.get($breakpoints, "md"), "viewport"); } } // ── Danger color — more vivid in dark mode ──────────────────────────────────── [data-theme="dark"] { --pico-del-color: #f05050; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --pico-del-color: #f05050; } } // ── Nav separator ───────────────────────────────────────────────────────────── .nav-separator { width: 1px; height: 1.2rem; background: var(--pico-muted-border-color); padding: 0; margin-inline: 0.25rem; } // ── Theme toggle ─────────────────────────────────────────────────────────────── .theme-toggle-btn { background: none; border: none; box-shadow: none; padding: 0; line-height: 1; color: var(--pico-muted-color); cursor: pointer; --pico-form-element-spacing-vertical: 0; --pico-form-element-spacing-horizontal: 0; &:hover { background: none; color: var(--pico-primary); } } // ── Icon buttons ─────────────────────────────────────────────────────────────── .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; font-size: 1.25rem; font-weight: bold; line-height: 1; flex-shrink: 0; background: transparent !important; border-color: transparent !important; color: var(--pico-muted-color); box-shadow: none !important; --pico-form-element-spacing-vertical: 0; --pico-form-element-spacing-horizontal: 0; &:hover { color: var(--pico-primary); } lucide-icon { display: flex; } } // ── Todo layout ─────────────────────────────────────────────────────────────── .todo-layout { display: grid; grid-template-columns: 220px 1fr; gap: calc(var(--pico-block-spacing-horizontal) * 2); align-items: start; } .todo-sidebar { border-right: 1px solid var(--pico-muted-border-color); padding-right: calc(var(--pico-block-spacing-horizontal) * 2); ul { list-style: none; padding: 0; margin: 0; } li { display: flex; align-items: center; justify-content: space-between; padding: calc(var(--pico-spacing) * 0.4) calc(var(--pico-spacing) * 0.5); border-radius: var(--pico-border-radius); cursor: pointer; color: var(--pico-muted-color); &[aria-current="true"] { background: var(--pico-primary-background); color: var(--pico-primary-inverse); } &:hover:not([aria-current="true"]) { background: var(--pico-card-sectioning-background-color); color: var(--pico-color); } small { font-size: 0.75rem; opacity: 0.7; } } } .todo-main { min-width: 0; padding-left: var(--pico-spacing); } .todo-panel-header { display: flex; align-items: baseline; gap: 0.25rem; margin-bottom: var(--pico-spacing); } .todo-item { display: flex; align-items: baseline; gap: calc(var(--pico-spacing) * 0.5); padding-block: calc(var(--pico-spacing) * 0.35); border-bottom: 1px solid var(--pico-muted-border-color); input[type="checkbox"] { margin: 0; flex-shrink: 0; align-self: center; } } .todo-item-text { flex: 1; cursor: pointer; word-break: break-word; } .todo-done { text-decoration: line-through; color: var(--pico-muted-color); } .todo-item-input { flex: 1; align-self: center; margin: 0 !important; padding: 0; min-height: unset; --pico-form-element-spacing-horizontal: 0; --pico-form-element-spacing-vertical: 0; border: none; border-bottom: 1px solid var(--pico-primary); border-radius: 0; background: transparent; box-shadow: none; &:not(:focus) { border-bottom-color: transparent; } } .todo-new-item { border-bottom: none; input[type="checkbox"] { opacity: 0.3; } } .todo-new-item-placeholder { color: var(--pico-muted-color); cursor: pointer; } // ── Dialog header/footer ─────────────────────────────────────────────────────── dialog article header { display: flex; align-items: center; justify-content: space-between; > :first-child { margin: 0; } > button[rel="prev"] { position: static; margin: 0; margin-inline-start: auto; flex-shrink: 0; } } dialog article footer { display: flex; justify-content: flex-end; align-items: center; gap: calc(var(--pico-spacing) * 0.5); } // ── Button variants (not compiled in classless mode — defined manually) ─────── button.secondary, [type="submit"].secondary, [type="button"].secondary, [role="button"].secondary { --pico-background-color: transparent; --pico-border-color: var(--pico-secondary); --pico-color: var(--pico-secondary); &:is(:hover, :active, :focus) { --pico-background-color: color-mix(in srgb, var(--pico-secondary) 10%, transparent); --pico-border-color: var(--pico-secondary-hover); --pico-color: var(--pico-secondary-hover); } } button.danger, [type="submit"].danger, [type="button"].danger, [role="button"].danger { --pico-background-color: transparent; --pico-border-color: var(--pico-del-color); --pico-color: var(--pico-del-color); &:is(:hover, :active, :focus) { --pico-background-color: color-mix(in srgb, var(--pico-del-color) 10%, transparent); --pico-border-color: color-mix(in srgb, var(--pico-del-color) 80%, black); --pico-color: color-mix(in srgb, var(--pico-del-color) 80%, black); } } .error { color: var(--pico-del-color); } ================================================ FILE: src/Web/ClientApp/src/test.ts ================================================ // This file is required by karma.conf.js and loads recursively all the .spec and framework files import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; getTestBed().initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting() ); ================================================ FILE: src/Web/ClientApp/tsconfig.app.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", "types": [] }, "files": [ "src/main.ts" ], "include": [ "src/**/*.d.ts" ] } ================================================ FILE: src/Web/ClientApp/tsconfig.json ================================================ { "compileOnSave": false, "compilerOptions": { "baseUrl": "./", "outDir": "./dist/out-tsc", "forceConsistentCasingInFileNames": true, "strict": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "sourceMap": true, "declaration": false, "downlevelIteration": true, "experimentalDecorators": true, "moduleResolution": "bundler", "importHelpers": true, "target": "es2022", "module": "es2022", "lib": [ "es2022", "dom" ], "useDefineForClassFields": false }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, "strictInputAccessModifiers": true, "strictTemplates": true } } ================================================ FILE: src/Web/ClientApp/tsconfig.spec.json ================================================ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ "jasmine", "node" ] }, "files": [ "src/test.ts" ], "include": [ "src/**/*.spec.ts", "src/**/*.d.ts" ] } ================================================ FILE: src/Web/ClientApp-React/.gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. # dependencies /node_modules # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: src/Web/ClientApp-React/README.md ================================================ # CleanArchitecture React Client This project uses [Vite](https://vitejs.dev/) with React 19 and TypeScript. ## Available Scripts ### `npm start` Runs the app in development mode with hot module replacement. Opens at [https://localhost:44447](https://localhost:44447). The development server proxies API requests to the ASP.NET Core backend. ### `npm run build` Builds the app for production to the `build` folder. Optimizes the build for best performance. ### `npm run preview` Previews the production build locally. ### `npm run lint` Runs ESLint on the src directory. ## Project Structure - `src/` - React source code - `src/main.jsx` - Application entry point - `src/App.js` - Root component - `src/components/` - React components - `public/` - Static assets (favicon, manifest) - `vite.config.ts` - Vite configuration with proxy settings - `index.html` - HTML template ## Environment Variables Vite environment variables must be prefixed with `VITE_` to be exposed to client code. Example: ``` VITE_API_URL=https://api.example.com ``` Access in code: ```javascript const apiUrl = import.meta.env.VITE_API_URL; ``` ## HTTPS Configuration The development server uses ASP.NET Core development certificates for HTTPS. Run `npm start` to automatically set up certificates via `aspnetcore-https.js`. ## Learn More - [Vite Documentation](https://vitejs.dev/) - [React Documentation](https://react.dev/) ================================================ FILE: src/Web/ClientApp-React/aspnetcore-https.cjs ================================================ // This script sets up HTTPS for the application using the ASP.NET Core HTTPS certificate const fs = require('fs'); const spawn = require('child_process').spawn; const path = require('path'); const baseFolder = process.env.APPDATA !== undefined && process.env.APPDATA !== '' ? `${process.env.APPDATA}/ASP.NET/https` : `${process.env.HOME}/.aspnet/https`; if (!fs.existsSync(baseFolder)) { fs.mkdirSync(baseFolder, { recursive: true }); } const certificateArg = process.argv.map(arg => arg.match(/--name=(?.+)/i)).filter(Boolean)[0]; const certificateName = certificateArg ? certificateArg.groups.value : process.env.npm_package_name; if (!certificateName) { console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<> explicitly.') process.exit(-1); } const certFilePath = path.join(baseFolder, `${certificateName}.pem`); const keyFilePath = path.join(baseFolder, `${certificateName}.key`); if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { spawn('dotnet', [ 'dev-certs', 'https', '--export-path', certFilePath, '--format', 'Pem', '--no-password', ], { stdio: 'inherit', }) .on('exit', (code) => process.exit(code)); } ================================================ FILE: src/Web/ClientApp-React/index.html ================================================ Clean Architecture
================================================ FILE: src/Web/ClientApp-React/nswag.json ================================================ { "runtime": "Net100", "defaultVariables": null, "documentGenerator": { "fromDocument": { "json": null, "url": "../wwwroot/openapi/v1.json", "output": null, "newLineBehavior": "Auto" } }, "codeGenerators": { "openApiToTypeScriptClient": { "className": "{controller}Client", "moduleName": "", "namespace": "", "typeScriptVersion": 4.3, "template": "Fetch", "promiseType": "Promise", "httpClass": "HttpClient", "withCredentials": false, "requestCredentials": "include", "useSingletonProvider": true, "injectionTokenType": "InjectionToken", "rxJsVersion": 7.0, "dateTimeType": "Date", "nullValue": "Undefined", "generateClientClasses": true, "generateClientInterfaces": false, "generateOptionalParameters": false, "exportTypes": true, "wrapDtoExceptions": false, "exceptionClass": "SwaggerException", "clientBaseClass": null, "wrapResponses": false, "wrapResponseMethods": [], "generateResponseClasses": true, "responseClass": "SwaggerResponse", "protectedMethods": [], "configurationClass": null, "useTransformOptionsMethod": false, "useTransformResultMethod": false, "generateDtoTypes": true, "operationGenerationMode": "MultipleClientsFromFirstTagAndOperationId", "includedOperationIds": [], "excludedOperationIds": [], "markOptionalProperties": true, "generateCloneMethod": false, "typeStyle": "Class", "enumStyle": "Enum", "useLeafType": false, "classTypes": [], "extendedClasses": [], "extensionCode": null, "generateDefaultValues": true, "excludedTypeNames": [], "excludedParameterNames": [], "handleReferences": false, "generateTypeCheckFunctions": false, "generateConstructorInterface": true, "convertConstructorInterfaceData": false, "importRequiredTypes": true, "useGetBaseUrlMethod": false, "baseUrlTokenName": "API_BASE_URL", "queryNullValue": "", "useAbortSignal": false, "inlineNamedDictionaries": false, "inlineNamedAny": false, "includeHttpContext": false, "serviceHost": null, "serviceSchemes": null, "output": "src/web-api-client.ts", "newLineBehavior": "Auto" } } } ================================================ FILE: src/Web/ClientApp-React/package.json ================================================ { "name": "cleanarchitecture.web", "version": "0.1.0", "private": true, "type": "module", "dependencies": { "@picocss/pico": "^2.0.6", "lucide-react": "^0.577.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.6.1" }, "devDependencies": { "@types/node": "^24.0.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^6.0.0", "eslint": "^9.26.0", "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-refresh": "^0.5.0", "globals": "^17.0.0", "nswag": "latest", "sass": "^1.98.0", "typescript": "~5.9.0", "vite": "^8.0.0" }, "scripts": { "prestart": "npm run generate-api", "start": "vite", "prebuild": "npm run generate-api", "build": "vite build", "preview": "vite preview", "lint": "eslint ./src/", "generate-api": "nswag run /runtime:Net100" } } ================================================ FILE: src/Web/ClientApp-React/public/manifest.webmanifest ================================================ { "short_name": "CleanArchitecture.Web", "name": "CleanArchitecture.Web", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], "start_url": "./index.html", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: src/Web/ClientApp-React/src/App.jsx ================================================ import React, { Component } from 'react'; import { Route, Routes } from 'react-router-dom'; import AppRoutes from './AppRoutes'; import { Layout } from './components/Layout'; import { AuthProvider } from './components/api-authorization/AuthContext'; import { ThemeProvider } from './components/ThemeContext'; export default class App extends Component { static displayName = App.name; render() { return ( {AppRoutes.map((route, index) => { const { element, ...rest } = route; return ; })} ); } } ================================================ FILE: src/Web/ClientApp-React/src/AppRoutes.jsx ================================================ import { Counter } from "./components/Counter"; import { Weather } from "./components/Weather"; import { Tasks } from "./components/Todo"; import { Home } from "./components/Home"; import { LoginPage } from "./components/api-authorization/LoginPage"; import { RegisterPage } from "./components/api-authorization/RegisterPage"; import { ProtectedRoute } from "./components/api-authorization/ProtectedRoute"; const AppRoutes = [ { index: true, element: }, { path: '/counter', element: }, { path: '/weather', element: }, { path: '/todo', element: }, { path: '/login', element: }, { path: '/register', element: } ]; export default AppRoutes; ================================================ FILE: src/Web/ClientApp-React/src/components/Counter.jsx ================================================ import { useState } from 'react'; export function Counter() { const [count, setCount] = useState(0); return (

Counter

This is a simple example of a React component.

Current count: {count}

); } ================================================ FILE: src/Web/ClientApp-React/src/components/Home.jsx ================================================ export function Home() { return (

Welcome

A full-stack application with a React frontend and an ASP.NET Core backend, built with:

To help you get started:

  • Client-side navigation. Click Counter then Back to return here.
  • Vite dev server. In development mode, Vite runs in the background with hot module replacement, so the page updates instantly when you modify any file.
  • Efficient production builds. In production mode, development-time features are disabled, and your dotnet publish configuration produces minified, efficiently bundled JavaScript files.

The ClientApp subdirectory is a Vite + React application. Open a command prompt there to run npm commands such as npm run dev or npm install.

); } ================================================ FILE: src/Web/ClientApp-React/src/components/Layout.jsx ================================================ import { NavMenu } from './NavMenu'; export function Layout({ children }) { return ( <>
{children}
); } ================================================ FILE: src/Web/ClientApp-React/src/components/NavMenu.jsx ================================================ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from './api-authorization/AuthContext'; import { ThemeToggle } from './ThemeToggle'; function AuthLinks() { const { isAuthenticated, logout } = useAuth(); const navigate = useNavigate(); const handleLogout = async (e) => { e.preventDefault(); await logout(); navigate('/login'); }; if (isAuthenticated) { return
  • Log out
  • ; } return ( <>
  • Log in
  • Register
  • ); } export function NavMenu() { return (
    ); } ================================================ FILE: src/Web/ClientApp-React/src/components/ThemeContext.jsx ================================================ import { createContext, useContext, useEffect, useState } from 'react'; const STORAGE_KEY = 'picoColorScheme'; const ThemeContext = createContext({}); export function useTheme() { return useContext(ThemeContext); } export function ThemeProvider({ children }) { const [theme, setTheme] = useState( () => localStorage.getItem(STORAGE_KEY) || 'auto' ); useEffect(() => { if (theme === 'auto') { document.documentElement.removeAttribute('data-theme'); } else { document.documentElement.setAttribute('data-theme', theme); } localStorage.setItem(STORAGE_KEY, theme); }, [theme]); return ( {children} ); } ================================================ FILE: src/Web/ClientApp-React/src/components/ThemeToggle.jsx ================================================ import { Sun, Moon, Laptop } from 'lucide-react'; import { useTheme } from './ThemeContext'; const icons = { auto: , light: , dark: , }; const next = { auto: 'light', light: 'dark', dark: 'auto' }; export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( ); } ================================================ FILE: src/Web/ClientApp-React/src/components/Todo.jsx ================================================ import { useState, useEffect, useRef } from 'react'; import { Plus, Settings, MoreHorizontal } from 'lucide-react'; import { TodoListsClient, TodoItemsClient } from '../web-api-client.ts'; const listsClient = new TodoListsClient(); const itemsClient = new TodoItemsClient(); export function Tasks() { const [lists, setLists] = useState(null); const [priorityLevels, setPriorityLevels] = useState([]); const [selectedListId, setSelectedListId] = useState(null); const [selectedItem, setSelectedItem] = useState(null); const [editingItem, setEditingItem] = useState(null); const [editValue, setEditValue] = useState(''); const [newItemTitle, setNewItemTitle] = useState(''); const [addingItem, setAddingItem] = useState(false); const [listOptionsEditor, setListOptionsEditor] = useState({}); const [itemDetailsEditor, setItemDetailsEditor] = useState({}); const [newListTitle, setNewListTitle] = useState(''); const [newListError, setNewListError] = useState(''); const originalTitle = useRef(''); const editCancelledRef = useRef(false); const newItemCancelledRef = useRef(false); const newListDialogRef = useRef(null); const listOptionsDialogRef = useRef(null); const deleteListDialogRef = useRef(null); const itemDetailsDialogRef = useRef(null); useEffect(() => { listsClient.getTodoLists().then(result => { setLists(result.lists); setPriorityLevels(result.priorityLevels); if (result.lists.length) setSelectedListId(result.lists[0].id); }).catch(console.error); }, []); useEffect(() => { setNewItemTitle(''); setAddingItem(false); }, [selectedListId]); const selectedList = lists?.find(l => l.id === selectedListId) ?? null; const remainingItems = list => list.items.filter(t => !t.done).length; // ── Lists ────────────────────────────────────────────────────────────────── const showNewListDialog = () => { setNewListTitle(''); setNewListError(''); newListDialogRef.current.showModal(); setTimeout(() => document.getElementById('newListTitle')?.focus(), 50); }; const closeNewListDialog = () => { newListDialogRef.current.close(); setNewListTitle(''); setNewListError(''); }; const commitNewList = async () => { if (!newListTitle.trim()) return; try { const id = await listsClient.createTodoList({ title: newListTitle.trim(), items: [] }); const newList = { id, title: newListTitle.trim(), items: [] }; setLists(ls => [...ls, newList]); setSelectedListId(id); closeNewListDialog(); } catch (e) { try { const errors = JSON.parse(e.response).errors; if (errors?.Title) { setNewListError(errors.Title[0]); return; } } catch { /* ignore */ } setNewListError('Failed to create list.'); } }; const showListOptionsDialog = () => { setListOptionsEditor({ id: selectedList.id, title: selectedList.title }); listOptionsDialogRef.current.showModal(); }; const closeListOptionsDialog = () => { listOptionsDialogRef.current.close(); setListOptionsEditor({}); }; const updateListOptions = async () => { try { await listsClient.updateTodoList(selectedList.id, listOptionsEditor); setLists(ls => ls.map(l => l.id === selectedList.id ? { ...l, title: listOptionsEditor.title } : l)); closeListOptionsDialog(); } catch (e) { console.error(e); } }; const confirmDeleteList = () => { closeListOptionsDialog(); deleteListDialogRef.current.showModal(); }; const closeDeleteListDialog = () => deleteListDialogRef.current.close(); const deleteListConfirmed = async () => { try { await listsClient.deleteTodoList(selectedList.id); const remaining = lists.filter(l => l.id !== selectedList.id); setLists(remaining); setSelectedListId(remaining.length ? remaining[0].id : null); closeDeleteListDialog(); } catch (e) { console.error(e); } }; // ── Items ────────────────────────────────────────────────────────────────── const showItemDetailsDialog = (item) => { setSelectedItem(item); setItemDetailsEditor({ ...item }); itemDetailsDialogRef.current.showModal(); }; const closeItemDetailsDialog = () => { itemDetailsDialogRef.current.close(); setSelectedItem(null); setItemDetailsEditor({}); }; const updateItemDetails = async () => { const isMoving = selectedItem.listId !== itemDetailsEditor.listId; try { await itemsClient.updateTodoItemDetail(selectedItem.id, itemDetailsEditor); setLists(ls => ls.map(l => { if (l.id === selectedItem.listId && isMoving) return { ...l, items: l.items.filter(i => i.id !== selectedItem.id) }; if (l.id === itemDetailsEditor.listId && isMoving) return { ...l, items: [...l.items, { ...selectedItem, ...itemDetailsEditor }] }; if (l.id === selectedItem.listId) return { ...l, items: l.items.map(i => i.id === selectedItem.id ? { ...i, priority: itemDetailsEditor.priority, note: itemDetailsEditor.note } : i) }; return l; })); closeItemDetailsDialog(); } catch (e) { console.error(e); } }; const deleteItem = async (item) => { if (itemDetailsDialogRef.current?.open) closeItemDetailsDialog(); try { await itemsClient.deleteTodoItem(item.id); setLists(ls => ls.map(l => l.id === selectedListId ? { ...l, items: l.items.filter(i => i.id !== item.id) } : l)); } catch (e) { console.error(e); } }; const updateCheckbox = async (item, done) => { const updated = { ...item, done }; setLists(ls => ls.map(l => l.id === selectedListId ? { ...l, items: l.items.map(i => i.id === item.id ? updated : i) } : l)); try { await itemsClient.updateTodoItem(item.id, updated); } catch (e) { console.error(e); } }; const editItem = (item, inputId) => { originalTitle.current = item.title; setEditValue(item.title); setEditingItem(item); setTimeout(() => document.getElementById(inputId)?.focus(), 100); }; const cancelEdit = (e) => { editCancelledRef.current = true; setLists(ls => ls.map(l => ({ ...l, items: l.items.map(i => i === editingItem ? { ...i, title: originalTitle.current } : i) }))); setEditingItem(null); e?.target.blur(); }; const commitEdit = async () => { if (!editValue.trim()) { await deleteItem(editingItem); setEditingItem(null); return; } const updated = { ...editingItem, title: editValue.trim() }; setLists(ls => ls.map(l => l.id === selectedListId ? { ...l, items: l.items.map(i => i === editingItem ? updated : i) } : l)); setEditingItem(null); try { await itemsClient.updateTodoItem(updated.id, updated); } catch (e) { console.error(e); } }; const startAddingItem = () => { setAddingItem(true); setTimeout(() => document.getElementById('newItemInput')?.focus(), 50); }; const cancelNewItem = (e) => { newItemCancelledRef.current = true; setAddingItem(false); setNewItemTitle(''); e?.target.blur(); }; const commitNewItem = async () => { setAddingItem(false); if (!newItemTitle.trim()) { setNewItemTitle(''); return; } const title = newItemTitle.trim(); const listId = selectedListId; setNewItemTitle(''); try { const id = await itemsClient.createTodoItem({ title, listId }); setLists(ls => ls.map(l => l.id === listId ? { ...l, items: [...l.items, { id, listId, title, done: false, priority: priorityLevels[0]?.id }] } : l)); } catch (e) { console.error(e); } }; if (!lists) return Loading…; return ( <>

    Tasks

    Manage your todo lists and tasks.

    {/* Sidebar */}

    Lists

      {lists.map(list => (
    • setSelectedListId(list.id)}> {list.title} {remainingItems(list)}
    • ))}
    {/* Items panel */} {selectedList && (

    {selectedList.title}

    {selectedList.items.map((item, i) => (
    updateCheckbox(item, e.target.checked)} /> {editingItem === item ? ( setEditValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.target.blur(); } if (e.key === 'Escape') cancelEdit(e); }} onBlur={() => { if (editCancelledRef.current) { editCancelledRef.current = false; return; } commitEdit(); }} autoFocus maxLength={200} /> ) : ( editItem(item, `itemTitle${i}`)}> {item.title} )} {item.id !== 0 && ( )}
    ))}
    {addingItem ? ( setNewItemTitle(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') commitNewItem(); if (e.key === 'Escape') cancelNewItem(e); }} onBlur={() => { if (newItemCancelledRef.current) { newItemCancelledRef.current = false; return; } commitNewItem(); }} maxLength={200} /> ) : ( New task… )}
    )}
    {/* New List dialog */}

    New List

    setNewListTitle(e.target.value)} aria-invalid={newListError ? 'true' : undefined} onKeyDown={e => e.key === 'Enter' && commitNewList()} maxLength={200} /> {newListError && {newListError}}
    {/* List Options dialog */}

    List Options

    setListOptionsEditor(ed => ({ ...ed, title: e.target.value }))} onKeyDown={e => e.key === 'Enter' && updateListOptions()} maxLength={200} />
    {/* Delete List dialog */}

    Delete "{selectedList?.title}"?

    All items will be permanently deleted.

    {/* Item Details dialog */}

    Item Details

    ); } ================================================ FILE: src/Web/ClientApp-React/src/components/Weather.jsx ================================================ import { useState, useEffect } from 'react'; import { WeatherForecastsClient } from '../web-api-client.ts'; export function Weather() { const [forecasts, setForecasts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchData() { try { const client = new WeatherForecastsClient(); const data = await client.getWeatherForecasts(); setForecasts(data); } catch (e) { setError('Unable to load weather forecasts. Please try again later.'); } finally { setLoading(false); } } fetchData(); }, []); return (

    Weather

    This component demonstrates fetching data from the server.

    {loading && Fetching your weather forecast...} {error &&

    {error}

    } {!loading && !error && ( {forecasts.map(forecast => )}
    Date Temp. (C) Temp. (F) Summary
    {new Date(forecast.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {forecast.temperatureC} {forecast.temperatureF} {forecast.summary}
    )}
    ); } ================================================ FILE: src/Web/ClientApp-React/src/components/api-authorization/AuthContext.jsx ================================================ import { createContext, useContext, useState, useEffect } from 'react'; import { UsersClient, LoginRequest, RegisterRequest } from '../../web-api-client'; const AuthContext = createContext(null); const client = new UsersClient(); export function AuthProvider({ children }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); useEffect(() => { client.infoGET() .then(() => setIsAuthenticated(true)) .catch(() => setIsAuthenticated(false)) .finally(() => setIsLoading(false)); }, []); const login = (email, password) => client.login(true, undefined, new LoginRequest({ email, password })) .then(() => setIsAuthenticated(true)); const register = (email, password) => client.register(new RegisterRequest({ email, password })); const logout = () => client.logout({}) .then(() => setIsAuthenticated(false)); return ( {children} ); } export const useAuth = () => useContext(AuthContext); ================================================ FILE: src/Web/ClientApp-React/src/components/api-authorization/LoginPage.jsx ================================================ import { useState } from 'react'; import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useAuth } from './AuthContext'; export function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [invalid, setInvalid] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const handleSubmit = async (e) => { e.preventDefault(); try { await login(email, password); const returnUrl = location.state?.returnUrl || '/'; navigate(returnUrl, { replace: true }); } catch { setInvalid(true); } }; const handleChange = (setter) => (e) => { setInvalid(false); setter(e.target.value); }; return (

    Log in

    {invalid && Invalid email or password.}

    Don't have an account? Register

    ); } ================================================ FILE: src/Web/ClientApp-React/src/components/api-authorization/ProtectedRoute.jsx ================================================ import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from './AuthContext'; export function ProtectedRoute({ children }) { const { isAuthenticated, isLoading } = useAuth(); const location = useLocation(); if (isLoading) return null; if (!isAuthenticated) return ; return children; } ================================================ FILE: src/Web/ClientApp-React/src/components/api-authorization/RegisterPage.jsx ================================================ import { useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from './AuthContext'; const MIN_PASSWORD_LENGTH = 6; function validateEmail(value) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } export function RegisterPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [emailTouched, setEmailTouched] = useState(false); const [passwordTouched, setPasswordTouched] = useState(false); const [error, setError] = useState(''); const { register } = useAuth(); const navigate = useNavigate(); const emailValid = validateEmail(email); const passwordValid = password.length >= MIN_PASSWORD_LENGTH; const emailInvalid = emailTouched ? !emailValid : undefined; const passwordInvalid = passwordTouched ? !passwordValid : undefined; const handleSubmit = async (e) => { e.preventDefault(); setError(''); setEmailTouched(true); setPasswordTouched(true); if (!emailValid || !passwordValid) return; try { await register(email, password); navigate('/login'); } catch { setError('Registration failed. Please try again.'); } }; return (

    Register

    {error &&

    {error}

    }
    setEmail(e.target.value)} onBlur={() => setEmailTouched(true)} aria-invalid={emailInvalid} aria-describedby="email-helper" /> {emailTouched && !emailValid ? 'Please enter a valid email address.' : ''} setPassword(e.target.value)} onBlur={() => setPasswordTouched(true)} aria-invalid={passwordInvalid} aria-describedby="password-helper" /> {passwordTouched && !passwordValid ? `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` : ''}

    Already have an account? Log in

    ); } ================================================ FILE: src/Web/ClientApp-React/src/main.jsx ================================================ import './styles.scss'; import React from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href'); const root = createRoot(document.getElementById('root')); root.render( ); ================================================ FILE: src/Web/ClientApp-React/src/styles.scss ================================================ @use "sass:map"; @use "@picocss/pico/scss/index" as * with ( $theme-color: "violet", $semantic-root-element: "#root", $enable-semantic-container: true, $enable-classes: false ); // ── Base font size & typography ─────────────────────────────────────────────── html { font-size: 95%; } :root { --pico-font-family-sans-serif: "Inter", system-ui, sans-serif; --pico-font-family-headings: "Outfit", sans-serif; --pico-font-family-monospace: "JetBrains Mono", monospace; --pico-border-radius: 0.375rem; --pico-line-height: 1.5; } // ── Header ──────────────────────────────────────────────────────────────────── :root { --header-height: 4.5rem; } #root > header { position: fixed; top: 0; left: 0; right: 0; width: 100%; max-width: none; height: var(--header-height); padding: 0; margin: 0; z-index: 100; background: color-mix(in srgb, var(--pico-background-color) 85%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--pico-muted-border-color); } #root > header nav { height: 100%; display: flex; align-items: center; width: 100%; margin-inline: auto; padding-inline: var(--pico-block-spacing-horizontal); @media (min-width: 576px) { max-width: 510px; padding-inline: 0; } @media (min-width: 768px) { max-width: 700px; } @media (min-width: 1024px) { max-width: 950px; } @media (min-width: 1280px) { max-width: 1200px; } @media (min-width: 1536px) { max-width: 1450px; } } // ── Main content ────────────────────────────────────────────────────────────── #root > main { padding-top: calc(var(--header-height) + var(--pico-block-spacing-vertical) * 2); } // ── Nav brand ───────────────────────────────────────────────────────────────── #root > header nav > ul:first-child a { font-family: "Outfit", sans-serif; font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--pico-contrast); } // ── Login / Register centered layout ────────────────────────────────────────── #root > main:has(> article:only-child) { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; > article { width: 100%; max-width: map.get(map.get($breakpoints, "md"), "viewport"); } } // ── Danger color — more vivid in dark mode ──────────────────────────────────── [data-theme="dark"] { --pico-del-color: #f05050; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --pico-del-color: #f05050; } } // ── Nav separator ───────────────────────────────────────────────────────────── .nav-separator { width: 1px; height: 1.2rem; background: var(--pico-muted-border-color); padding: 0; margin-inline: 0.25rem; } // ── Theme toggle ─────────────────────────────────────────────────────────────── .theme-toggle-btn { background: none; border: none; box-shadow: none; padding: 0; line-height: 1; color: var(--pico-muted-color); cursor: pointer; --pico-form-element-spacing-vertical: 0; --pico-form-element-spacing-horizontal: 0; &:hover { background: none; color: var(--pico-primary); } } // ── Icon buttons ─────────────────────────────────────────────────────────────── .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; font-size: 1.25rem; font-weight: bold; line-height: 1; flex-shrink: 0; background: transparent !important; border-color: transparent !important; color: var(--pico-muted-color); box-shadow: none !important; --pico-form-element-spacing-vertical: 0; --pico-form-element-spacing-horizontal: 0; &:hover { color: var(--pico-primary); } } // ── Todo layout ─────────────────────────────────────────────────────────────── .todo-layout { display: grid; grid-template-columns: 220px 1fr; gap: calc(var(--pico-block-spacing-horizontal) * 2); align-items: start; } .todo-sidebar { border-right: 1px solid var(--pico-muted-border-color); padding-right: calc(var(--pico-block-spacing-horizontal) * 2); ul { list-style: none; padding: 0; margin: 0; } li { display: flex; align-items: center; justify-content: space-between; padding: calc(var(--pico-spacing) * 0.4) calc(var(--pico-spacing) * 0.5); border-radius: var(--pico-border-radius); cursor: pointer; color: var(--pico-muted-color); &[aria-current="true"] { background: var(--pico-primary-background); color: var(--pico-primary-inverse); } &:hover:not([aria-current="true"]) { background: var(--pico-card-sectioning-background-color); color: var(--pico-color); } small { font-size: 0.75rem; opacity: 0.7; } } } .todo-main { min-width: 0; padding-left: var(--pico-spacing); } .todo-panel-header { display: flex; align-items: baseline; gap: 0.25rem; margin-bottom: var(--pico-spacing); } .todo-item { display: flex; align-items: baseline; gap: calc(var(--pico-spacing) * 0.5); padding-block: calc(var(--pico-spacing) * 0.35); border-bottom: 1px solid var(--pico-muted-border-color); input[type="checkbox"] { margin: 0; flex-shrink: 0; align-self: center; } } .todo-item-text { flex: 1; cursor: pointer; word-break: break-word; } .todo-done { text-decoration: line-through; color: var(--pico-muted-color); } .todo-item-input { flex: 1; align-self: center; margin: 0 !important; padding: 0; min-height: unset; --pico-form-element-spacing-horizontal: 0; --pico-form-element-spacing-vertical: 0; border: none; border-bottom: 1px solid var(--pico-primary); border-radius: 0; background: transparent; box-shadow: none; &:not(:focus) { border-bottom-color: transparent; } } .todo-new-item { border-bottom: none; input[type="checkbox"] { opacity: 0.3; } } .todo-new-item-placeholder { color: var(--pico-muted-color); cursor: pointer; } // ── Dialog header/footer ─────────────────────────────────────────────────────── dialog article header { display: flex; align-items: center; justify-content: space-between; > :first-child { margin: 0; } > button[rel="prev"] { position: static; margin: 0; margin-inline-start: auto; flex-shrink: 0; } } dialog article footer { display: flex; justify-content: flex-end; align-items: center; gap: calc(var(--pico-spacing) * 0.5); } // ── Button variants (not compiled in classless mode — defined manually) ─────── button.secondary, [type="submit"].secondary, [type="button"].secondary, [role="button"].secondary { --pico-background-color: transparent; --pico-border-color: var(--pico-secondary); --pico-color: var(--pico-secondary); &:is(:hover, :active, :focus) { --pico-background-color: color-mix(in srgb, var(--pico-secondary) 10%, transparent); --pico-border-color: var(--pico-secondary-hover); --pico-color: var(--pico-secondary-hover); } } button.danger, [type="submit"].danger, [type="button"].danger, [role="button"].danger { --pico-background-color: transparent; --pico-border-color: var(--pico-del-color); --pico-color: var(--pico-del-color); &:is(:hover, :active, :focus) { --pico-background-color: color-mix(in srgb, var(--pico-del-color) 10%, transparent); --pico-border-color: color-mix(in srgb, var(--pico-del-color) 80%, black); --pico-color: color-mix(in srgb, var(--pico-del-color) 80%, black); } } .error { color: var(--pico-del-color); } ================================================ FILE: src/Web/ClientApp-React/src/vite-env.d.ts ================================================ /// ================================================ FILE: src/Web/ClientApp-React/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, /* Compatibility */ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "allowJs": true }, "include": ["src"] } ================================================ FILE: src/Web/ClientApp-React/vite.config.ts ================================================ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; const target = process.env['services__webapi__https__0'] || process.env['services__webapi__http__0']; const proxyOptions = target ? { target, secure: false, changeOrigin: true } : undefined; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], server: { port: parseInt(process.env.PORT!), proxy: proxyOptions ? { '/api': proxyOptions, '/openapi': proxyOptions, '/scalar': proxyOptions, '/weatherforecast': proxyOptions, '/WeatherForecast': proxyOptions, } : undefined, }, build: { outDir: 'build', }, }); ================================================ FILE: src/Web/DependencyInjection.cs ================================================ using Azure.Identity; using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.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(); #if (UseApiOnly) options.AddDocumentTransformer(); #endif }); 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()); } } } ================================================ FILE: src/Web/Endpoints/TodoItems.cs ================================================ using CleanArchitecture.Application.Common.Models; using CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; using CleanArchitecture.Application.TodoItems.Commands.DeleteTodoItem; using CleanArchitecture.Application.TodoItems.Commands.UpdateTodoItem; using CleanArchitecture.Application.TodoItems.Commands.UpdateTodoItemDetail; using CleanArchitecture.Application.TodoItems.Queries.GetTodoItemsWithPagination; using Microsoft.AspNetCore.Http.HttpResults; namespace CleanArchitecture.Web.Endpoints; public class TodoItems : IEndpointGroup { public static void Map(RouteGroupBuilder groupBuilder) { groupBuilder.RequireAuthorization(); groupBuilder.MapGet(GetTodoItemsWithPagination); groupBuilder.MapPost(CreateTodoItem); groupBuilder.MapPut(UpdateTodoItem, "{id}"); groupBuilder.MapPatch(UpdateTodoItemDetail, "UpdateDetail/{id}"); groupBuilder.MapDelete(DeleteTodoItem, "{id}"); } [EndpointSummary("Get Todo Items with Pagination")] [EndpointDescription("Retrieves a paginated list of todo items based on the provided query parameters.")] public static async Task>> GetTodoItemsWithPagination( ISender sender, [AsParameters] GetTodoItemsWithPaginationQuery query) { var result = await sender.Send(query); return TypedResults.Ok(result); } [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(); } } ================================================ FILE: src/Web/Endpoints/TodoLists.cs ================================================ using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Application.TodoLists.Commands.DeleteTodoList; using CleanArchitecture.Application.TodoLists.Commands.UpdateTodoList; using CleanArchitecture.Application.TodoLists.Queries.GetTodos; using Microsoft.AspNetCore.Http.HttpResults; namespace CleanArchitecture.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(); } } ================================================ FILE: src/Web/Endpoints/Users.cs ================================================ using CleanArchitecture.Infrastructure.Identity; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace CleanArchitecture.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(); } } ================================================ FILE: src/Web/Endpoints/WeatherForecasts.cs ================================================ using CleanArchitecture.Application.WeatherForecasts.Queries.GetWeatherForecasts; using Microsoft.AspNetCore.Http.HttpResults; namespace CleanArchitecture.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); } } ================================================ FILE: src/Web/GlobalUsings.cs ================================================ global using Ardalis.GuardClauses; global using CleanArchitecture.Web.Infrastructure; global using MediatR; ================================================ FILE: src/Web/Infrastructure/ApiExceptionOperationTransformer.cs ================================================ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace CleanArchitecture.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; } } ================================================ FILE: src/Web/Infrastructure/BearerSecuritySchemeTransformer.cs ================================================ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace CleanArchitecture.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; } } } ================================================ FILE: src/Web/Infrastructure/EndpointRouteBuilderExtensions.cs ================================================ using System.Diagnostics.CodeAnalysis; namespace CleanArchitecture.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); } } ================================================ FILE: src/Web/Infrastructure/IEndpointGroup.cs ================================================ namespace CleanArchitecture.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); } ================================================ FILE: src/Web/Infrastructure/IdentityApiOperationTransformer.cs ================================================ using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace CleanArchitecture.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; } } ================================================ FILE: src/Web/Infrastructure/MethodInfoExtensions.cs ================================================ using System.Reflection; namespace CleanArchitecture.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."); } } ================================================ FILE: src/Web/Infrastructure/ProblemDetailsExceptionHandler.cs ================================================ using CleanArchitecture.Application.Common.Exceptions; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; namespace CleanArchitecture.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; } } ================================================ FILE: src/Web/Infrastructure/WebApplicationExtensions.cs ================================================ using System.Reflection; namespace CleanArchitecture.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; } } ================================================ FILE: src/Web/Program.cs ================================================ using CleanArchitecture.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.MapOpenApi(); app.MapScalarApiReference(); #if (!UseApiOnly) app.MapFallbackToFile("index.html"); #endif app.UseExceptionHandler(options => { }); #if (UseApiOnly) app.Map("/", () => Results.Redirect("/scalar")); #endif app.MapDefaultEndpoints(); app.MapEndpoints(typeof(Program).Assembly); app.UseFileServer(); app.Run(); ================================================ FILE: src/Web/Properties/launchSettings.json ================================================ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "scalar", "applicationUrl": "http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "scalar", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ================================================ FILE: src/Web/Services/CurrentUser.cs ================================================ using System.Security.Claims; using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.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(); } ================================================ FILE: src/Web/Web-webapi.http ================================================ # For more info on HTTP files go to https://aka.ms/vs/httpfile @Web_HostAddress = https://localhost:5001 @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}} ### ================================================ FILE: src/Web/Web.csproj ================================================  CleanArchitecture.Web CleanArchitecture.Web ./wwwroot/openapi/ --file-name v1 all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: src/Web/Web.http ================================================ # For more info on HTTP files go to https://aka.ms/vs/httpfile @Web_HostAddress = https://localhost:5001 @AuthCookieName = .AspNetCore.Identity.Application @AuthCookieValue = # GET Identity Account Login # Get the @RequestVerificationToken necessary for logging in. GET {{Web_HostAddress}}/Identity/Account/Login ### # POST Identity Account Login # Get the @AuthCookieValue necessary for authenticating requests. @Email=administrator@localhost @Password=Administrator1! @RequestVerificationToken= POST {{Web_HostAddress}}/Identity/Account/Login Content-Type: application/x-www-form-urlencoded Input.Email={{Email}}&Input.Password={{Password}}&__RequestVerificationToken={{RequestVerificationToken}} ### # GET WeatherForecast GET {{Web_HostAddress}}/api/WeatherForecasts Cookie: {{AuthCookieName}}={{AuthCookieValue}} ### # GET TodoLists GET {{Web_HostAddress}}/api/TodoLists Cookie: {{AuthCookieName}}={{AuthCookieValue}} ### #GET TodoList GET {{Web_HostAddress}}/api/TodoLists/1 Cookie: {{AuthCookieName}}={{AuthCookieValue}} ### # POST TodoLists POST {{Web_HostAddress}}/api/TodoLists Cookie: {{AuthCookieName}}={{AuthCookieValue}} Content-Type: application/json // CreateTodoListCommand { "Title": "Backlog" } ### # PUT TodoLists PUT {{Web_HostAddress}}/api/TodoLists/1 Cookie: {{AuthCookieName}}={{AuthCookieValue}} Content-Type: application/json // UpdateTodoListCommand { "Id": 1, "Title": "Product Backlog" } ### # DELETE TodoLists DELETE {{Web_HostAddress}}/api/TodoLists/1 Cookie: {{AuthCookieName}}={{AuthCookieValue}} ### # GET TodoItems @PageNumber = 1 @PageSize = 10 GET {{Web_HostAddress}}/api/TodoItems?ListId=1&PageNumber={{PageNumber}}&PageSize={{PageSize}} Cookie: {{AuthCookieName}}={{AuthCookieValue}} ### # POST TodoItems POST {{Web_HostAddress}}/api/TodoItems Cookie: {{AuthCookieName}}={{AuthCookieValue}} Content-Type: application/json // CreateTodoItemCommand { "ListId": 1, "Title": "Eat a burrito 🌯" } ### #PUT TodoItems UpdateItemDetails PUT {{Web_HostAddress}}/api/TodoItems/UpdateItemDetails?Id=1 Cookie: {{AuthCookieName}}={{AuthCookieValue}} 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 Cookie: {{AuthCookieName}}={{AuthCookieValue}} Content-Type: application/json // UpdateTodoItemCommand { "Id": 1, "Title": "Eat a yummy burrito 🌯", "Done": true } ### # DELETE TodoItem DELETE {{Web_HostAddress}}/api/TodoItems/1 Cookie: {{AuthCookieName}}={{AuthCookieValue}} ### ================================================ FILE: src/Web/appsettings.PostgreSQL.json ================================================ { "ConnectionStrings": { "CleanArchitectureDb": "Server=127.0.0.1;Port=5432;Database=CleanArchitectureDb;Username=admin;Password=password;" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } } } ================================================ FILE: src/Web/appsettings.SQLServer.json ================================================ { "ConnectionStrings": { "CleanArchitectureDb": "Server=(localdb)\\mssqllocaldb;Database=CleanArchitectureDb;Trusted_Connection=True;MultipleActiveResultSets=true" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } } } ================================================ FILE: src/Web/appsettings.SQLite.json ================================================ { "ConnectionStrings": { "CleanArchitectureDb": "DataSource=CleanArchitecture.db;Cache=Shared" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } } } ================================================ FILE: src/Web/appsettings.json ================================================ { "ConnectionStrings": { "CleanArchitectureDb": "DataSource=CleanArchitecture.db;Cache=Shared" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "AllowedHosts": "*" } ================================================ FILE: templates/ca-use-case/.template.config/dotnetcli.host.json ================================================ { "symbolInfo": { "featureName": { "longName": "feature-name", "shortName": "fn" }, "returnType": { "longName": "return-type", "shortName": "rt" }, "useCaseType": { "longName": "usecase-type", "shortName": "ut" }, "parentNamespace": { "longName": "parent-namespace", "shortName": "pn" } } } ================================================ FILE: templates/ca-use-case/.template.config/template.json ================================================ { "$schema": "http://json.schemastore.org/template", "author": "JasonTaylorDev", "classifications": [ "Clean Architecture" ], "name": "Clean Architecture Solution Use Case", "description": "Create a new use case (query or command)", "identity": "Clean.Architecture.Solution.UseCase.CSharp", "groupIdentity": "Clean.Architecture.Solution.UseCase", "shortName": "ca-usecase", "tags": { "language": "C#", "type": "item" }, "sourceName": "CleanArchitectureUseCase", "preferNameDirectory": false, "symbols": { "RootNamespace": { "type": "bind", "binding": "msbuild:RootNamespace", "defaultValue": "CleanArchitecture.Application" }, "parentNamespace": { "type": "parameter", "datatype": "string", "isRequired": false, "description": "Parent namespace when creating use case in a subfolder (e.g., 'Accounting' when in src/Application/Accounting)", "defaultValue": "" }, "featureName": { "type": "parameter", "datatype": "string", "isRequired": true, "fileRename": "FeatureName" }, "ComputedNamespaceWithFeature": { "type": "generated", "generator": "join", "parameters": { "symbols": [ { "type": "ref", "value": "RootNamespace" }, { "type": "ref", "value": "parentNamespace" }, { "type": "ref", "value": "featureName" } ], "separator": ".", "removeEmptyValues": true }, "replaces": "CleanArchitecture.Application.FeatureName" }, "useCaseType": { "type": "parameter", "datatype": "choice", "isRequired": true, "choices": [ { "choice": "command", "description": "Create a new command" }, { "choice": "query", "description": "Create a new query" } ], "description": "The type of use case to create" }, "createCommand": { "type": "computed", "value": "(useCaseType == \"command\")" }, "createQuery": { "type": "computed", "value": "(useCaseType == \"query\")" }, "returnType": { "type": "parameter", "datatype": "string", "isRequired": false, "replaces": "TReturnType", "defaultValue": "" }, "hasReturnType": { "type": "computed", "value": "(returnType != \"\")" }, "CommonInterfacesNamespace": { "type": "generated", "generator": "join", "parameters": { "symbols": [ { "type": "ref", "value": "RootNamespace" }, { "type": "const", "value": "Common.Interfaces" } ], "separator": "." }, "replaces": "CleanArchitecture.Application.Common.Interfaces" } }, "sources": [ { "modifiers": [ { "condition": "(createCommand)", "exclude": [ "FeatureName/Queries/**/*" ] }, { "condition": "(createQuery)", "exclude": [ "FeatureName/Commands/**/*" ] } ] } ] } ================================================ FILE: templates/ca-use-case/FeatureName/Commands/CleanArchitectureUseCase/CleanArchitectureUseCase.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.Application.FeatureName.Commands.CleanArchitectureUseCase; //#if (hasReturnType) public record CleanArchitectureUseCaseCommand : IRequest //#else public record CleanArchitectureUseCaseCommand : IRequest //#endif { } public class CleanArchitectureUseCaseCommandValidator : AbstractValidator { public CleanArchitectureUseCaseCommandValidator() { } } //#if (hasReturnType) public class CleanArchitectureUseCaseCommandHandler : IRequestHandler //#else public class CleanArchitectureUseCaseCommandHandler : IRequestHandler //#endif { private readonly IApplicationDbContext _context; public CleanArchitectureUseCaseCommandHandler(IApplicationDbContext context) { _context = context; } //#if (hasReturnType) public async Task Handle(CleanArchitectureUseCaseCommand request, CancellationToken cancellationToken) { throw new NotImplementedException(); } //#else public async Task Handle(CleanArchitectureUseCaseCommand request, CancellationToken cancellationToken) { throw new NotImplementedException(); } //#endif } ================================================ FILE: templates/ca-use-case/FeatureName/Queries/CleanArchitectureUseCase/CleanArchitectureUseCase.cs ================================================ using CleanArchitecture.Application.Common.Interfaces; namespace CleanArchitecture.Application.FeatureName.Queries.CleanArchitectureUseCase; //#if (hasReturnType) public record CleanArchitectureUseCaseQuery : IRequest //#else public record CleanArchitectureUseCaseQuery : IRequest //#endif { } public class CleanArchitectureUseCaseQueryValidator : AbstractValidator { public CleanArchitectureUseCaseQueryValidator() { } } //#if (hasReturnType) public class CleanArchitectureUseCaseQueryHandler : IRequestHandler //#else public class CleanArchitectureUseCaseQueryHandler : IRequestHandler //#endif { private readonly IApplicationDbContext _context; public CleanArchitectureUseCaseQueryHandler(IApplicationDbContext context) { _context = context; } //#if (hasReturnType) public async Task Handle(CleanArchitectureUseCaseQuery request, CancellationToken cancellationToken) { throw new NotImplementedException(); } //#else public async Task Handle(CleanArchitectureUseCaseQuery request, CancellationToken cancellationToken) { throw new NotImplementedException(); } //#endif } ================================================ FILE: tests/Application.FunctionalTests/Application.FunctionalTests.csproj ================================================ CleanArchitecture.Application.FunctionalTests CleanArchitecture.Application.FunctionalTests all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: tests/Application.FunctionalTests/FunctionalTestSetup.cs ================================================ using Microsoft.Extensions.DependencyInjection; namespace CleanArchitecture.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(); } } ================================================ FILE: tests/Application.FunctionalTests/GlobalUsings.cs ================================================ global using Ardalis.GuardClauses; global using CleanArchitecture.Application.FunctionalTests.Infrastructure; global using CleanArchitecture.Shared; global using Moq; global using NUnit.Framework; global using Shouldly; ================================================ FILE: tests/Application.FunctionalTests/Infrastructure/DatabaseResetter.cs ================================================ #if (UsePostgreSQL) using Npgsql; #elif (UseSqlServer) using Microsoft.Data.SqlClient; #else using Microsoft.Data.Sqlite; #endif using Respawn; using System.Data.Common; namespace CleanArchitecture.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) { #if (UsePostgreSQL) var connection = new NpgsqlConnection(connectionString); #elif (UseSqlServer) var connection = new SqlConnection(connectionString); #else var connection = new SqliteConnection(connectionString); #endif 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(); } ================================================ FILE: tests/Application.FunctionalTests/Infrastructure/TestApp.cs ================================================ using CleanArchitecture.Domain.Constants; using CleanArchitecture.Infrastructure.Data; using CleanArchitecture.Infrastructure.Identity; using MediatR; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace CleanArchitecture.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(); } } ================================================ FILE: tests/Application.FunctionalTests/Infrastructure/TestBase.cs ================================================ namespace CleanArchitecture.Application.FunctionalTests.Infrastructure; public abstract class TestBase { [SetUp] public async Task SetUp() { await TestApp.ResetState(); } } ================================================ FILE: tests/Application.FunctionalTests/Infrastructure/WebApiFactory.cs ================================================ using CleanArchitecture.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 CleanArchitecture.Application.FunctionalTests.Infrastructure; public class WebApiFactory(string connectionString) : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder .UseSetting("ConnectionStrings:CleanArchitectureDb", 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; }); }); } } ================================================ FILE: tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs ================================================ using CleanArchitecture.Application.Common.Exceptions; using CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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)); } } ================================================ FILE: tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs ================================================ using CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; using CleanArchitecture.Application.TodoItems.Commands.DeleteTodoItem; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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(); } } ================================================ FILE: tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs ================================================ using CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; using CleanArchitecture.Application.TodoItems.Commands.UpdateTodoItem; using CleanArchitecture.Application.TodoItems.Commands.UpdateTodoItemDetail; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Enums; namespace CleanArchitecture.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)); } } ================================================ FILE: tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs ================================================ using CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; using CleanArchitecture.Application.TodoItems.Commands.UpdateTodoItem; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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)); } } ================================================ FILE: tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs ================================================ using CleanArchitecture.Application.Common.Exceptions; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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)); } } ================================================ FILE: tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs ================================================ using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Application.TodoLists.Commands.DeleteTodoList; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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(); } } ================================================ FILE: tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs ================================================ using CleanArchitecture.Application.Common.Exceptions; using CleanArchitecture.Application.Common.Security; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Application.TodoLists.Commands.PurgeTodoLists; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Application.FunctionalTests.TodoLists.Commands; public class PurgeTodoListsTests : TestBase { [Test] public async Task ShouldDenyAnonymousUser() { var command = new PurgeTodoListsCommand(); command.GetType().ShouldSatisfyAllConditions( type => type.ShouldBeDecoratedWith() ); var action = () => TestApp.SendAsync(command); await Should.ThrowAsync(action); } [Test] public async Task ShouldDenyNonAdministrator() { await TestApp.RunAsDefaultUserAsync(); var command = new PurgeTodoListsCommand(); var action = () => TestApp.SendAsync(command); await Should.ThrowAsync(action); } [Test] public async Task ShouldAllowAdministrator() { await TestApp.RunAsAdministratorAsync(); var command = new PurgeTodoListsCommand(); var action = () => TestApp.SendAsync(command); Func asyncAction = async () => await TestApp.SendAsync(command); await asyncAction.ShouldNotThrowAsync(); } [Test] public async Task ShouldDeleteAllLists() { await TestApp.RunAsAdministratorAsync(); await TestApp.SendAsync(new CreateTodoListCommand { Title = "New List #1" }); await TestApp.SendAsync(new CreateTodoListCommand { Title = "New List #2" }); await TestApp.SendAsync(new CreateTodoListCommand { Title = "New List #3" }); await TestApp.SendAsync(new PurgeTodoListsCommand()); var count = await TestApp.CountAsync(); count.ShouldBe(0); } } ================================================ FILE: tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs ================================================ using CleanArchitecture.Application.Common.Exceptions; using CleanArchitecture.Application.TodoLists.Commands.CreateTodoList; using CleanArchitecture.Application.TodoLists.Commands.UpdateTodoList; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.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)); } } ================================================ FILE: tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs ================================================ using CleanArchitecture.Application.TodoLists.Queries.GetTodos; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.ValueObjects; namespace CleanArchitecture.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); } } ================================================ FILE: tests/Application.UnitTests/Application.UnitTests.csproj ================================================  CleanArchitecture.Application.UnitTests CleanArchitecture.Application.UnitTests all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs ================================================ using CleanArchitecture.Application.Common.Behaviours; using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.TodoItems.Commands.CreateTodoItem; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; namespace CleanArchitecture.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); } } ================================================ FILE: tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs ================================================ using CleanArchitecture.Application.Common.Exceptions; using FluentValidation.Results; using NUnit.Framework; using Shouldly; namespace CleanArchitecture.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); } } ================================================ FILE: tests/Application.UnitTests/Common/Mappings/MappingTests.cs ================================================ using System.Runtime.CompilerServices; using AutoMapper; using CleanArchitecture.Application.Common.Interfaces; using CleanArchitecture.Application.Common.Models; using CleanArchitecture.Application.TodoItems.Queries.GetTodoItemsWithPagination; using CleanArchitecture.Application.TodoLists.Queries.GetTodos; using CleanArchitecture.Domain.Entities; using Microsoft.Extensions.Logging; using NUnit.Framework; namespace CleanArchitecture.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))] [TestCase(typeof(TodoList), typeof(LookupDto))] [TestCase(typeof(TodoItem), typeof(LookupDto))] [TestCase(typeof(TodoItem), typeof(TodoItemBriefDto))] 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(); } } ================================================ FILE: tests/Application.UnitTests/Common/Models/PaginatedListTests.cs ================================================ using CleanArchitecture.Application.Common.Models; using NUnit.Framework; using Shouldly; namespace CleanArchitecture.Application.UnitTests.Common.Models; public class PaginatedListTests { [Test] public void HasPreviousPage_ShouldBeFalse_WhenOnFirstPage() { var list = new PaginatedList([], 100, pageNumber: 1, pageSize: 10); list.HasPreviousPage.ShouldBeFalse(); } [Test] public void HasPreviousPage_ShouldBeTrue_WhenOnSecondPage() { var list = new PaginatedList([], 100, pageNumber: 2, pageSize: 10); list.HasPreviousPage.ShouldBeTrue(); } [Test] public void HasPreviousPage_ShouldBeTrue_WhenOnePageBeyondLastPage() { var list = new PaginatedList([], 100, pageNumber: 11, pageSize: 10); list.HasPreviousPage.ShouldBeTrue(); } [Test] public void HasPreviousPage_ShouldBeFalse_WhenTwoPagesOrMoreBeyondLastPage() { var list = new PaginatedList([], 100, pageNumber: 12, pageSize: 10); list.HasPreviousPage.ShouldBeFalse(); } [Test] public void HasNextPage_ShouldBeFalse_WhenOnLastPage() { var list = new PaginatedList([], 100, pageNumber: 10, pageSize: 10); list.HasNextPage.ShouldBeFalse(); } [Test] public void HasNextPage_ShouldBeTrue_WhenNotOnLastPage() { var list = new PaginatedList([], 100, pageNumber: 9, pageSize: 10); list.HasNextPage.ShouldBeTrue(); } } ================================================ FILE: tests/Domain.UnitTests/Domain.UnitTests.csproj ================================================  CleanArchitecture.Domain.UnitTests CleanArchitecture.Domain.UnitTests all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: tests/Domain.UnitTests/ValueObjects/ColourTests.cs ================================================ using CleanArchitecture.Domain.Exceptions; using CleanArchitecture.Domain.ValueObjects; using NUnit.Framework; using Shouldly; namespace CleanArchitecture.Domain.UnitTests.ValueObjects; public class ColourTests { [Test] public void ShouldReturnCorrectColourCode() { var code = "#FFFFFF"; var colour = Colour.From(code); colour.Code.ShouldBe(code); } [Test] public void ToStringReturnsCode() { var colour = Colour.White; colour.ToString().ShouldBe(colour.Code); } [Test] public void ShouldPerformImplicitConversionToColourCodeString() { string code = Colour.White; code.ShouldBe("#FFFFFF"); } [Test] public void ShouldPerformExplicitConversionGivenSupportedColourCode() { var colour = (Colour)"#FFFFFF"; colour.ShouldBe(Colour.White); } [Test] public void ShouldThrowUnsupportedColourExceptionGivenNotSupportedColourCode() { Should.Throw(() => Colour.From("##FF33CC")); } [Test] public void ShouldBeComparableWithOperators() { var color1 = new Colour("#FFFFFF"); var color2 = new Colour("#FFFFFF"); var color3 = new Colour("#AAAAAA"); (color1 == color2).ShouldBe(true); (color1 == color3).ShouldBe(false); } } ================================================ FILE: tests/Infrastructure.IntegrationTests/GlobalUsings.cs ================================================ global using NUnit.Framework; ================================================ FILE: tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj ================================================ CleanArchitecture.Infrastructure.IntegrationTests CleanArchitecture.Infrastructure.IntegrationTests all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: tests/TestAppHost/Program.cs ================================================ using CleanArchitecture.Shared; namespace CleanArchitecture.TestAppHost; public class Program { public static void Main(string[] args) { var builder = DistributedApplication.CreateBuilder(args); #if (UsePostgreSQL) builder.AddPostgres(Services.DatabaseServer) .AddDatabase(Services.Database); #elif (UseSqlServer) builder.AddSqlServer(Services.DatabaseServer) .AddDatabase(Services.Database); #else builder .AddSqlite(Services.Database); #endif builder.Build().Run(); } } ================================================ FILE: tests/TestAppHost/TestAppHost.csproj ================================================ Exe CleanArchitecture.TestAppHost CleanArchitecture.TestAppHost ================================================ FILE: tests/Web.AcceptanceTests/AspireSetup.cs ================================================ using Aspire.Hosting; namespace CleanArchitecture.Web.AcceptanceTests; [SetUpFixture] public class AspireSetup { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); public static IDistributedApplicationTestingBuilder Builder { get; private set; } = null!; public static DistributedApplication App { get; private set; } = null!; [OneTimeSetUp] public async Task OneTimeSetup() { var cts = new CancellationTokenSource(DefaultTimeout); var cancellationToken = cts.Token; Builder = await DistributedApplicationTestingBuilder .CreateAsync( args: [], configureBuilder: (options, _) => { options.DisableDashboard = false; // Enable the dashboard for testing purposes }); Builder.Configuration["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true"; Builder.Services.AddLogging(logging => { logging.SetMinimumLevel(LogLevel.Debug); // Override the logging filters from the app's configuration logging.AddFilter(Builder.Environment.ApplicationName, LogLevel.Debug); logging.AddFilter("Aspire.", LogLevel.Debug); }); Builder.Services.ConfigureHttpClientDefaults(clientBuilder => { clientBuilder.AddStandardResilienceHandler(); }); App = await Builder .BuildAsync(cancellationToken) .WaitAsync(cancellationToken); await App .StartAsync(cancellationToken) .WaitAsync(cancellationToken); await Task.WhenAll( App.ResourceNotifications.WaitForResourceHealthyAsync(Services.WebApi, cancellationToken).WaitAsync(cancellationToken), App.ResourceNotifications.WaitForResourceHealthyAsync(Services.WebFrontend, cancellationToken).WaitAsync(cancellationToken)); } [OneTimeTearDown] public async Task OneTimeTearDown() { await App.DisposeAsync(); } } ================================================ FILE: tests/Web.AcceptanceTests/Features/Counter.feature ================================================ @Counter Feature: Counter Scenario: Counter page is displayed Given a user visits the counter page Then the counter heading is "Counter" Scenario: Counter increments when button is clicked Given a user visits the counter page Then the current count is 0 When the user clicks increment Then the current count is 1 ================================================ FILE: tests/Web.AcceptanceTests/Features/Home.feature ================================================ @Home Feature: Home Scenario: Welcome heading is displayed Given a user visits the home page Then the heading "Welcome" is visible ================================================ FILE: tests/Web.AcceptanceTests/Features/Login.feature ================================================ @Login Feature: Login User can log in Scenario: User can log in with valid credentials Given a logged out user When the user logs in with valid credentials Then they log in successfully Scenario: User cannot log in with invalid credentials Given a logged out user When the user logs in with invalid credentials Then an error is displayed ================================================ FILE: tests/Web.AcceptanceTests/Features/Weather.feature ================================================ @Weather Feature: Weather Scenario: Weather forecast data is displayed Given an authenticated user visits the weather page Then the weather forecast heading is "Weather" And the weather forecast table is displayed And 5 weather forecasts are shown ================================================ FILE: tests/Web.AcceptanceTests/GlobalUsings.cs ================================================ global using CleanArchitecture.Web.AcceptanceTests.Pages; global using CleanArchitecture.Shared; global using Microsoft.Playwright; global using Reqnroll; global using Reqnroll.BoDi; global using Shouldly; ================================================ FILE: tests/Web.AcceptanceTests/Pages/BasePage.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.Pages; public abstract class BasePage(IPage page) { protected static string BaseUrl => AspireSetup.App.GetEndpoint(Services.WebFrontend).ToString().TrimEnd('/'); public abstract string PagePath { get; } protected IPage Page { get; } = page; public Task GotoAsync() => Page.GotoAsync(PagePath); } ================================================ FILE: tests/Web.AcceptanceTests/Pages/CounterPage.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.Pages; public class CounterPage(IPage page) : BasePage(page) { public override string PagePath => $"{BaseUrl}/counter"; public Task AssertHeading(string text) => Assertions.Expect(Page.Locator("h1")).ToHaveTextAsync(text); public Task AssertCurrentCount(int count) => Assertions.Expect(Page.Locator("p[aria-live='polite'] strong")).ToHaveTextAsync(count.ToString()); public Task ClickIncrement() => Page.Locator("button:has-text('Increment')").ClickAsync(); } ================================================ FILE: tests/Web.AcceptanceTests/Pages/HomePage.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.Pages; public class HomePage(IPage page) : BasePage(page) { public override string PagePath => BaseUrl; public Task AssertHeading(string text) => Assertions.Expect(Page.Locator("h1")).ToHaveTextAsync(text); } ================================================ FILE: tests/Web.AcceptanceTests/Pages/LoginPage.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.Pages; public class LoginPage(IPage page) : BasePage(page) { public override string PagePath => $"{BaseUrl}/login"; public Task SetEmail(string email) => Page.FillAsync("#email", email); public Task SetPassword(string password) => Page.FillAsync("#password", password); public Task ClickLogin() => Page.Locator("button[type='submit']").ClickAsync(); public Task LogoutButtonText() => Page.Locator("a:has-text('Log out')").TextContentAsync(); public Task AssertErrorVisible() => Assertions.Expect(Page.Locator("#login-error")).ToBeVisibleAsync(); } ================================================ FILE: tests/Web.AcceptanceTests/Pages/WeatherPage.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.Pages; public class WeatherPage(IPage page) : BasePage(page) { public override string PagePath => $"{BaseUrl}/weather"; public Task AssertHeading(string text) => Assertions.Expect(Page.Locator("h1")).ToHaveTextAsync(text); public Task AssertTableVisible() => Assertions.Expect(Page.Locator("table")).ToBeVisibleAsync(); public Task AssertRowCount(int count) => Assertions.Expect(Page.Locator("tbody tr")).ToHaveCountAsync(count); } ================================================ FILE: tests/Web.AcceptanceTests/PlaywrightSetup.cs ================================================ using System.Diagnostics; namespace CleanArchitecture.Web.AcceptanceTests; [SetUpFixture] public class PlaywrightSetup { private static bool IsHeadless => Debugger.IsAttached is false; private static IPlaywright? _playwright; public static IBrowser Browser { get; private set; } = null!; [OneTimeSetUp] public async Task OneTimeSetUp() { Assertions.SetDefaultExpectTimeout(10_000); _playwright = await Playwright.CreateAsync(); Browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = IsHeadless, SlowMo = IsHeadless ? 0 : 500 }); } [OneTimeTearDown] public async Task OneTimeTearDown() { await Browser.CloseAsync(); _playwright?.Dispose(); } } ================================================ FILE: tests/Web.AcceptanceTests/StepDefinitions/CounterStepDefinitions.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.StepDefinitions; [Binding] public sealed class CounterStepDefinitions(CounterPage counterPage) { [BeforeFeature("Counter")] public static async Task BeforeCounterFeature(IObjectContainer container) { var context = await PlaywrightSetup.Browser.NewContextAsync(); var page = await context.NewPageAsync(); container.RegisterInstanceAs(context); container.RegisterInstanceAs(new CounterPage(page)); } [AfterFeature] public static async Task AfterCounterFeature(IObjectContainer container) { var context = container.Resolve(); await context.DisposeAsync(); } [Given("a user visits the counter page")] public Task GivenAUserVisitsTheCounterPage() => counterPage.GotoAsync(); [Then("the counter heading is {string}")] public Task ThenTheCounterHeadingIs(string text) => counterPage.AssertHeading(text); [Then("the current count is {int}")] public Task ThenTheCurrentCountIs(int count) => counterPage.AssertCurrentCount(count); [When("the user clicks increment")] public Task WhenTheUserClicksIncrement() => counterPage.ClickIncrement(); } ================================================ FILE: tests/Web.AcceptanceTests/StepDefinitions/HomeStepDefinitions.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.StepDefinitions; [Binding] public sealed class HomeStepDefinitions(HomePage homePage) { [BeforeFeature("Home")] public static async Task BeforeHomeFeature(IObjectContainer container) { var context = await PlaywrightSetup.Browser.NewContextAsync(); var page = await context.NewPageAsync(); container.RegisterInstanceAs(context); container.RegisterInstanceAs(new HomePage(page)); } [AfterFeature] public static async Task AfterHomeFeature(IObjectContainer container) { var context = container.Resolve(); await context.DisposeAsync(); } [Given("a user visits the home page")] public Task GivenAUserVisitsTheHomePage() => homePage.GotoAsync(); [Then("the heading {string} is visible")] public Task ThenTheHeadingIsVisible(string text) => homePage.AssertHeading(text); } ================================================ FILE: tests/Web.AcceptanceTests/StepDefinitions/LoginStepDefinitions.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.StepDefinitions; [Binding] public sealed class LoginStepDefinitions(LoginPage loginPage) { [BeforeFeature("Login")] public static async Task BeforeLoginFeature(IObjectContainer container) { var context = await PlaywrightSetup.Browser.NewContextAsync(); var page = await context.NewPageAsync(); container.RegisterInstanceAs(context); container.RegisterInstanceAs(new LoginPage(page)); } [AfterFeature] public static async Task AfterLoginFeature(IObjectContainer container) { var context = container.Resolve(); await context.DisposeAsync(); } [Given("a logged out user")] public Task GivenALoggedOutUser() => loginPage.GotoAsync(); [When("the user logs in with valid credentials")] public async Task TheUserLogsInWithValidCredentials() { await loginPage.SetEmail("administrator@localhost"); await loginPage.SetPassword("Administrator1!"); await loginPage.ClickLogin(); } [Then("they log in successfully")] public async Task TheyLogInSuccessfully() { var logoutButtonText = await loginPage.LogoutButtonText(); logoutButtonText.ShouldNotBeNull(); logoutButtonText.ShouldBe("Log out"); } [When("the user logs in with invalid credentials")] public async Task TheUserLogsInWithInvalidCredentials() { await loginPage.SetEmail("hacker@localhost"); await loginPage.SetPassword("l337hax!"); await loginPage.ClickLogin(); } [Then("an error is displayed")] public Task AnErrorIsDisplayed() => loginPage.AssertErrorVisible(); } ================================================ FILE: tests/Web.AcceptanceTests/StepDefinitions/WeatherStepDefinitions.cs ================================================ namespace CleanArchitecture.Web.AcceptanceTests.StepDefinitions; [Binding] public sealed class WeatherStepDefinitions(WeatherPage weatherPage) { [BeforeFeature("Weather")] public static async Task BeforeWeatherFeature(IObjectContainer container) { var context = await PlaywrightSetup.Browser.NewContextAsync(); var page = await context.NewPageAsync(); var loginPage = new LoginPage(page); await loginPage.GotoAsync(); await loginPage.SetEmail("administrator@localhost"); await loginPage.SetPassword("Administrator1!"); await loginPage.ClickLogin(); await Assertions.Expect(page.Locator("a:has-text('Log out')")).ToBeVisibleAsync(); container.RegisterInstanceAs(context); container.RegisterInstanceAs(new WeatherPage(page)); } [AfterFeature] public static async Task AfterWeatherFeature(IObjectContainer container) { var context = container.Resolve(); await context.DisposeAsync(); } [Given("an authenticated user visits the weather page")] public Task GivenAnAuthenticatedUserVisitsTheWeatherPage() => weatherPage.GotoAsync(); [Then("the weather forecast heading is {string}")] public Task ThenTheWeatherForecastHeadingIs(string text) => weatherPage.AssertHeading(text); [Then("the weather forecast table is displayed")] public Task ThenTheWeatherForecastTableIsDisplayed() => weatherPage.AssertTableVisible(); [Then("{int} weather forecasts are shown")] public Task ThenWeatherForecastsAreShown(int count) => weatherPage.AssertRowCount(count); } ================================================ FILE: tests/Web.AcceptanceTests/Web.AcceptanceTests.csproj ================================================  CleanArchitecture.Web.AcceptanceTests CleanArchitecture.Web.AcceptanceTests true