From f231fd5b975ecc22c541c95ad370201ddbbba0e7 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Sat, 10 Jan 2026 11:53:07 +0300 Subject: [PATCH 1/6] docs: add multitenancy sample scenarios --- docs/samples/index.md | 18 +++++----- .../background-jobs-context-propagation.md | 31 ++++++++++++++++ .../efcore-database-per-tenant.md | 32 +++++++++++++++++ .../header-and-route-resolution.md | 31 ++++++++++++++++ .../identity-tenant-isolated-auth.md | 33 +++++++++++++++++ .../provisioning-hybrid-residency.md | 35 +++++++++++++++++++ .../multitenancy/redis-tenant-caching.md | 33 +++++++++++++++++ mkdocs.yml | 10 +++++- 8 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 docs/samples/multitenancy/background-jobs-context-propagation.md create mode 100644 docs/samples/multitenancy/efcore-database-per-tenant.md create mode 100644 docs/samples/multitenancy/header-and-route-resolution.md create mode 100644 docs/samples/multitenancy/identity-tenant-isolated-auth.md create mode 100644 docs/samples/multitenancy/provisioning-hybrid-residency.md create mode 100644 docs/samples/multitenancy/redis-tenant-caching.md diff --git a/docs/samples/index.md b/docs/samples/index.md index 80d022e..89a9a06 100644 --- a/docs/samples/index.md +++ b/docs/samples/index.md @@ -1,12 +1,10 @@ # Samples -There are no runnable samples in the repository yet. The current guidance is: - -- Use the extension pages for step-by-step setup. -- Review tests under `tests/` for realistic usage patterns. - -Planned samples: - -- Query caching with explicit invalidation. -- Multitenancy resolution (header + route) with enforcement. -- EF Core shared-database isolation with tenant filters. +There are still no runnable samples committed, but the multitenancy scenarios below capture the step-by-step work needed to add them under `samples/` when ready. Each plan follows the Clean Architecture template (SQLite by default) and the repository rule to keep numbered step comments mirrored in the sample README. + +- [Header + route resolution with enforcement](multitenancy/header-and-route-resolution.md) +- [Tenant context propagation into background jobs](multitenancy/background-jobs-context-propagation.md) +- [EF Core database-per-tenant isolation](multitenancy/efcore-database-per-tenant.md) +- [Tenant-isolated Identity and authorization](multitenancy/identity-tenant-isolated-auth.md) +- [Provisioning with region-aware sharding and dedicated databases](multitenancy/provisioning-hybrid-residency.md) +- [Tenant-scoped Redis caching with key prefixing](multitenancy/redis-tenant-caching.md) diff --git a/docs/samples/multitenancy/background-jobs-context-propagation.md b/docs/samples/multitenancy/background-jobs-context-propagation.md new file mode 100644 index 0000000..adebad2 --- /dev/null +++ b/docs/samples/multitenancy/background-jobs-context-propagation.md @@ -0,0 +1,31 @@ +# Scenario: Tenant Context Propagation into Background Jobs + +## Goal +Document a sample that captures tenant context in HTTP requests and restores it inside background jobs so multi-tenant data access and logging stay isolated. + +## Sample name and location +- Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.BackgroundJobs` +- Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.BackgroundJobs` + +## Modules used +- Multitenancy core (context, serializer, enforcement behaviors) +- Multitenancy.AspNetCore (HTTP resolution) + +## Prerequisites +- Create the base Web API solution using SQLite. +- Keep numbered step comments in code changes with matching entries in the sample README. + +## Steps +1. Reference `CleanArchitecture.Extensions.Multitenancy` and `CleanArchitecture.Extensions.Multitenancy.AspNetCore`; enable middleware for header-based resolution and keep enforcement on. +2. Ensure `ITenantContextSerializer` is registered (from the core package) and configure correlation options if needed to include tenant IDs in log scopes. +3. Build an in-memory `IBackgroundJobQueue` abstraction that carries a payload plus serialized `TenantContext`. +4. In controllers/handlers that enqueue work, serialize the current `TenantContext` and attach it to the queued message. +5. Implement a hosted worker that dequeues messages, restores tenant context via `ITenantAccessor`/`ITenantContextSerializer`, runs the work, and clears `ICurrentTenant` afterward. +6. Register MediatR behaviors (`TenantEnforcementBehavior`, `TenantCorrelationBehavior`) so background commands also require a tenant when needed and emit tenant-aware log scopes. +7. Add tests to assert tenant context flows into the worker (log scope contains tenant ID; EF Core context sees the tenant) and that enqueuing without a tenant is rejected when the message type requires it. +8. Document failure handling: how to drop or poison messages when context deserialization fails and how to avoid leaking tenant info in those paths. + +## Validation +- Jobs executed by the worker see the same tenant ID as the request that queued them. +- Messages created without tenant context are rejected for tenant-required workflows. +- Tenant context is cleared after each job to avoid cross-tenant leakage. diff --git a/docs/samples/multitenancy/efcore-database-per-tenant.md b/docs/samples/multitenancy/efcore-database-per-tenant.md new file mode 100644 index 0000000..52bcac7 --- /dev/null +++ b/docs/samples/multitenancy/efcore-database-per-tenant.md @@ -0,0 +1,32 @@ +# Scenario: EF Core Database-Per-Tenant Isolation + +## Goal +Document a sample that uses database-per-tenant isolation with EF Core helpers, per-tenant migrations, and strict SaveChanges enforcement. + +## Sample name and location +- Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.EfCoreDatabasePerTenant` +- Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.EfCoreDatabasePerTenant` + +## Modules used +- Multitenancy core +- Multitenancy.AspNetCore +- Multitenancy.EFCore + +## Prerequisites +- Base Web API solution using SQLite with the Clean Architecture template. +- Numbered step comments and matching README entries for every change. + +## Steps +1. Add references to `CleanArchitecture.Extensions.Multitenancy`, `CleanArchitecture.Extensions.Multitenancy.AspNetCore`, and `CleanArchitecture.Extensions.Multitenancy.EFCore`; include them in the sample solution. +2. Configure `EfCoreMultitenancyOptions` to `DatabasePerTenant`, set the tenant ID property name, and mark global entities (Identity tables, etc.) as exclusions. +3. Implement `ITenantConnectionResolver` mapping each tenant to a SQLite file under `App_Data/{tenantId}.db`, allowing premium/regional tenants to pick dedicated databases if needed. +4. Replace the default DbContext registration with `ITenantDbContextFactory` using the `TenantDbContext` base class plus `TenantSaveChangesInterceptor` and the tenant-aware model customizer. +5. Wire `TenantMigrationRunner` to run migrations per tenant; include a CLI/hosted command that iterates the tenant catalog and migrates active tenants. +6. Ensure the web pipeline resolves tenants before EF Core usage and keep `TenantEnforcementBehavior` enabled so data access cannot occur without tenant context. +7. Add tests verifying per-tenant database files are created, cross-tenant queries are blocked, and global entities bypass filters when configured. +8. Document operational guidance: backup/restore per tenant, handling connection string rotation, and cleaning up databases on tenant deletion. + +## Validation +- Each tenant writes to its own SQLite file; queries do not cross boundaries. +- SaveChanges fails when tenant context is missing or mismatched. +- Migration runner reports per-tenant status and supports reruns without data loss. diff --git a/docs/samples/multitenancy/header-and-route-resolution.md b/docs/samples/multitenancy/header-and-route-resolution.md new file mode 100644 index 0000000..1cf9ecb --- /dev/null +++ b/docs/samples/multitenancy/header-and-route-resolution.md @@ -0,0 +1,31 @@ +# Scenario: Header + Route Resolution with ASP.NET Core Enforcement + +## Goal +Document a sample that shows deterministic tenant resolution from route first, host second, and header fallback, with ProblemDetails responses when a tenant is missing or inactive. + +## Sample name and location +- Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution` +- Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution` + +## Modules used +- Multitenancy core (resolution pipeline + behaviors) +- Multitenancy.AspNetCore (middleware, attributes, ProblemDetails) + +## Prerequisites +- Generate the base Web API solution with the Clean Architecture template using SQLite (`dotnet new ca-sln -cf None ...`). +- Keep numbered step comments in code changes and mirror them in the sample README per repository guidance. + +## Steps +1. Add project references to `CleanArchitecture.Extensions.Multitenancy` and `CleanArchitecture.Extensions.Multitenancy.AspNetCore` from `src/`, and include those projects in the sample solution. +2. Configure `MultitenancyOptions` for route-first ordering (`Route > Host > Header > Query > Claim`), set header name `X-Tenant-ID`, require tenants by default, and disable fallback tenants. +3. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; place `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. +4. Add route conventions that group tenant-bound APIs under `/tenants/{tenantId}/...`; keep health/status endpoints outside the group for anonymous access. +5. Decorate tenant-bound endpoints with `RequireTenant`, and mark public endpoints with `AllowAnonymousTenant` to keep resolution optional without enforcement. +6. Enable `TenantExceptionHandler`/ProblemDetails so unresolved tenants return 400, missing tenants return 404, and suspended tenants return 403. +7. Add integration tests that cover: resolved via route, resolved via host mapping, header fallback when the route is absent, conflict handling when route/header disagree, and enforcement responses when no tenant is provided. +8. Update the sample README with the walkthrough (inputs, expected status codes) and middleware ordering reminders. + +## Validation +- Requests with `/tenants/{tenantId}` succeed only when the tenant exists and is active. +- Requests without tenant context return the expected ProblemDetails payloads. +- Tenant context is cleared per request (no AsyncLocal leakage between tests). diff --git a/docs/samples/multitenancy/identity-tenant-isolated-auth.md b/docs/samples/multitenancy/identity-tenant-isolated-auth.md new file mode 100644 index 0000000..701c322 --- /dev/null +++ b/docs/samples/multitenancy/identity-tenant-isolated-auth.md @@ -0,0 +1,33 @@ +# Scenario: Tenant-Isolated Identity and Authorization + +## Goal +Document a sample that ties Identity login and authorization to the current tenant using tenant-aware stores, claims, and policies. + +## Sample name and location +- Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.IdentityPerTenant` +- Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.IdentityPerTenant` + +## Modules used +- Multitenancy core +- Multitenancy.AspNetCore +- Multitenancy.Identity (planned adapter) +- Multitenancy.EFCore for data isolation + +## Prerequisites +- Base Web API solution with Identity enabled (template default) and SQLite. +- Numbered step comments and matching README entries for all changes. + +## Steps +1. Reference multitenancy core and AspNetCore; add `CleanArchitecture.Extensions.Multitenancy.Identity` when the package is available (use a project reference if built in-repo). +2. Ensure tenant resolution runs before authentication so login attempts already have tenant context (host/route/header providers configured). +3. Update Identity user and role entities/stores to implement `ITenantUser`/`ITenantRole`; scope user queries by tenant ID and namespace roles using the configured prefix pattern. +4. Register `TenantClaimsPrincipalFactory` to inject `tenant_id`, `tenant_name`, and per-tenant roles/permissions into JWTs or cookies. +5. Configure `TenantPolicyProvider` plus authorization handlers (`TenantMembershipHandler`, `TenantPermissionRequirement`) and swap existing policy registrations to tenant-aware versions. +6. Enforce tenant suspension/inactive flags during sign-in (fail fast with the proper error) and ensure issued tokens are invalidated if tenant state changes. +7. Add integration tests covering successful login within a tenant, rejected login for mismatched tenant, role prefixing behavior, and authorization policies that deny cross-tenant access. +8. Document how to seed a tenant admin user via provisioning events or startup seeding, and how to rotate keys without breaking tenant isolation. + +## Validation +- Authentication only succeeds when the principal’s tenant matches the resolved tenant context. +- Authorization policies respect tenant-prefixed roles/permissions. +- Token claims include tenant identifiers and are rejected if the tenant is suspended or deleted. diff --git a/docs/samples/multitenancy/provisioning-hybrid-residency.md b/docs/samples/multitenancy/provisioning-hybrid-residency.md new file mode 100644 index 0000000..ef619b9 --- /dev/null +++ b/docs/samples/multitenancy/provisioning-hybrid-residency.md @@ -0,0 +1,35 @@ +# Scenario: Provisioning with Region-Aware Sharding and Dedicated Databases + +## Goal +Document a sample that provisions tenants through an orchestrator, chooses shard/database based on region/plan, and runs migrations/seeds accordingly. + +## Sample name and location +- Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.ProvisioningHybrid` +- Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.ProvisioningHybrid` + +## Modules used +- Multitenancy core +- Multitenancy.EFCore +- Multitenancy.Provisioning (planned adapter) +- Multitenancy.Sharding (planned adapter) +- Multitenancy.AspNetCore for API hosting +- Multitenancy.Storage initializer for assets (optional) + +## Prerequisites +- Base Web API solution using SQLite for shared mode, with the option to create per-tenant SQLite files for dedicated mode. +- Numbered step comments and matching README entries for every change. + +## Steps +1. Reference core, AspNetCore, and EFCore packages; add `Multitenancy.Provisioning` and `Multitenancy.Sharding` once available, and include them in the sample solution. +2. Implement a tenant catalog store and register `ProvisioningOrchestrator` with the Requested -> Validating -> Provisioning -> Active -> Suspended -> Deleted state machine from the blueprint. +3. Configure `ITenantPlanBuilder` to choose isolation mode: default shared database, but premium/regulated tenants get a dedicated database on a region-tagged shard (via `IShardResolver`). +4. Hook `ITenantSchemaManager` to run EF Core migrations/seeds per tenant according to the selected isolation mode; provide a dry-run path for planning without execution. +5. Add API endpoints or CLI commands for create/suspend/delete tenant operations that call provisioning services and publish lifecycle events (`TenantCreated`, `TenantProvisioned`, `TenantSuspended`, `TenantDeleted`). +6. Integrate the storage initializer so tenant-specific folders/containers are created on activation, following the Multitenancy.Storage path conventions. +7. Add operational scripts/tests to simulate cold onboarding (pre-created database) and hybrid flows, verifying events fire in order and rollback works on partial failures. +8. Document monitoring expectations (progress logs per tenant, metrics for provisioning duration) and cleanup steps when deletion or suspension occurs. + +## Validation +- Provisioning selects the correct isolation mode based on plan/region and records the choice in tenant metadata. +- Migrations/seeds run per tenant and can resume idempotently after failures. +- Lifecycle events drive downstream actions (Identity seeding, storage setup) without leaking tenant data across boundaries. diff --git a/docs/samples/multitenancy/redis-tenant-caching.md b/docs/samples/multitenancy/redis-tenant-caching.md new file mode 100644 index 0000000..48e4e2c --- /dev/null +++ b/docs/samples/multitenancy/redis-tenant-caching.md @@ -0,0 +1,33 @@ +# Scenario: Tenant-Scoped Redis Caching with Key Prefixing + +## Goal +Document a sample that combines multitenancy caching behavior with Redis key prefixing to guarantee cache isolation per tenant. + +## Sample name and location +- Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.RedisCaching` +- Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.RedisCaching` + +## Modules used +- Multitenancy core +- Multitenancy.Caching +- Multitenancy.Redis (planned adapter) +- Multitenancy.AspNetCore for inbound resolution + +## Prerequisites +- Base Web API solution; provide a Redis connection (local container or test instance) via configuration. +- Numbered step comments and matching README entries for every change. + +## Steps +1. Reference `CleanArchitecture.Extensions.Multitenancy`, `CleanArchitecture.Extensions.Multitenancy.AspNetCore`, `CleanArchitecture.Extensions.Multitenancy.Caching`, and add `CleanArchitecture.Extensions.Multitenancy.Redis` when available; ensure distributed cache services are registered before the multitenancy adapters. +2. Configure `MultitenancyOptions` to require tenant context for cache-bearing operations and enable `TenantScopedCacheBehavior` in MediatR. +3. Configure `RedisKeyStrategyOptions` with a prefix pattern such as `{tenant}:{resource}:{hash}`, disable global keys, and set default TTLs per resource; wire `TenantRedisConnectionResolver` if different endpoints per tenant are needed. +4. Register Redis distributed cache (StackExchange.Redis) pointing at the dev instance; expose the connection string via configuration so tests can swap to in-memory fakes. +5. Update caching helpers to reject keys that lack tenant context; audit background jobs to ensure `TenantContext` is restored before cache usage. +6. Add integration tests to assert keys include tenant prefixing, cross-tenant cache pollution is prevented, and cache hits/misses are counted per tenant. +7. Document migration steps for changing prefix formats and how to flush a single tenant's keys safely during deletion without issuing a global flush. +8. Note operational guardrails: avoid flush-all commands, monitor keyspace size per tenant, and alert on prefix collisions. + +## Validation +- Cache keys are consistently prefixed with the current tenant ID and optional environment/resource segments. +- Requests without tenant context fail fast when cache access is required. +- Cache isolation holds under concurrent cross-tenant load and during key format migrations. diff --git a/mkdocs.yml b/mkdocs.yml index f549482..9374c1b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,7 +60,15 @@ nav: - Recipes: - Authentication: recipes/authentication.md - Caching: recipes/caching.md - - Samples: samples/index.md + - Samples: + - Overview: samples/index.md + - Multitenancy: + - Header + Route Resolution: samples/multitenancy/header-and-route-resolution.md + - Background Jobs: samples/multitenancy/background-jobs-context-propagation.md + - EF Core Database-Per-Tenant: samples/multitenancy/efcore-database-per-tenant.md + - Identity (Tenant-Isolated): samples/multitenancy/identity-tenant-isolated-auth.md + - Provisioning (Hybrid Residency): samples/multitenancy/provisioning-hybrid-residency.md + - Redis Tenant Caching: samples/multitenancy/redis-tenant-caching.md - Reference: - Configuration: reference/configuration.md - Caching options: reference/caching-options.md From 19768eb021d2ff16549d96071a1b9e2196438c9a Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Mon, 12 Jan 2026 17:36:19 +0300 Subject: [PATCH 2/6] Add multitenancy header/route sample template and docs --- .../header-and-route-resolution.md | 37 +- .../.devcontainer/devcontainer.json | 31 + .../.editorconfig | 382 +++++ .../.github/workflows/azure-dev.yml | 56 + .../.gitignore | 482 +++++++ ...Multitenancy.HeaderAndRouteResolution.slnx | 22 + .../Directory.Build.props | 10 + .../Directory.Packages.props | 49 + .../README.md | 58 + .../azure.yaml | 9 + .../build.cake | 91 ++ .../global.json | 6 + .../infra/abbreviations.json | 138 ++ .../infra/core/ai/cognitiveservices.bicep | 56 + .../infra/core/ai/hub-dependencies.bicep | 170 +++ .../infra/core/ai/hub.bicep | 113 ++ .../infra/core/ai/project.bicep | 75 + .../infra/core/config/configstore.bicep | 48 + .../core/database/cosmos/cosmos-account.bicep | 50 + .../cosmos/mongo/cosmos-mongo-account.bicep | 23 + .../cosmos/mongo/cosmos-mongo-db.bicep | 47 + .../cosmos/sql/cosmos-sql-account.bicep | 22 + .../database/cosmos/sql/cosmos-sql-db.bicep | 74 + .../cosmos/sql/cosmos-sql-role-assign.bicep | 19 + .../cosmos/sql/cosmos-sql-role-def.bicep | 30 + .../core/database/mysql/flexibleserver.bicep | 65 + .../database/postgresql/flexibleserver.bicep | 153 ++ .../core/database/sqlserver/sqlserver.bicep | 204 +++ .../infra/core/gateway/apim.bicep | 79 + .../infra/core/host/ai-environment.bicep | 110 ++ .../infra/core/host/aks-agent-pool.bicep | 18 + .../infra/core/host/aks-managed-cluster.bicep | 140 ++ .../infra/core/host/aks.bicep | 285 ++++ .../core/host/appservice-appsettings.bicep | 17 + .../infra/core/host/appservice.bicep | 173 +++ .../infra/core/host/appserviceplan.bicep | 41 + .../core/host/container-app-upsert.bicep | 110 ++ .../infra/core/host/container-app.bicep | 169 +++ .../host/container-apps-environment.bicep | 41 + .../infra/core/host/container-apps.bicep | 42 + .../infra/core/host/container-registry.bicep | 137 ++ .../infra/core/host/functions.bicep | 100 ++ .../infra/core/host/staticwebapp.bicep | 22 + .../applicationinsights-dashboard.bicep | 1236 ++++++++++++++++ .../core/monitor/applicationinsights.bicep | 31 + .../infra/core/monitor/loganalytics.bicep | 22 + .../infra/core/monitor/monitoring.bicep | 33 + .../infra/core/networking/cdn-endpoint.bicep | 52 + .../infra/core/networking/cdn-profile.bicep | 34 + .../infra/core/networking/cdn.bicep | 42 + .../infra/core/search/search-services.bicep | 68 + .../security/aks-managed-cluster-access.bicep | 27 + .../core/security/configstore-access.bicep | 21 + .../infra/core/security/keyvault-access.bicep | 22 + .../infra/core/security/keyvault-secret.bicep | 31 + .../infra/core/security/keyvault.bicep | 55 + .../infra/core/security/registry-access.bicep | 19 + .../infra/core/security/role.bicep | 21 + .../infra/core/storage/storage-account.bicep | 103 ++ .../infra/core/testing/loadtesting.bicep | 15 + .../infra/main.bicep | 125 ++ .../infra/main.parameters.json | 21 + .../infra/services/web.bicep | 41 + .../src/Application/Application.csproj | 23 + .../Behaviours/AuthorizationBehaviour.cs | 80 ++ .../Common/Behaviours/LoggingBehaviour.cs | 35 + .../Common/Behaviours/PerformanceBehaviour.cs | 54 + .../Behaviours/UnhandledExceptionBehaviour.cs | 30 + .../Common/Behaviours/ValidationBehaviour.cs | 34 + .../Exceptions/ForbiddenAccessException.cs | 6 + .../Common/Exceptions/ValidationException.cs | 22 + .../Interfaces/IApplicationDbContext.cs | 12 + .../Common/Interfaces/IIdentityService.cs | 16 + .../Application/Common/Interfaces/IUser.cs | 8 + .../Common/Mappings/MappingExtensions.cs | 12 + .../Application/Common/Models/LookupDto.cs | 19 + .../Common/Models/PaginatedList.cs | 29 + .../src/Application/Common/Models/Result.cs | 24 + .../Common/Security/AuthorizeAttribute.cs | 23 + .../src/Application/DependencyInjection.cs | 25 + .../src/Application/GlobalUsings.cs | 6 + .../Commands/CreateTodoItem/CreateTodoItem.cs | 40 + .../CreateTodoItemCommandValidator.cs | 11 + .../Commands/DeleteTodoItem/DeleteTodoItem.cs | 31 + .../Commands/UpdateTodoItem/UpdateTodoItem.cs | 35 + .../UpdateTodoItemCommandValidator.cs | 11 + .../UpdateTodoItemDetail.cs | 39 + .../TodoItemCompletedEventHandler.cs | 21 + .../TodoItemCreatedEventHandler.cs | 21 + .../GetTodoItemsWithPagination.cs | 33 + ...etTodoItemsWithPaginationQueryValidator.cs | 16 + .../TodoItemBriefDto.cs | 22 + .../Commands/CreateTodoList/CreateTodoList.cs | 32 + .../CreateTodoListCommandValidator.cs | 26 + .../Commands/DeleteTodoList/DeleteTodoList.cs | 28 + .../Commands/PurgeTodoLists/PurgeTodoLists.cs | 26 + .../Commands/UpdateTodoList/UpdateTodoList.cs | 33 + .../UpdateTodoListCommandValidator.cs | 27 + .../TodoLists/Queries/GetTodos/GetTodos.cs | 38 + .../TodoLists/Queries/GetTodos/TodoItemDto.cs | 27 + .../TodoLists/Queries/GetTodos/TodoListDto.cs | 27 + .../TodoLists/Queries/GetTodos/TodosVm.cs | 10 + .../GetWeatherForecastsQuery.cs | 25 + .../GetWeatherForecasts/WeatherForecast.cs | 12 + .../src/Domain/Common/BaseAuditableEntity.cs | 12 + .../src/Domain/Common/BaseEntity.cs | 30 + .../src/Domain/Common/BaseEvent.cs | 7 + .../src/Domain/Common/ValueObject.cs | 45 + .../src/Domain/Constants/Policies.cs | 6 + .../src/Domain/Constants/Roles.cs | 6 + .../src/Domain/Domain.csproj | 12 + .../src/Domain/Entities/TodoItem.cs | 31 + .../src/Domain/Entities/TodoList.cs | 10 + .../src/Domain/Enums/PriorityLevel.cs | 9 + .../Domain/Events/TodoItemCompletedEvent.cs | 11 + .../src/Domain/Events/TodoItemCreatedEvent.cs | 11 + .../src/Domain/Events/TodoItemDeletedEvent.cs | 11 + .../Exceptions/UnsupportedColourException.cs | 9 + .../src/Domain/GlobalUsings.cs | 6 + .../src/Domain/ValueObjects/Colour.cs | 69 + .../Data/ApplicationDbContext.cs | 23 + .../Data/ApplicationDbContextInitialiser.cs | 108 ++ .../Configurations/TodoItemConfiguration.cs | 15 + .../Configurations/TodoListConfiguration.cs | 18 + .../AuditableEntityInterceptor.cs | 64 + .../DispatchDomainEventsInterceptor.cs | 50 + .../src/Infrastructure/DependencyInjection.cs | 53 + .../src/Infrastructure/GlobalUsings.cs | 1 + .../Identity/ApplicationUser.cs | 7 + .../Identity/IdentityResultExtensions.cs | 14 + .../Identity/IdentityService.cs | 81 ++ .../src/Infrastructure/Infrastructure.csproj | 19 + .../src/Web/DependencyInjection.cs | 60 + .../src/Web/Endpoints/TodoItems.cs | 60 + .../src/Web/Endpoints/TodoLists.cs | 48 + .../src/Web/Endpoints/Users.cs | 11 + .../src/Web/Endpoints/WeatherForecasts.cs | 22 + .../src/Web/GlobalUsings.cs | 3 + .../Infrastructure/CustomExceptionHandler.cs | 87 ++ .../Web/Infrastructure/EndpointGroupBase.cs | 7 + .../IEndpointRouteBuilderExtensions.cs | 38 + .../Infrastructure/MethodInfoExtensions.cs | 18 + .../WebApplicationExtensions.cs | 36 + .../src/Web/Program.cs | 43 + .../src/Web/Properties/launchSettings.json | 27 + .../src/Web/Services/CurrentUser.cs | 19 + .../src/Web/Web.csproj | 47 + .../src/Web/Web.http | 139 ++ .../src/Web/app.db | Bin 0 -> 4096 bytes .../src/Web/app.db-shm | Bin 0 -> 32768 bytes .../src/Web/app.db-wal | Bin 0 -> 177192 bytes .../src/Web/appsettings.Development.json | 13 + .../src/Web/appsettings.json | 10 + .../src/Web/config.nswag | 22 + .../src/Web/wwwroot/api/specification.json | 1268 +++++++++++++++++ .../src/Web/wwwroot/favicon.ico | Bin 0 -> 5430 bytes .../Application.FunctionalTests.csproj | 31 + .../BaseTestFixture.cs | 13 + .../CustomWebApplicationFactory.cs | 53 + .../GlobalUsings.cs | 4 + .../ITestDatabase.cs | 16 + .../SqliteTestDatabase.cs | 58 + .../TestDatabaseFactory.cs | 13 + .../Application.FunctionalTests/Testing.cs | 151 ++ .../TodoItems/Commands/CreateTodoItemTests.cs | 48 + .../TodoItems/Commands/DeleteTodoItemTests.cs | 40 + .../Commands/UpdateTodoItemDetailTests.cs | 58 + .../TodoItems/Commands/UpdateTodoItemTests.cs | 51 + .../TodoLists/Commands/CreateTodoListTests.cs | 53 + .../TodoLists/Commands/DeleteTodoListTests.cs | 32 + .../TodoLists/Commands/PurgeTodoListsTests.cs | 78 + .../TodoLists/Commands/UpdateTodoListTests.cs | 70 + .../TodoLists/Queries/GetTodosTests.cs | 61 + .../Application.UnitTests.csproj | 30 + .../Common/Behaviours/RequestLoggerTests.cs | 45 + .../Exceptions/ValidationExceptionTests.cs | 63 + .../Common/Mappings/MappingTests.cs | 66 + .../Domain.UnitTests/Domain.UnitTests.csproj | 27 + .../ValueObjects/ColourTests.cs | 49 + .../GlobalUsings.cs | 1 + .../Infrastructure.IntegrationTests.csproj | 22 + 181 files changed, 10911 insertions(+), 10 deletions(-) create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.devcontainer/devcontainer.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.editorconfig create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.github/workflows/azure-dev.yml create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Build.props create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/azure.yaml create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/build.cake create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/global.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/abbreviations.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/cognitiveservices.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub-dependencies.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/project.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/config/configstore.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/cosmos-account.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-account.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-db.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-account.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-db.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-assign.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-def.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/mysql/flexibleserver.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/postgresql/flexibleserver.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/sqlserver/sqlserver.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/gateway/apim.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/ai-environment.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-agent-pool.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-managed-cluster.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice-appsettings.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appserviceplan.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app-upsert.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps-environment.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-registry.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/functions.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/staticwebapp.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights-dashboard.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/loganalytics.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/monitoring.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-endpoint.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-profile.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/search/search-services.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/aks-managed-cluster-access.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/configstore-access.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-access.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-secret.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/registry-access.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/role.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/storage/storage-account.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/testing/loadtesting.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.parameters.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/services/web.bicep create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/AuthorizationBehaviour.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/LoggingBehaviour.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/PerformanceBehaviour.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/ValidationBehaviour.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ForbiddenAccessException.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ValidationException.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IApplicationDbContext.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IIdentityService.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IUser.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Mappings/MappingExtensions.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/LookupDto.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/PaginatedList.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/Result.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Security/AuthorizeAttribute.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/DependencyInjection.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/GlobalUsings.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseAuditableEntity.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEntity.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEvent.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/ValueObject.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Policies.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Roles.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Domain.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoItem.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoList.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Enums/PriorityLevel.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCompletedEvent.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCreatedEvent.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemDeletedEvent.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Exceptions/UnsupportedColourException.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/GlobalUsings.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/ValueObjects/Colour.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContext.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/DependencyInjection.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/GlobalUsings.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/ApplicationUser.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityResultExtensions.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityService.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Infrastructure.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoItems.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoLists.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/Users.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/WeatherForecasts.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/GlobalUsings.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/CustomExceptionHandler.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/EndpointGroupBase.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/MethodInfoExtensions.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/WebApplicationExtensions.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Properties/launchSettings.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Services/CurrentUser.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.http create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-shm create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-wal create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.Development.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/config.nswag create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/favicon.ico create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/Application.FunctionalTests.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/BaseTestFixture.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/GlobalUsings.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/ITestDatabase.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/SqliteTestDatabase.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TestDatabaseFactory.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/Testing.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Application.UnitTests.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Mappings/MappingTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/Domain.UnitTests.csproj create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/ValueObjects/ColourTests.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/GlobalUsings.cs create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj diff --git a/docs/samples/multitenancy/header-and-route-resolution.md b/docs/samples/multitenancy/header-and-route-resolution.md index 1cf9ecb..98508a8 100644 --- a/docs/samples/multitenancy/header-and-route-resolution.md +++ b/docs/samples/multitenancy/header-and-route-resolution.md @@ -1,31 +1,48 @@ # Scenario: Header + Route Resolution with ASP.NET Core Enforcement ## Goal -Document a sample that shows deterministic tenant resolution from route first, host second, and header fallback, with ProblemDetails responses when a tenant is missing or inactive. + +Document a sample that shows deterministic tenant resolution from route first, host second, and header fallback, with ProblemDetails responses when a tenant is missing or inactive ## Sample name and location + - Solution: `CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution` - Path: `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution` ## Modules used + - Multitenancy core (resolution pipeline + behaviors) - Multitenancy.AspNetCore (middleware, attributes, ProblemDetails) ## Prerequisites -- Generate the base Web API solution with the Clean Architecture template using SQLite (`dotnet new ca-sln -cf None ...`). + +- Install the .NET SDK required by the Clean Architecture template. - Keep numbered step comments in code changes and mirror them in the sample README per repository guidance. ## Steps -1. Add project references to `CleanArchitecture.Extensions.Multitenancy` and `CleanArchitecture.Extensions.Multitenancy.AspNetCore` from `src/`, and include those projects in the sample solution. -2. Configure `MultitenancyOptions` for route-first ordering (`Route > Host > Header > Query > Claim`), set header name `X-Tenant-ID`, require tenants by default, and disable fallback tenants. -3. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; place `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. -4. Add route conventions that group tenant-bound APIs under `/tenants/{tenantId}/...`; keep health/status endpoints outside the group for anonymous access. -5. Decorate tenant-bound endpoints with `RequireTenant`, and mark public endpoints with `AllowAnonymousTenant` to keep resolution optional without enforcement. -6. Enable `TenantExceptionHandler`/ProblemDetails so unresolved tenants return 400, missing tenants return 404, and suspended tenants return 403. -7. Add integration tests that cover: resolved via route, resolved via host mapping, header fallback when the route is absent, conflict handling when route/header disagree, and enforcement responses when no tenant is provided. -8. Update the sample README with the walkthrough (inputs, expected status codes) and middleware ordering reminders. + +1. Install Jason Taylor's Clean Architecture template and create the base Web API-only solution (no extensions yet). + - Install or update the template to the version we align with: + ```bash + dotnet new install Clean.Architecture.Solution.Template::10.0.0-preview + ``` + - From the repo root, create the sample solution under `CleanArchitecture.Extensions/samples` using SQLite: + ```bash + cd CleanArchitecture.Extensions/samples + dotnet new ca-sln -cf None -o CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution --database sqlite + ``` + - Verify the output folder exists and contains the new solution file plus `src/` and `tests/`. +2. Add project references to `CleanArchitecture.Extensions.Multitenancy` and `CleanArchitecture.Extensions.Multitenancy.AspNetCore` from `src/`, and include those projects in the sample solution. +3. Configure `MultitenancyOptions` for route-first ordering (`Route > Host > Header > Query > Claim`), set header name `X-Tenant-ID`, require tenants by default, and disable fallback tenants. +4. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; place `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. +5. Add route conventions that group tenant-bound APIs under `/tenants/{tenantId}/...`; keep health/status endpoints outside the group for anonymous access. +6. Decorate tenant-bound endpoints with `RequireTenant`, and mark public endpoints with `AllowAnonymousTenant` to keep resolution optional without enforcement. +7. Enable `TenantExceptionHandler`/ProblemDetails so unresolved tenants return 400, missing tenants return 404, and suspended tenants return 403. +8. Add integration tests that cover: resolved via route, resolved via host mapping, header fallback when the route is absent, conflict handling when route/header disagree, and enforcement responses when no tenant is provided. +9. Update the sample README with the walkthrough (inputs, expected status codes) and middleware ordering reminders. ## Validation + - Requests with `/tenants/{tenantId}` succeed only when the tenant exists and is active. - Requests without tenant context return the expected ProblemDetails payloads. - Tenant context is cleared per request (no AsyncLocal leakage between tests). diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.devcontainer/devcontainer.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.devcontainer/devcontainer.json new file mode 100644 index 0000000..575f26e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +{ + "name": "Azure Developer CLI", + "image": "mcr.microsoft.com/devcontainers/python:3.10-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" + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.editorconfig b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.editorconfig new file mode 100644 index 0000000..3fd8c1e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.editorconfig @@ -0,0 +1,382 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.{xml,csproj,props,targets,ruleset,nuspec,resx}] +indent_size = 2 + +# Javascript files +[*.js] +indent_size = 2 + +# Json files +[*.{json,config,nswag}] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = lf +insert_final_newline = true + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_style_namespace_declarations = file_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + +dotnet_style_namespace_match_folder = true:suggestion diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.github/workflows/azure-dev.yml b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.github/workflows/azure-dev.yml new file mode 100644 index 0000000..d161516 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.github/workflows/azure-dev.yml @@ -0,0 +1,56 @@ +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@v4 + + - name: Install azd + uses: Azure/setup-azd@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + 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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore new file mode 100644 index 0000000..164fea3 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore @@ -0,0 +1,482 @@ +## 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/ + +# 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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx new file mode 100644 index 0000000..aa51817 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Build.props b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Build.props new file mode 100644 index 0000000..0877ece --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Build.props @@ -0,0 +1,10 @@ + + + + net10.0 + true + $(MSBuildThisFileDirectory)..\artifacts + enable + enable + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props new file mode 100644 index 0000000..14feb67 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props @@ -0,0 +1,49 @@ + + + + true + 9.5.2 + 10.0.0 + 10.0.0 + 10.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md new file mode 100644 index 0000000..4490d56 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md @@ -0,0 +1,58 @@ +# CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution + +The project was generated using the [Clean.Architecture.Solution.Template](https://github.com/jasontaylordev/CleanArchitecture) version 10.0.0-preview. + +## Build + +Run `dotnet build -tl` to build the solution. + +## Run + +To run the web application: + +```bash +cd .\src\Web\ +dotnet watch run +``` + +Navigate to https://localhost:5001. The application will automatically reload if you change any of the source files. + +## Code Styles & Formatting + +The template includes [EditorConfig](https://editorconfig.org/) support to help maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The **.editorconfig** file defines the coding styles applicable to this solution. + +## Code Scaffolding + +The template includes support to scaffold new commands and queries. + +Start in the `.\src\Application\` folder. + +Create a new command: + +``` +dotnet new ca-usecase --name CreateTodoList --feature-name TodoLists --usecase-type command --return-type int +``` + +Create a new query: + +``` +dotnet new ca-usecase -n GetTodos -fn TodoLists -ut query -rt TodosVm +``` + +If you encounter the error *"No templates or subcommands found matching: 'ca-usecase'."*, install the template and try again: + +```bash +dotnet new install Clean.Architecture.Solution.Template::10.0.0-preview +``` + +## Test + +The solution contains unit, integration, and functional tests. + +To run the tests: +```bash +dotnet test +``` + +## Help +To learn more about the template go to the [project website](https://github.com/jasontaylordev/CleanArchitecture). Here you can find additional guidance, request new features, report a bug, and discuss the template with other users. \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/azure.yaml b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/azure.yaml new file mode 100644 index 0000000..2df2a2c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/azure.yaml @@ -0,0 +1,9 @@ +# 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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/build.cake b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/build.cake new file mode 100644 index 0000000..b341e3a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/build.cake @@ -0,0 +1,91 @@ + +var target = Argument("target", "Default"); +var configuration = Argument("configuration", "Release"); +var webServerPath = "./src/Web"; +var webClientPath = "./src/Web/ClientApp"; +var webUrl = "https://localhost:5001/"; + +IProcess webProcess = null; + +Task("Build") + .Does(() => { + Information("Building project..."); + DotNetBuild("./CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx", new DotNetBuildSettings { + Configuration = configuration + }); + }); + +Task("Run") + .Does(() => { + Information("Starting web project..."); + var processSettings = new ProcessSettings { + Arguments = $"run --project {webServerPath} --configuration {configuration} --no-build --no-restore", + RedirectStandardOutput = true, + RedirectStandardError = true + }; + webProcess = StartAndReturnProcess("dotnet", processSettings); + Information("Waiting for web project to be available..."); + var maxRetries = 30; + var delay = 2000; // 2 seconds + var retries = 0; + var isAvailable = false; + + while (retries < maxRetries && !isAvailable) { + try { + using (var client = new System.Net.Http.HttpClient()) { + var response = client.GetAsync(webUrl).Result; + if (response.IsSuccessStatusCode) { + isAvailable = true; + } + } + } catch { + // Ignore exceptions and retry + } + + if (!isAvailable) { + retries++; + System.Threading.Thread.Sleep(delay); + } + } + + if (!isAvailable) { + throw new Exception("Web project is not available after waiting."); + } + + Information("Web project is available."); + }); + +Task("Test") + .ContinueOnError() + .Does(() => { + Information("Testing project..."); + + var testSettings = new DotNetTestSettings { + Configuration = configuration, + NoBuild = true + }; + + + DotNetTest("./CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx", testSettings); + }); + +Teardown(context => +{ + if (webProcess != null) { + Information("Stopping web project..."); + webProcess.Kill(); + webProcess.WaitForExit(); + Information("Web project stopped."); + } +}); + +Task("Default") + .IsDependentOn("Build") + .IsDependentOn("Run") + .IsDependentOn("Test"); + +Task("Basic") + .IsDependentOn("Build") + .IsDependentOn("Test"); + +RunTarget(target); diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/global.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/global.json new file mode 100644 index 0000000..f72210c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/abbreviations.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/abbreviations.json new file mode 100644 index 0000000..59744c5 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/abbreviations.json @@ -0,0 +1,138 @@ +{ + "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-" +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/cognitiveservices.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/cognitiveservices.bicep new file mode 100644 index 0000000..76778e6 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/cognitiveservices.bicep @@ -0,0 +1,56 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub-dependencies.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub-dependencies.bicep new file mode 100644 index 0000000..eeabee7 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub-dependencies.bicep @@ -0,0 +1,170 @@ +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 : '' diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub.bicep new file mode 100644 index 0000000..576d13c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/hub.bicep @@ -0,0 +1,113 @@ +@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-01-01-preview' = { + 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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/project.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/project.bicep new file mode 100644 index 0000000..78b0d52 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/ai/project.bicep @@ -0,0 +1,75 @@ +@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-01-01-preview' = { + 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-01-01-preview' existing = { + name: hubName +} + +output id string = project.id +output name string = project.name +output principalId string = project.identity.principalId diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/config/configstore.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/config/configstore.bicep new file mode 100644 index 0000000..96818f1 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/config/configstore.bicep @@ -0,0 +1,48 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/cosmos-account.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/cosmos-account.bicep new file mode 100644 index 0000000..c16b229 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/cosmos-account.bicep @@ -0,0 +1,50 @@ +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-07-01' = { + parent: keyVault + name: connectionStringKey + properties: { + value: cosmos.listConnectionStrings().connectionStrings[0].connectionString + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +output connectionStringKey string = connectionStringKey +output endpoint string = cosmos.properties.documentEndpoint +output id string = cosmos.id +output name string = cosmos.name diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-account.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-account.bicep new file mode 100644 index 0000000..4aafbf3 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-account.bicep @@ -0,0 +1,23 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-db.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-db.bicep new file mode 100644 index 0000000..2a67057 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/mongo/cosmos-mongo-db.bicep @@ -0,0 +1,47 @@ +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-08-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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-account.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-account.bicep new file mode 100644 index 0000000..8431135 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-account.bicep @@ -0,0 +1,22 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-db.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-db.bicep new file mode 100644 index 0000000..265880d --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-db.bicep @@ -0,0 +1,74 @@ +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-05-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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-assign.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-assign.bicep new file mode 100644 index 0000000..3949efe --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-assign.bicep @@ -0,0 +1,19 @@ +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-05-15' = { + parent: cosmos + name: guid(roleDefinitionId, principalId, cosmos.id) + properties: { + principalId: principalId + roleDefinitionId: roleDefinitionId + scope: cosmos.id + } +} + +resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' existing = { + name: accountName +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-def.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-def.bicep new file mode 100644 index 0000000..778d6dc --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/cosmos/sql/cosmos-sql-role-def.bicep @@ -0,0 +1,30 @@ +metadata description = 'Creates a SQL role definition under an Azure Cosmos DB account.' +param accountName string + +resource roleDefinition 'Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions@2022-08-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-08-15' existing = { + name: accountName +} + +output id string = roleDefinition.id diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/mysql/flexibleserver.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/mysql/flexibleserver.bicep new file mode 100644 index 0000000..8319f1c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/mysql/flexibleserver.bicep @@ -0,0 +1,65 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/postgresql/flexibleserver.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/postgresql/flexibleserver.bicep new file mode 100644 index 0000000..603ac56 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/postgresql/flexibleserver.bicep @@ -0,0 +1,153 @@ +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-07-01' = { + parent: keyVault + name: 'dbAdminPassword' + properties: { + value: administratorLoginPassword + } +} + +resource appUserLoginPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'dbAppUserPassword' + properties: { + value: appUserLoginPassword + } +} + +resource sqlAzureConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: connectionStringKey + properties: { + value: '${connectionString}; Password=${appUserLoginPassword}' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +var connectionString = 'Host=${postgresServer.properties.fullyQualifiedDomainName};Port=5432;Database=${databaseName};Username=${appUserLogin}' +output connectionStringKey string = connectionStringKey diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/sqlserver/sqlserver.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/sqlserver/sqlserver.bicep new file mode 100644 index 0000000..6d4c392 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/database/sqlserver/sqlserver.bicep @@ -0,0 +1,204 @@ +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-07-01' = { + parent: keyVault + name: 'dbAdminPassword' + properties: { + value: sqlAdminPassword + } +} + +resource appUserPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'dbAppUserPassword' + properties: { + value: appUserPassword + } +} + +resource sqlAzureConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: connectionStringKey + properties: { + value: '${connectionString}; Password=${appUserPassword}' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +var connectionString = 'Server=${sqlServer.properties.fullyQualifiedDomainName}; Database=${sqlDatabase.name}; User=${appUser}' +output connectionStringKey string = connectionStringKey +output databaseName string = sqlDatabase.name diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/gateway/apim.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/gateway/apim.bicep new file mode 100644 index 0000000..be7464f --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/gateway/apim.bicep @@ -0,0 +1,79 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/ai-environment.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/ai-environment.bicep new file mode 100644 index 0000000..d03675f --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/ai-environment.bicep @@ -0,0 +1,110 @@ +@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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-agent-pool.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-agent-pool.bicep new file mode 100644 index 0000000..9c76435 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-agent-pool.bicep @@ -0,0 +1,18 @@ +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-10-02-preview' existing = { + name: clusterName +} + +resource nodePool 'Microsoft.ContainerService/managedClusters/agentPools@2023-10-02-preview' = { + parent: aksCluster + name: name + properties: config +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-managed-cluster.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-managed-cluster.bicep new file mode 100644 index 0000000..e28fda9 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks-managed-cluster.bicep @@ -0,0 +1,140 @@ +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-10-02-preview' = { + 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 +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks.bicep new file mode 100644 index 0000000..485864c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/aks.bicep @@ -0,0 +1,285 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice-appsettings.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice-appsettings.bicep new file mode 100644 index 0000000..f4b22f8 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice-appsettings.bicep @@ -0,0 +1,17 @@ +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 +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice.bicep new file mode 100644 index 0000000..4a872fb --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appservice.bicep @@ -0,0 +1,173 @@ +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-07-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}' diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appserviceplan.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appserviceplan.bicep new file mode 100644 index 0000000..a341035 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/appserviceplan.bicep @@ -0,0 +1,41 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app-upsert.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app-upsert.bicep new file mode 100644 index 0000000..5e05f89 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app-upsert.bicep @@ -0,0 +1,110 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app.bicep new file mode 100644 index 0000000..c64fc82 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-app.bicep @@ -0,0 +1,169 @@ +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}' : '' diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps-environment.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps-environment.bicep new file mode 100644 index 0000000..20f4632 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps-environment.bicep @@ -0,0 +1,41 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps.bicep new file mode 100644 index 0000000..64f4f2a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-apps.bicep @@ -0,0 +1,42 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-registry.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-registry.bicep new file mode 100644 index 0000000..d14731c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/container-registry.bicep @@ -0,0 +1,137 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/functions.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/functions.bicep new file mode 100644 index 0000000..cdf11e9 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/functions.bicep @@ -0,0 +1,100 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/staticwebapp.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/staticwebapp.bicep new file mode 100644 index 0000000..cedaf90 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/host/staticwebapp.bicep @@ -0,0 +1,22 @@ +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}' diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights-dashboard.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights-dashboard.bicep new file mode 100644 index 0000000..d082e66 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights-dashboard.bicep @@ -0,0 +1,1236 @@ +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 +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights.bicep new file mode 100644 index 0000000..850e9fe --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/applicationinsights.bicep @@ -0,0 +1,31 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/loganalytics.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/loganalytics.bicep new file mode 100644 index 0000000..33f9dc2 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/loganalytics.bicep @@ -0,0 +1,22 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/monitoring.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/monitoring.bicep new file mode 100644 index 0000000..7476125 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/monitor/monitoring.bicep @@ -0,0 +1,33 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-endpoint.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-endpoint.bicep new file mode 100644 index 0000000..5e8ab69 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-endpoint.bicep @@ -0,0 +1,52 @@ +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}' diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-profile.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-profile.bicep new file mode 100644 index 0000000..27669ee --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn-profile.bicep @@ -0,0 +1,34 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn.bicep new file mode 100644 index 0000000..de98a1f --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/networking/cdn.bicep @@ -0,0 +1,42 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/search/search-services.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/search/search-services.bicep new file mode 100644 index 0000000..33fd83e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/search/search-services.bicep @@ -0,0 +1,68 @@ +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 : '' + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/aks-managed-cluster-access.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/aks-managed-cluster-access.bicep new file mode 100644 index 0000000..aedb080 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/aks-managed-cluster-access.bicep @@ -0,0 +1,27 @@ +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-10-02-preview' existing = { + name: clusterName +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/configstore-access.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/configstore-access.bicep new file mode 100644 index 0000000..de72b94 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/configstore-access.bicep @@ -0,0 +1,21 @@ +@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' + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-access.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-access.bicep new file mode 100644 index 0000000..316775f --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-access.bicep @@ -0,0 +1,22 @@ +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-07-01' = { + parent: keyVault + name: name + properties: { + accessPolicies: [ { + objectId: principalId + tenantId: subscription().tenantId + permissions: permissions + } ] + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-secret.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-secret.bicep new file mode 100644 index 0000000..7441b29 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault-secret.bicep @@ -0,0 +1,31 @@ +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-07-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-07-01' existing = { + name: keyVaultName +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault.bicep new file mode 100644 index 0000000..bb1ee29 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/keyvault.bicep @@ -0,0 +1,55 @@ +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-07-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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/registry-access.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/registry-access.bicep new file mode 100644 index 0000000..fc66837 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/registry-access.bicep @@ -0,0 +1,19 @@ +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 +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/role.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/role.bicep new file mode 100644 index 0000000..0b30cfd --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/security/role.bicep @@ -0,0 +1,21 @@ +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) + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/storage/storage-account.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/storage/storage-account.bicep new file mode 100644 index 0000000..1a4e560 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/storage/storage-account.bicep @@ -0,0 +1,103 @@ +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-01-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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/testing/loadtesting.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/testing/loadtesting.bicep new file mode 100644 index 0000000..4678108 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/core/testing/loadtesting.bicep @@ -0,0 +1,15 @@ +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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.bicep new file mode 100644 index 0000000..a913796 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.bicep @@ -0,0 +1,125 @@ +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 +} + + + +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 +output WEB_BASE_URI string = web.outputs.uri diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.parameters.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.parameters.json new file mode 100644 index 0000000..a5d911a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/main.parameters.json @@ -0,0 +1,21 @@ +{ + "$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)" + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/services/web.bicep b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/services/web.bicep new file mode 100644 index 0000000..ed1ba77 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/infra/services/web.bicep @@ -0,0 +1,41 @@ +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: '9.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 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj new file mode 100644 index 0000000..b4dfbc8 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj @@ -0,0 +1,23 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application + + + + + + + + + + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/AuthorizationBehaviour.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/AuthorizationBehaviour.cs new file mode 100644 index 0000000..7ec1530 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/AuthorizationBehaviour.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Security; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Behaviours; + +public class AuthorizationBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly IUser _user; + private readonly IIdentityService _identityService; + + public AuthorizationBehaviour( + IUser user, + IIdentityService identityService) + { + _user = user; + _identityService = identityService; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var authorizeAttributes = request.GetType().GetCustomAttributes(); + + if (authorizeAttributes.Any()) + { + // Must be authenticated user + if (_user.Id == null) + { + throw new UnauthorizedAccessException(); + } + + // Role-based authorization + var authorizeAttributesWithRoles = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Roles)); + + if (authorizeAttributesWithRoles.Any()) + { + var authorized = false; + + foreach (var roles in authorizeAttributesWithRoles.Select(a => a.Roles.Split(','))) + { + foreach (var role in roles) + { + var isInRole = _user.Roles?.Any(x => role == x)??false; + if (isInRole) + { + authorized = true; + break; + } + } + } + + // Must be a member of at least one role in roles + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + + // Policy-based authorization + var authorizeAttributesWithPolicies = authorizeAttributes.Where(a => !string.IsNullOrWhiteSpace(a.Policy)); + if (authorizeAttributesWithPolicies.Any()) + { + foreach (var policy in authorizeAttributesWithPolicies.Select(a => a.Policy)) + { + var authorized = await _identityService.AuthorizeAsync(_user.Id, policy); + + if (!authorized) + { + throw new ForbiddenAccessException(); + } + } + } + } + + // User is authorized / authorization not required + return await next(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/LoggingBehaviour.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/LoggingBehaviour.cs new file mode 100644 index 0000000..0d2ae15 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/LoggingBehaviour.cs @@ -0,0 +1,35 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using MediatR.Pipeline; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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.Extensions.Samples.Multitenancy.HeaderAndRouteResolution Request: {Name} {@UserId} {@UserName} {@Request}", + requestName, userId, userName, request); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/PerformanceBehaviour.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/PerformanceBehaviour.cs new file mode 100644 index 0000000..846ad48 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/PerformanceBehaviour.cs @@ -0,0 +1,54 @@ +using System.Diagnostics; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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.Extensions.Samples.Multitenancy.HeaderAndRouteResolution Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds) {@UserId} {@UserName} {@Request}", + requestName, elapsedMilliseconds, userId, userName, request); + } + + return response; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs new file mode 100644 index 0000000..ddd4f1c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/UnhandledExceptionBehaviour.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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.Extensions.Samples.Multitenancy.HeaderAndRouteResolution Request: Unhandled Exception for Request {Name} {@Request}", requestName, request); + + throw; + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/ValidationBehaviour.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000..8d66652 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,34 @@ +using ValidationException = CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions.ValidationException; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Behaviours; + +public class ValidationBehaviour : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehaviour(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var validationResults = await Task.WhenAll( + _validators.Select(v => + v.ValidateAsync(new ValidationContext(request), cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Any()) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ForbiddenAccessException.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ForbiddenAccessException.cs new file mode 100644 index 0000000..8e2d95f --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ForbiddenAccessException.cs @@ -0,0 +1,6 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; + +public class ForbiddenAccessException : Exception +{ + public ForbiddenAccessException() : base() { } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ValidationException.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000..53f09b4 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,22 @@ +using FluentValidation.Results; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; + +public class ValidationException : Exception +{ + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public IDictionary Errors { get; } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IApplicationDbContext.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IApplicationDbContext.cs new file mode 100644 index 0000000..3facab6 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IApplicationDbContext.cs @@ -0,0 +1,12 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +public interface IApplicationDbContext +{ + DbSet TodoLists { get; } + + DbSet TodoItems { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IIdentityService.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IIdentityService.cs new file mode 100644 index 0000000..9519c73 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IIdentityService.cs @@ -0,0 +1,16 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +public interface IIdentityService +{ + Task GetUserNameAsync(string userId); + + Task IsInRoleAsync(string userId, string role); + + Task AuthorizeAsync(string userId, string policyName); + + Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password); + + Task DeleteUserAsync(string userId); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IUser.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IUser.cs new file mode 100644 index 0000000..7fbf34d --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Interfaces/IUser.cs @@ -0,0 +1,8 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +public interface IUser +{ + string? Id { get; } + List? Roles { get; } + +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Mappings/MappingExtensions.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Mappings/MappingExtensions.cs new file mode 100644 index 0000000..cd2188a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Mappings/MappingExtensions.cs @@ -0,0 +1,12 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/LookupDto.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/LookupDto.cs new file mode 100644 index 0000000..15a5505 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/LookupDto.cs @@ -0,0 +1,19 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; + +public class LookupDto +{ + public int Id { get; init; } + + public string? Title { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap(); + CreateMap(); + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/PaginatedList.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/PaginatedList.cs new file mode 100644 index 0000000..5f2f0c9 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/PaginatedList.cs @@ -0,0 +1,29 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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; + + 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); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/Result.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/Result.cs new file mode 100644 index 0000000..100fe99 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Models/Result.cs @@ -0,0 +1,24 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; + +public class Result +{ + internal Result(bool succeeded, IEnumerable errors) + { + Succeeded = succeeded; + Errors = errors.ToArray(); + } + + public bool Succeeded { get; init; } + + public string[] Errors { get; init; } + + public static Result Success() + { + return new Result(true, Array.Empty()); + } + + public static Result Failure(IEnumerable errors) + { + return new Result(false, errors); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Security/AuthorizeAttribute.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Security/AuthorizeAttribute.cs new file mode 100644 index 0000000..e5e5911 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Common/Security/AuthorizeAttribute.cs @@ -0,0 +1,23 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Security; + +/// +/// Specifies the class this attribute is applied to requires authorization. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public class AuthorizeAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public AuthorizeAttribute() { } + + /// + /// Gets or sets a comma delimited list of roles that are allowed to access the resource. + /// + public string Roles { get; set; } = string.Empty; + + /// + /// Gets or sets the policy name that determines access to the resource. + /// + public string Policy { get; set; } = string.Empty; +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/DependencyInjection.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/DependencyInjection.cs new file mode 100644 index 0000000..87f522b --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/DependencyInjection.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Behaviours; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DependencyInjection +{ + public static void AddApplicationServices(this IHostApplicationBuilder builder) + { + builder.Services.AddAutoMapper(cfg => + cfg.AddMaps(Assembly.GetExecutingAssembly())); + + builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + + builder.Services.AddMediatR(cfg => { + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + cfg.AddOpenRequestPreProcessor(typeof(LoggingBehaviour<>)); + cfg.AddOpenBehavior(typeof(UnhandledExceptionBehaviour<,>)); + cfg.AddOpenBehavior(typeof(AuthorizationBehaviour<,>)); + cfg.AddOpenBehavior(typeof(ValidationBehaviour<,>)); + cfg.AddOpenBehavior(typeof(PerformanceBehaviour<,>)); + }); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/GlobalUsings.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/GlobalUsings.cs new file mode 100644 index 0000000..fa904ba --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using Ardalis.GuardClauses; +global using AutoMapper; +global using AutoMapper.QueryableExtensions; +global using Microsoft.EntityFrameworkCore; +global using FluentValidation; +global using MediatR; \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs new file mode 100644 index 0000000..d32e471 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItem.cs @@ -0,0 +1,40 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs new file mode 100644 index 0000000..61b2dfd --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/CreateTodoItem/CreateTodoItemCommandValidator.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; + +public class CreateTodoItemCommandValidator : AbstractValidator +{ + public CreateTodoItemCommandValidator() + { + RuleFor(v => v.Title) + .MaximumLength(200) + .NotEmpty(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs new file mode 100644 index 0000000..ed7f0d2 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/DeleteTodoItem/DeleteTodoItem.cs @@ -0,0 +1,31 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); + } + +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs new file mode 100644 index 0000000..4e2b608 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItem.cs @@ -0,0 +1,35 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs new file mode 100644 index 0000000..0308739 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItem/UpdateTodoItemCommandValidator.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.UpdateTodoItem; + +public class UpdateTodoItemCommandValidator : AbstractValidator +{ + public UpdateTodoItemCommandValidator() + { + RuleFor(v => v.Title) + .MaximumLength(200) + .NotEmpty(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs new file mode 100644 index 0000000..ecf8d62 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Commands/UpdateTodoItemDetail/UpdateTodoItemDetail.cs @@ -0,0 +1,39 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Enums; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs new file mode 100644 index 0000000..076c24d --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCompletedEventHandler.cs @@ -0,0 +1,21 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.EventHandlers; + +public class TodoItemCompletedEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public TodoItemCompletedEventHandler(ILogger logger) + { + _logger = logger; + } + + public Task Handle(TodoItemCompletedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution Domain Event: {DomainEvent}", notification.GetType().Name); + + return Task.CompletedTask; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs new file mode 100644 index 0000000..0981452 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/EventHandlers/TodoItemCreatedEventHandler.cs @@ -0,0 +1,21 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.EventHandlers; + +public class TodoItemCreatedEventHandler : INotificationHandler +{ + private readonly ILogger _logger; + + public TodoItemCreatedEventHandler(ILogger logger) + { + _logger = logger; + } + + public Task Handle(TodoItemCreatedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution Domain Event: {DomainEvent}", notification.GetType().Name); + + return Task.CompletedTask; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs new file mode 100644 index 0000000..35dd090 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPagination.cs @@ -0,0 +1,33 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Mappings; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs new file mode 100644 index 0000000..a581fd6 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/GetTodoItemsWithPaginationQueryValidator.cs @@ -0,0 +1,16 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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."); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs new file mode 100644 index 0000000..c4ce3e4 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoItems/Queries/GetTodoItemsWithPagination/TodoItemBriefDto.cs @@ -0,0 +1,22 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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(); + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs new file mode 100644 index 0000000..de17f6e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoList.cs @@ -0,0 +1,32 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs new file mode 100644 index 0000000..e5c1019 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/CreateTodoList/CreateTodoListCommandValidator.cs @@ -0,0 +1,26 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; + +public class CreateTodoListCommandValidator : AbstractValidator +{ + private readonly IApplicationDbContext _context; + + public CreateTodoListCommandValidator(IApplicationDbContext context) + { + _context = context; + + RuleFor(v => v.Title) + .NotEmpty() + .MaximumLength(200) + .MustAsync(BeUniqueTitle) + .WithMessage("'{PropertyName}' must be unique.") + .WithErrorCode("Unique"); + } + + public async Task BeUniqueTitle(string title, CancellationToken cancellationToken) + { + return !await _context.TodoLists + .AnyAsync(l => l.Title == title, cancellationToken); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs new file mode 100644 index 0000000..895eb25 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/DeleteTodoList/DeleteTodoList.cs @@ -0,0 +1,28 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.DeleteTodoList; + +public record DeleteTodoListCommand(int Id) : IRequest; + +public class DeleteTodoListCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _context; + + public DeleteTodoListCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task Handle(DeleteTodoListCommand request, CancellationToken cancellationToken) + { + var entity = await _context.TodoLists + .Where(l => l.Id == request.Id) + .SingleOrDefaultAsync(cancellationToken); + + Guard.Against.NotFound(request.Id, entity); + + _context.TodoLists.Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs new file mode 100644 index 0000000..efb7941 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/PurgeTodoLists/PurgeTodoLists.cs @@ -0,0 +1,26 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Security; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Constants; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs new file mode 100644 index 0000000..1b8bb2e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoList.cs @@ -0,0 +1,33 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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); + + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs new file mode 100644 index 0000000..d319719 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Commands/UpdateTodoList/UpdateTodoListCommandValidator.cs @@ -0,0 +1,27 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.UpdateTodoList; + +public class UpdateTodoListCommandValidator : AbstractValidator +{ + private readonly IApplicationDbContext _context; + + public UpdateTodoListCommandValidator(IApplicationDbContext context) + { + _context = context; + + RuleFor(v => v.Title) + .NotEmpty() + .MaximumLength(200) + .MustAsync(BeUniqueTitle) + .WithMessage("'{PropertyName}' must be unique.") + .WithErrorCode("Unique"); + } + + public async Task BeUniqueTitle(UpdateTodoListCommand model, string title, CancellationToken cancellationToken) + { + return !await _context.TodoLists + .Where(l => l.Id != model.Id) + .AnyAsync(l => l.Title == title, cancellationToken); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs new file mode 100644 index 0000000..45729ef --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/GetTodos.cs @@ -0,0 +1,38 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Security; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Enums; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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) + }; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs new file mode 100644 index 0000000..fabb8b7 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoItemDto.cs @@ -0,0 +1,27 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Queries.GetTodos; + +public class TodoItemDto +{ + public int Id { get; init; } + + public int ListId { get; init; } + + public string? Title { get; init; } + + public bool Done { get; init; } + + public int Priority { get; init; } + + public string? Note { get; init; } + + private class Mapping : Profile + { + public Mapping() + { + CreateMap().ForMember(d => d.Priority, + opt => opt.MapFrom(s => (int)s.Priority)); + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs new file mode 100644 index 0000000..4a48333 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodoListDto.cs @@ -0,0 +1,27 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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(); + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs new file mode 100644 index 0000000..a0d3381 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/TodoLists/Queries/GetTodos/TodosVm.cs @@ -0,0 +1,10 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Queries.GetTodos; + +public class TodosVm +{ + public IReadOnlyCollection PriorityLevels { get; init; } = Array.Empty(); + + public IReadOnlyCollection Lists { get; init; } = Array.Empty(); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs new file mode 100644 index 0000000..844d6b8 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/GetWeatherForecastsQuery.cs @@ -0,0 +1,25 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +public record GetWeatherForecastsQuery : IRequest>; + +public class GetWeatherForecastsQueryHandler : IRequestHandler> +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task> Handle(GetWeatherForecastsQuery request, CancellationToken cancellationToken) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + var rng = new Random(); + + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateTime.Now.AddDays(index), + TemperatureC = rng.Next(-20, 55), + Summary = Summaries[rng.Next(Summaries.Length)] + }); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs new file mode 100644 index 0000000..ed6afde --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/WeatherForecasts/Queries/GetWeatherForecasts/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.WeatherForecasts.Queries.GetWeatherForecasts; + +public class WeatherForecast +{ + public DateTime Date { get; init; } + + public int TemperatureC { get; init; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string Summary { get; init; } = string.Empty; +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseAuditableEntity.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseAuditableEntity.cs new file mode 100644 index 0000000..7fe301c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseAuditableEntity.cs @@ -0,0 +1,12 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Common; + +public abstract class BaseAuditableEntity : BaseEntity +{ + public DateTimeOffset Created { get; set; } + + public string? CreatedBy { get; set; } + + public DateTimeOffset LastModified { get; set; } + + public string? LastModifiedBy { get; set; } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEntity.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..94afbf3 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEntity.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Common; + +public abstract class BaseEntity +{ + // This can easily be modified to be BaseEntity and public T Id to support different key types. + // Using non-generic integer types for simplicity + public int Id { get; set; } + + private readonly List _domainEvents = new(); + + [NotMapped] + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(BaseEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } + + public void RemoveDomainEvent(BaseEvent domainEvent) + { + _domainEvents.Remove(domainEvent); + } + + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEvent.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEvent.cs new file mode 100644 index 0000000..c19e404 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/BaseEvent.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Common; + +public abstract class BaseEvent : INotification +{ +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/ValueObject.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/ValueObject.cs new file mode 100644 index 0000000..ad6357d --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Common/ValueObject.cs @@ -0,0 +1,45 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Policies.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Policies.cs new file mode 100644 index 0000000..eaad69a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Policies.cs @@ -0,0 +1,6 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Constants; + +public abstract class Policies +{ + public const string CanPurge = nameof(CanPurge); +} \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Roles.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Roles.cs new file mode 100644 index 0000000..8c74d2b --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Constants/Roles.cs @@ -0,0 +1,6 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Constants; + +public abstract class Roles +{ + public const string Administrator = nameof(Administrator); +} \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Domain.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Domain.csproj new file mode 100644 index 0000000..9bf1f67 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Domain.csproj @@ -0,0 +1,12 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoItem.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoItem.cs new file mode 100644 index 0000000..10bd6cb --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoItem.cs @@ -0,0 +1,31 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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!; +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoList.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoList.cs new file mode 100644 index 0000000..eeff8f4 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Entities/TodoList.cs @@ -0,0 +1,10 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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(); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Enums/PriorityLevel.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Enums/PriorityLevel.cs new file mode 100644 index 0000000..075e698 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Enums/PriorityLevel.cs @@ -0,0 +1,9 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Enums; + +public enum PriorityLevel +{ + None = 0, + Low = 1, + Medium = 2, + High = 3 +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCompletedEvent.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCompletedEvent.cs new file mode 100644 index 0000000..eae1797 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCompletedEvent.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; + +public class TodoItemCompletedEvent : BaseEvent +{ + public TodoItemCompletedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCreatedEvent.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCreatedEvent.cs new file mode 100644 index 0000000..c51b227 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemCreatedEvent.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; + +public class TodoItemCreatedEvent : BaseEvent +{ + public TodoItemCreatedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemDeletedEvent.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemDeletedEvent.cs new file mode 100644 index 0000000..8ee77c0 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Events/TodoItemDeletedEvent.cs @@ -0,0 +1,11 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; + +public class TodoItemDeletedEvent : BaseEvent +{ + public TodoItemDeletedEvent(TodoItem item) + { + Item = item; + } + + public TodoItem Item { get; } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Exceptions/UnsupportedColourException.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Exceptions/UnsupportedColourException.cs new file mode 100644 index 0000000..8fe38e9 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/Exceptions/UnsupportedColourException.cs @@ -0,0 +1,9 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Exceptions; + +public class UnsupportedColourException : Exception +{ + public UnsupportedColourException(string code) + : base($"Colour \"{code}\" is unsupported.") + { + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/GlobalUsings.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/GlobalUsings.cs new file mode 100644 index 0000000..a319fa4 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Common; +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Enums; +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Events; +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Exceptions; +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.ValueObjects; \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/ValueObjects/Colour.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/ValueObjects/Colour.cs new file mode 100644 index 0000000..51af988 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Domain/ValueObjects/Colour.cs @@ -0,0 +1,69 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContext.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..4e36431 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContext.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; + +public class ApplicationDbContext : IdentityDbContext, IApplicationDbContext +{ + public ApplicationDbContext(DbContextOptions options) : base(options) { } + + public DbSet TodoLists => Set(); + + public DbSet TodoItems => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs new file mode 100644 index 0000000..bffc18c --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/ApplicationDbContextInitialiser.cs @@ -0,0 +1,108 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Constants; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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(); + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs new file mode 100644 index 0000000..ef6a9e9 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoItemConfiguration.cs @@ -0,0 +1,15 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data.Configurations; + +public class TodoItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs new file mode 100644 index 0000000..18767ad --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Configurations/TodoListConfiguration.cs @@ -0,0 +1,18 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data.Configurations; + +public class TodoListConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Title) + .HasMaxLength(200) + .IsRequired(); + + builder + .OwnsOne(b => b.Colour); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..8c6b5dd --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/AuditableEntityInterceptor.cs @@ -0,0 +1,64 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data.Interceptors; + +public class AuditableEntityInterceptor : SaveChangesInterceptor +{ + private readonly IUser _user; + private readonly TimeProvider _dateTime; + + public AuditableEntityInterceptor( + IUser user, + TimeProvider dateTime) + { + _user = user; + _dateTime = dateTime; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData.Context); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + UpdateEntities(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public void UpdateEntities(DbContext? context) + { + if (context == null) return; + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities()) + { + var utcNow = _dateTime.GetUtcNow(); + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedBy = _user.Id; + entry.Entity.Created = utcNow; + } + entry.Entity.LastModifiedBy = _user.Id; + entry.Entity.LastModified = utcNow; + } + } + } +} + +public static class Extensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) => + entry.References.Any(r => + r.TargetEntry != null && + r.TargetEntry.Metadata.IsOwned() && + (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs new file mode 100644 index 0000000..3f20b72 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs @@ -0,0 +1,50 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Common; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data.Interceptors; + +public class DispatchDomainEventsInterceptor : SaveChangesInterceptor +{ + private readonly IMediator _mediator; + + public DispatchDomainEventsInterceptor(IMediator mediator) + { + _mediator = mediator; + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + DispatchDomainEvents(eventData.Context).GetAwaiter().GetResult(); + + return base.SavingChanges(eventData, result); + + } + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + await DispatchDomainEvents(eventData.Context); + + return await base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public async Task DispatchDomainEvents(DbContext? context) + { + if (context == null) return; + + var entities = context.ChangeTracker + .Entries() + .Where(e => e.Entity.DomainEvents.Any()) + .Select(e => e.Entity); + + var domainEvents = entities + .SelectMany(e => e.DomainEvents) + .ToList(); + + entities.ToList().ForEach(e => e.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + await _mediator.Publish(domainEvent); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/DependencyInjection.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..acb65a3 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/DependencyInjection.cs @@ -0,0 +1,53 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Constants; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data.Interceptors; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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("CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolutionDb"); + Guard.Against.Null(connectionString, message: "Connection string 'CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolutionDb' not found."); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddDbContext((sp, options) => + { + options.AddInterceptors(sp.GetServices()); + options.UseSqlite(connectionString); + options.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); + }); + + + builder.Services.AddScoped(provider => provider.GetRequiredService()); + + builder.Services.AddScoped(); + + builder.Services.AddAuthentication() + .AddBearerToken(IdentityConstants.BearerScheme); + + builder.Services.AddAuthorizationBuilder(); + + builder.Services + .AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores() + .AddApiEndpoints(); + + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddTransient(); + + builder.Services.AddAuthorization(options => + options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator))); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/GlobalUsings.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..4668da2 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/GlobalUsings.cs @@ -0,0 +1 @@ +global using Ardalis.GuardClauses; \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/ApplicationUser.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..6dffb5d --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; + +public class ApplicationUser : IdentityUser +{ +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityResultExtensions.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityResultExtensions.cs new file mode 100644 index 0000000..d8cd17e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityResultExtensions.cs @@ -0,0 +1,14 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; +using Microsoft.AspNetCore.Identity; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; + +public static class IdentityResultExtensions +{ + public static Result ToApplicationResult(this IdentityResult result) + { + return result.Succeeded + ? Result.Success() + : Result.Failure(result.Errors.Select(e => e.Description)); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityService.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..542f98e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,81 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; + +public class IdentityService : IIdentityService +{ + private readonly UserManager _userManager; + private readonly IUserClaimsPrincipalFactory _userClaimsPrincipalFactory; + private readonly IAuthorizationService _authorizationService; + + public IdentityService( + UserManager userManager, + IUserClaimsPrincipalFactory userClaimsPrincipalFactory, + IAuthorizationService authorizationService) + { + _userManager = userManager; + _userClaimsPrincipalFactory = userClaimsPrincipalFactory; + _authorizationService = authorizationService; + } + + public async Task GetUserNameAsync(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + + return user?.UserName; + } + + public async Task<(Result Result, string UserId)> CreateUserAsync(string userName, string password) + { + var user = new ApplicationUser + { + UserName = userName, + Email = userName, + }; + + var result = await _userManager.CreateAsync(user, password); + + return (result.ToApplicationResult(), user.Id); + } + + public async Task IsInRoleAsync(string userId, string role) + { + var user = await _userManager.FindByIdAsync(userId); + + return user != null && await _userManager.IsInRoleAsync(user, role); + } + + public async Task AuthorizeAsync(string userId, string policyName) + { + var user = await _userManager.FindByIdAsync(userId); + + if (user == null) + { + return false; + } + + var principal = await _userClaimsPrincipalFactory.CreateAsync(user); + + var result = await _authorizationService.AuthorizeAsync(principal, policyName); + + return result.Succeeded; + } + + public async Task DeleteUserAsync(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + + return user != null ? await DeleteUserAsync(user) : Result.Success(); + } + + public async Task DeleteUserAsync(ApplicationUser user) + { + var result = await _userManager.DeleteAsync(user); + + return result.ToApplicationResult(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Infrastructure.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..3ae7a6e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Infrastructure/Infrastructure.csproj @@ -0,0 +1,19 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure + + + + + + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs new file mode 100644 index 0000000..37e852a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs @@ -0,0 +1,60 @@ +using Azure.Identity; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Services; +using Microsoft.AspNetCore.Mvc; + +using NSwag; +using NSwag.Generation.Processors.Security; + +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.AddHealthChecks() + .AddDbContextCheck(); + + builder.Services.AddExceptionHandler(); + + + // Customise default API behaviour + builder.Services.Configure(options => + options.SuppressModelStateInvalidFilter = true); + + builder.Services.AddEndpointsApiExplorer(); + + builder.Services.AddOpenApiDocument((configure, sp) => + { + configure.Title = "CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution API"; + + // Add JWT + configure.AddSecurity("JWT", Enumerable.Empty(), new OpenApiSecurityScheme + { + Type = OpenApiSecuritySchemeType.ApiKey, + Name = "Authorization", + In = OpenApiSecurityApiKeyLocation.Header, + Description = "Type into the textbox: Bearer {your JWT token}." + }); + + configure.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT")); + }); + } + + public static void AddKeyVaultIfConfigured(this IHostApplicationBuilder builder) + { + var keyVaultUri = builder.Configuration["AZURE_KEY_VAULT_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(keyVaultUri)) + { + builder.Configuration.AddAzureKeyVault( + new Uri(keyVaultUri), + new DefaultAzureCredential()); + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoItems.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoItems.cs new file mode 100644 index 0000000..02d3e80 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoItems.cs @@ -0,0 +1,60 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.DeleteTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.UpdateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.UpdateTodoItemDetail; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Queries.GetTodoItemsWithPagination; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Endpoints; + +public class TodoItems : EndpointGroupBase +{ + public override void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapGet(GetTodoItemsWithPagination).RequireAuthorization(); + groupBuilder.MapPost(CreateTodoItem).RequireAuthorization(); + groupBuilder.MapPut(UpdateTodoItem, "{id}").RequireAuthorization(); + groupBuilder.MapPut(UpdateTodoItemDetail, "UpdateDetail/{id}").RequireAuthorization(); + groupBuilder.MapDelete(DeleteTodoItem, "{id}").RequireAuthorization(); + } + + public async Task>> GetTodoItemsWithPagination(ISender sender, [AsParameters] GetTodoItemsWithPaginationQuery query) + { + var result = await sender.Send(query); + + return TypedResults.Ok(result); + } + + public async Task> CreateTodoItem(ISender sender, CreateTodoItemCommand command) + { + var id = await sender.Send(command); + + return TypedResults.Created($"/{nameof(TodoItems)}/{id}", id); + } + + public async Task> UpdateTodoItem(ISender sender, int id, UpdateTodoItemCommand command) + { + if (id != command.Id) return TypedResults.BadRequest(); + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + public async Task> UpdateTodoItemDetail(ISender sender, int id, UpdateTodoItemDetailCommand command) + { + if (id != command.Id) return TypedResults.BadRequest(); + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + public async Task DeleteTodoItem(ISender sender, int id) + { + await sender.Send(new DeleteTodoItemCommand(id)); + + return TypedResults.NoContent(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoLists.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoLists.cs new file mode 100644 index 0000000..40933ae --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/TodoLists.cs @@ -0,0 +1,48 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.DeleteTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.UpdateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Queries.GetTodos; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Endpoints; + +public class TodoLists : EndpointGroupBase +{ + public override void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapGet(GetTodoLists).RequireAuthorization(); + groupBuilder.MapPost(CreateTodoList).RequireAuthorization(); + groupBuilder.MapPut(UpdateTodoList, "{id}").RequireAuthorization(); + groupBuilder.MapDelete(DeleteTodoList, "{id}").RequireAuthorization(); + } + + public async Task> GetTodoLists(ISender sender) + { + var vm = await sender.Send(new GetTodosQuery()); + + return TypedResults.Ok(vm); + } + + public async Task> CreateTodoList(ISender sender, CreateTodoListCommand command) + { + var id = await sender.Send(command); + + return TypedResults.Created($"/{nameof(TodoLists)}/{id}", id); + } + + public async Task> UpdateTodoList(ISender sender, int id, UpdateTodoListCommand command) + { + if (id != command.Id) return TypedResults.BadRequest(); + + await sender.Send(command); + + return TypedResults.NoContent(); + } + + public async Task DeleteTodoList(ISender sender, int id) + { + await sender.Send(new DeleteTodoListCommand(id)); + + return TypedResults.NoContent(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/Users.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/Users.cs new file mode 100644 index 0000000..d51e00b --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/Users.cs @@ -0,0 +1,11 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Endpoints; + +public class Users : EndpointGroupBase +{ + public override void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapIdentityApi(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/WeatherForecasts.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/WeatherForecasts.cs new file mode 100644 index 0000000..466bb73 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Endpoints/WeatherForecasts.cs @@ -0,0 +1,22 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.WeatherForecasts.Queries.GetWeatherForecasts; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Endpoints; + +public class WeatherForecasts : EndpointGroupBase +{ + public override void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet(GetWeatherForecasts); + } + + public async Task>> GetWeatherForecasts(ISender sender) + { + var forecasts = await sender.Send(new GetWeatherForecastsQuery()); + + return TypedResults.Ok(forecasts); + } + +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/GlobalUsings.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/GlobalUsings.cs new file mode 100644 index 0000000..15f31d5 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Ardalis.GuardClauses; +global using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Infrastructure; +global using MediatR; diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/CustomExceptionHandler.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/CustomExceptionHandler.cs new file mode 100644 index 0000000..deadff1 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/CustomExceptionHandler.cs @@ -0,0 +1,87 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Infrastructure; + +public class CustomExceptionHandler : IExceptionHandler +{ + private readonly Dictionary> _exceptionHandlers; + + public CustomExceptionHandler() + { + // Register known exception types and handlers. + _exceptionHandlers = new() + { + { typeof(ValidationException), HandleValidationException }, + { typeof(NotFoundException), HandleNotFoundException }, + { typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException }, + { typeof(ForbiddenAccessException), HandleForbiddenAccessException }, + }; + } + + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + var exceptionType = exception.GetType(); + + if (_exceptionHandlers.ContainsKey(exceptionType)) + { + await _exceptionHandlers[exceptionType].Invoke(httpContext, exception); + return true; + } + + return false; + } + + private async Task HandleValidationException(HttpContext httpContext, Exception ex) + { + var exception = (ValidationException)ex; + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors) + { + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }); + } + + private async Task HandleNotFoundException(HttpContext httpContext, Exception ex) + { + var exception = (NotFoundException)ex; + + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails() + { + Status = StatusCodes.Status404NotFound, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Title = "The specified resource was not found.", + Detail = exception.Message + }); + } + + private async Task HandleUnauthorizedAccessException(HttpContext httpContext, Exception ex) + { + httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; + + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }); + } + + private async Task HandleForbiddenAccessException(HttpContext httpContext, Exception ex) + { + httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status403Forbidden, + Title = "Forbidden", + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3" + }); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/EndpointGroupBase.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/EndpointGroupBase.cs new file mode 100644 index 0000000..3f03150 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/EndpointGroupBase.cs @@ -0,0 +1,7 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Infrastructure; + +public abstract class EndpointGroupBase +{ + public virtual string? GroupName { get; } + public abstract void Map(RouteGroupBuilder groupBuilder); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..b025feb --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/IEndpointRouteBuilderExtensions.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Infrastructure; + +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 MapDelete(this IEndpointRouteBuilder builder, Delegate handler, [StringSyntax("Route")] string pattern) + { + Guard.Against.AnonymousMethod(handler); + + return builder.MapDelete(pattern, handler) + .WithName(handler.Method.Name); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/MethodInfoExtensions.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/MethodInfoExtensions.cs new file mode 100644 index 0000000..a95dc37 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/MethodInfoExtensions.cs @@ -0,0 +1,18 @@ +using System.Reflection; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Infrastructure; + +public static class MethodInfoExtensions +{ + public static bool IsAnonymous(this MethodInfo method) + { + var invalidChars = new[] { '<', '>' }; + return method.Name.Any(invalidChars.Contains); + } + + public static void AnonymousMethod(this IGuardClause guardClause, Delegate input) + { + if (input.Method.IsAnonymous()) + throw new ArgumentException("The endpoint name must be specified when using anonymous handlers."); + } +} \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/WebApplicationExtensions.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/WebApplicationExtensions.cs new file mode 100644 index 0000000..e7f29f4 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Infrastructure/WebApplicationExtensions.cs @@ -0,0 +1,36 @@ +using System.Reflection; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Infrastructure; + +public static class WebApplicationExtensions +{ + private static RouteGroupBuilder MapGroup(this WebApplication app, EndpointGroupBase group) + { + var groupName = group.GroupName ?? group.GetType().Name; + + return app + .MapGroup($"/api/{groupName}") + .WithGroupName(groupName) + .WithTags(groupName); + } + + public static WebApplication MapEndpoints(this WebApplication app) + { + var endpointGroupType = typeof(EndpointGroupBase); + + var assembly = Assembly.GetExecutingAssembly(); + + var endpointGroupTypes = assembly.GetExportedTypes() + .Where(t => t.IsSubclassOf(endpointGroupType)); + + foreach (var type in endpointGroupTypes) + { + if (Activator.CreateInstance(type) is EndpointGroupBase instance) + { + instance.Map(app.MapGroup(instance)); + } + } + + return app; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs new file mode 100644 index 0000000..4f94a76 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs @@ -0,0 +1,43 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +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.UseHealthChecks("/health"); +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseSwaggerUi(settings => +{ + settings.Path = "/api"; + settings.DocumentPath = "/api/specification.json"; +}); + + +app.UseExceptionHandler(options => { }); + +app.Map("/", () => Results.Redirect("/api")); + +app.MapEndpoints(); + +app.Run(); + +public partial class Program { } diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Properties/launchSettings.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Properties/launchSettings.json new file mode 100644 index 0000000..fc5c88a --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61846", + "sslPort": 44312 + } + }, + "profiles": { + "CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Services/CurrentUser.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Services/CurrentUser.cs new file mode 100644 index 0000000..b8cdfb5 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Services/CurrentUser.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; + +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Services; + +public class CurrentUser : IUser +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUser(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? Id => _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); + public List? Roles => _httpContextAccessor.HttpContext?.User?.FindAll(ClaimTypes.Role).Select(x => x.Value).ToList(); + +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj new file mode 100644 index 0000000..875455f --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj @@ -0,0 +1,47 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + OnBuildSuccess + + + + + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.http b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.http new file mode 100644 index 0000000..105a254 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.http @@ -0,0 +1,139 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile +@Web_HostAddress = https://localhost: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}} + +### \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db new file mode 100644 index 0000000000000000000000000000000000000000..4e86411b5803e34b1e4767ce981907694f15c1ed GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYBBVdHv<{?BV9F^qO3M zT&4dUuc-!gpwI82`u+Lup|!1~l$DC&`>833GOg5=8D&;!D09lZvY<4TMP*4@R$9u6 zvZ}O|ROu+*SGII79Rfwt`xtD&)VQ?j+O&CSG<5lC&z_9wG+&Q>kkdd_ytc0&Rk z+crOGiV6u-Y}0l^gLt1Sfv)-KZB$sGYQ69vMj%i~ftno_vM-uKAS{sBQFss|5GbU8 zpQTG75T}5jB26I>r+}ZIP9YGdfZrTIArPlP!}@VHPS*(NwwPTM5QtR3?@6K%h*Q9C z2%`{)Q=nWe CGc8mA literal 0 HcmV?d00001 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-wal b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..64e9391756b4731646ba9b3de7233ab4d75d30d7 GIT binary patch literal 177192 zcmeI*eQX=&eFtz-ltfW)wBr}XPGX%Ybhc_S5%sQko`i3EaleB-VD2l8L(q%)?VjY$hSh8jfineQ;H0xp@ z-MZ(wd+xcrhexXKk=nC1-&?oZY@2NK^||R0*Q>ug zcIpz3FP0Ie9 zW88hM+~!zTBCxcp$?5U=?B_+9n@#fpX+FZsaf#2x#1nj0DxGf*M%h4&WnzItVV3ch zuJ$r}o9Y?z^3q*|l3pgpPQ{qW82O2Z!|jEYVez<-9nXmm3Q0a^ktV`rc#Bo{a_M|= zoxa=PSY$F54TK^w#ydVTv%GBM(a>lhI?ar*)68D2gzb#iTpY4M$-AvkTH|hqmhaY( zk2IszdT1=lhK3{NoZ6TuJH$rWNRXX0(n&zGt(7HY0~7;Z?b zIM7(@nb=pkRj7qAGzw)w(^7{^IqENLDQOeRpU_Obt58?#&BA;nl|T5k<9>u+ zusFW86oY~^pXL^fJ%N6-YsYWdOe|ZOR$y~8GZ&k509pxX87nq9b)SYq@rFeFVEyX) zfkJ5vAy=0)EZOSRp1!Lj=D{>4WDGG^=UrAYS6AyXr$;*JbJ<}wTI}c<%K&Iw8v_uC z$HqbtlHVvBiD~Butt%~i0#Wu7TbM6%7Vs+TR&k0j_2p`@ZgdQnY|iB&phu*dz1nB! z^L6Dyps)UYLSWpwVhCtrtA1Q%{$qteFV+q1y2*})o8U`k zohJU0m3y<~`lOv~*>-lg#_4Hkv7bAt>@y}N#jqgBQgNczv>$JA`Sr!muvx{x#RNHB zJTywKy@Fy|%;#o;V$w22j^||F>e?-+u02%jybWD)n3LpDF)5@3%gv2RO#2|QIhS@g zJ*}l%l;uG&n-X#vidfpSY9$=!B&vMH`K(ib`_OREWOKEpJ zR@g}(K@MhVh{S+T2E}|or_KfTW{ju*T-^2qKh z5_h(VE>wpS=oDTMfB*y_009U<00Izz00bZa0SK&50cDIpdyb&ye?5V-4WHjsPwR?t zf%O@2)CvL+fB*y_009U<00Izz00bbQ3n-rf(8dM6_`A>EH#4;T%XnTu2f`EtAOHaf zKmY;|fB*y_009Uyt4kNForNATsjzw(H$efv>7FR+dyj;cWb0uX=z1Rwwb2tWV= z5P$##bb(qrsXZ_7)Q6UQKYrrd=kUCM4umNPKmY;|fB*y_009U<00Izzz&aLi(zst6f2id@ zBN!LZfiMLD2tWV=5P$##AOHafKmY;|SjPe`S~u-^f$;M`aldf2{aY9pSjQ1Z)gS-? z2tWV=5P$##AOHafKmY=|fbtcC+PJ`D-+lZ;=`U^j7{AWT630uX=z1Rwwb2tWV= z5P$##*0DeXt(!J3uxILymmm7L>rVVVf^{5mR1E?UfB*y_009U<00Izz00bbQ3pCP6 zZCv1QF5h+OlV3TB&k^W8n1TQVAOHafKmY;|fB*y_009WBX949S1KPO2jw~2tWV=5P$##AOHafKmYkS-?#zr;Q7|x&Mn3u1mk1!t(;_IpnAt1Rwwb2tWV=5P$## zAOHafK%gkFiB4(b0$<(z-cSDhw$Hlpyg<fu&tdPLI!LKQGGMY?=>9^ATQ-OMEUSp5U`m>3nlA$_8RA6AK&)vy8WNwU^o3 zRL_u?m+m5z^fEDaD#k>{$WJ^RZZE71i^qlRcuss!Nb)(0G!ZVtTdca5OXrL0^xX!> zB9pObAQXu)-tm!{hDHO?X=a3-X7*|&Y-haY;*bSO-fe}_8h1Oie7A;tq#3Q& zLt{}kG#oMK)W$^FAvVfJg6yP`P9kbvV%5fsMVLW0%o5*VU@{mOWSi>S+}El@lRmpm z$R_#I(#fW76&@xtRa_T+}hvE&1_`&*G&uaRCLTL;k zSC=#_k<+r$(|47`JecN$j3MUgyvr))>S{ga^hhUtE<4Odiyb{<831i-V*mp2*jOk+ z@*8C%G3^|ob){ubAj)213-e{p0$yd^DozomzFaNVjgH}x&AB`T^oUfmSNjZozOGyd z^wqyl2#i}-3;|7S)sL&pf2{Q%hepY@ zS5Qog`P@uUOj@SM@tn+CUAqOxw$ckX&)pu=h6a2h)wi_=a-%w@Q=aE4FP9?BvSo8g4Cc6KuW z=^I<6P?iV9Y)Z&wC}L^Ps+Dk@lcZB(E_s-f=E`I=$tUtTL0*`Yxy*c-^&|(9AV*rg zWs>nZG0R8tnOXf3-B1(5+EtV`c1j%L60(?Mv-B=wg?fd>#0fDkv)N+%Gh|v^Sz#xE z1UZV85HwbdATELZavkto=|J$YbeQhQ7zRh9nQ@TY*}B3l?Je|wZCt?i-Ur7%{k`V5 z=<@>Z>JD4;Xmev@U&FQf`|7tlf9yO~`|H|l&ChC1)VQjjB&+d)00bal<2_DKSBrhI zMj7Y`P0i2?ppeXGq#1g76-pN8cNY5EP-KvuV!UONdg;iDa^a=yfONrLbsyfg`Rz{6 zo>u!}tCEd&N)OS&7Q>~*jzSjkNN6I?l9RHMlPR^DhhZCo$df>d^DCA=ZlA)L7&bFp6)|Op5emn-<#qH*w@MLFF5V*se_*gdHzc9X)sa*eP?^$?n z&i60KEzK5sTceE&Jh5Xo`TnQ7pThSG;ARg35P$##AOHafKmY;|fB*y_uptDt(pqZc z0x$gR?zyKQfA&u>F0dhDk4i!S0uX=z1Rwwb2tWV=5P$##wow9YT;O}h7XJFNe||TO zaRD?82tWV=5P$##AOHafKmY;|fWU?j*iLJyjSKwr`3L6Pzwn(`F)pwnVvkBf00Izz z00bZa0SG_<0uX=z1U!^L8yEQ9%g_GTe|Inp#s$zYAOHafKmY;|fB*y_009U<00J9A z;8t2oZCs#f|I4R8@r@T>!??hPh&?I^0SG_<0uX=z1Rwwb2tWV=5ZFNpv~hv@?cqQD z;P!ug5#s`A7!ZH}1Rwwb2tWV=5P$##AOL|4A+VFwa;G*f@I({)(NBK=&pR*1K1!Ojwo2xF_IosQp@^||BI+bVYlYQjr`rf3EAK-fj z65U<>i3HCXpUA%_Ehf11oG8hr`{;YZW5Gc9@YrPR`celb0^}761g5#;bc!9zNJozE z>-USJX@2;SNFMt?lFH8CJ25*wc_cPI*B1#Net^yO_D@X@P6>VN!oYBB=*)2E>EvY3 z{F#BSXyCwsP`rEOzQN#;@xHOao_OD(;nC^o?#a<;@5K0YXn10Xo8|mHNq@rE&kywb zy8Wr7kL%}qd>#G$iT;k>p4skXk_5Jf23H#wc&6%ycl=8ta~Yo_SW`_Ng@phFAOHaf zKmY;|fB*y_009V?1nB1oRx=htHoBE=sErFm4!tBj>^t_3Ixg_G&Hc7%x0rzd1Rwwb z2tWV=5P$##AOHafKwzy2>~!ome|kWF>VZ^c2l**}zrgdmA94vjhtFeNV66oi<%IwQ zAOHafKmY;|fB*y_009VC2&_CVVETT6SKj#4k;Ee(J&*4fu&~5p2tWV=5P$##AOHaf zKmY;|xakG({Q~Ou3-t8*ySlh!%GW#FOTJ&Aqq~QEzkr|oN+tRRI{Rk_QvMzi%tjhe z?Q;ZI-?;lXwy&PPQyCXdl|xdk5EU>``(EKmY;|fB*y_009U<00IzzfJtEG?;SM#J%Z{N zAN;q5jsrQ23z!5k0|5v?00Izz00bZa0SG_<0uWfc0xORT)Y6J+pCkDA=8L}>*wuJa z{ocVVHusgaTWypa0uX=z1Rwwb2tWV=5P$##Ag~?;j@avLEw_!?#%wV$DKcR}l3RzA zS7%3Oudl=J^LH}-?z_7>?(XQlv!k!SyT?DUuY=5WR7^m8oYY7g7pQ*y=@(uO?&_iA z0#)w+u(^Lte&GcH2tWV=5P$##AOHafKmY;|fWTT6XsU9w*y)HuNaizAb)((U;-a&3 zd_f|W+C(d+jSHN5{mNG+ets!J#|5gJ+imVk?z@`5L?-Zp00bZa0SG_<0uX=z1Rwx` z4KHxIy3yw1Y<0Fe`rdVvKgH#e%z~KDNqjoRoD$?YhGViqf^TQiV)i(b<>r}pE`RER z#hcds{T=-)zN@V#SgduBtRL31=F)=1)4caQ$TM6z$8*UACMjllMm{An@|=)8E-_-3 zOz{j!D(@x*x$>NKL3%0`q>$i&DpC$sD@SmSPn=-HR7%MwCFCTT5y;Iq^Va9iTj%Ak zlvg1^!cOw))AAbSPLP^1GJRrz)`(Q-u_dca`uZ!#788(0y_5cE;{sp!*7xJTZT-u~ z>Gum%yMJMG|JMCJd1l}j8@}-a00Izz00bZa0SG_<0uX=z1R${11*#n`do}&#puegd I4wviy0KQaiP5=M^ literal 0 HcmV?d00001 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.Development.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.Development.json new file mode 100644 index 0000000..6fa9acc --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolutionDb": "DataSource=app.db;Cache=Shared" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.AspNetCore.SpaProxy": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.json new file mode 100644 index 0000000..d9d9a9b --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/config.nswag b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/config.nswag new file mode 100644 index 0000000..fed8907 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/config.nswag @@ -0,0 +1,22 @@ +{ + "runtime": "Net100", + "defaultVariables": null, + "documentGenerator": { + "aspNetCoreToOpenApi": { + "project": "Web.csproj", + "documentName": "v1", + "msBuildProjectExtensionsPath": null, + "configuration": null, + "runtime": null, + "targetFramework": null, + "noBuild": true, + "msBuildOutputPath": null, + "verbose": false, + "workingDirectory": null, + "aspNetCoreEnvironment": null, + "output": "wwwroot/api/specification.json", + "newLineBehavior": "Auto" + } + }, + "codeGenerators": {} +} \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json new file mode 100644 index 0000000..7503331 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/api/specification.json @@ -0,0 +1,1268 @@ +{ + "x-generator": "NSwag v14.6.2.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))", + "openapi": "3.0.0", + "info": { + "title": "CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution API", + "version": "1.0.0" + }, + "paths": { + "/api/TodoItems": { + "get": { + "tags": [ + "TodoItems" + ], + "operationId": "GetTodoItemsWithPagination", + "parameters": [ + { + "name": "ListId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageNumber", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedListOfTodoItemBriefDto" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "post": { + "tags": [ + "TodoItems" + ], + "operationId": "CreateTodoItem", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTodoItemCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoItems/{id}": { + "put": { + "tags": [ + "TodoItems" + ], + "operationId": "UpdateTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTodoItemCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "delete": { + "tags": [ + "TodoItems" + ], + "operationId": "DeleteTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoItems/UpdateDetail/{id}": { + "put": { + "tags": [ + "TodoItems" + ], + "operationId": "UpdateTodoItemDetail", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTodoItemDetailCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoLists": { + "get": { + "tags": [ + "TodoLists" + ], + "operationId": "GetTodoLists", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodosVm" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "post": { + "tags": [ + "TodoLists" + ], + "operationId": "CreateTodoList", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTodoListCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/TodoLists/{id}": { + "put": { + "tags": [ + "TodoLists" + ], + "operationId": "UpdateTodoList", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTodoListCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "delete": { + "tags": [ + "TodoLists" + ], + "operationId": "DeleteTodoList", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/Users/register": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersRegister", + "requestBody": { + "x-name": "registration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/Users/login": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersLogin", + "parameters": [ + { + "name": "useCookies", + "in": "query", + "schema": { + "type": "boolean", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "useSessionCookies", + "in": "query", + "schema": { + "type": "boolean", + "nullable": true + }, + "x-position": 3 + } + ], + "requestBody": { + "x-name": "login", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenResponse" + } + } + } + } + } + } + }, + "/api/Users/refresh": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersRefresh", + "requestBody": { + "x-name": "refreshRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessTokenResponse" + } + } + } + } + } + } + }, + "/api/Users/confirmEmail": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiUsersConfirmEmail", + "parameters": [ + { + "name": "userId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "code", + "in": "query", + "required": true, + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "changedEmail", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 3 + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/Users/resendConfirmationEmail": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersResendConfirmationEmail", + "requestBody": { + "x-name": "resendRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResendConfirmationEmailRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/Users/forgotPassword": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersForgotPassword", + "requestBody": { + "x-name": "resetRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/Users/resetPassword": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersResetPassword", + "requestBody": { + "x-name": "resetRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/Users/manage/2fa": { + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersManage2fa", + "requestBody": { + "x-name": "tfaRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFactorRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TwoFactorResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/Users/manage/info": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiUsersManageInfo", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + }, + "post": { + "tags": [ + "Users" + ], + "operationId": "PostApiUsersManageInfo", + "requestBody": { + "x-name": "infoRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InfoResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "404": { + "description": "" + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/WeatherForecasts": { + "get": { + "tags": [ + "WeatherForecasts" + ], + "operationId": "GetWeatherForecasts", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WeatherForecast" + } + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + } + }, + "components": { + "schemas": { + "PaginatedListOfTodoItemBriefDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItemBriefDto" + } + }, + "pageNumber": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + }, + "totalCount": { + "type": "integer", + "format": "int32" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "hasNextPage": { + "type": "boolean" + } + } + }, + "TodoItemBriefDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "listId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + } + } + }, + "CreateTodoItemCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "listId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "UpdateTodoItemCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + } + } + }, + "UpdateTodoItemDetailCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "listId": { + "type": "integer", + "format": "int32" + }, + "priority": { + "$ref": "#/components/schemas/PriorityLevel" + }, + "note": { + "type": "string", + "nullable": true + } + } + }, + "PriorityLevel": { + "type": "integer", + "description": "", + "x-enumNames": [ + "None", + "Low", + "Medium", + "High" + ], + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "TodosVm": { + "type": "object", + "additionalProperties": false, + "properties": { + "priorityLevels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LookupDto" + } + }, + "lists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoListDto" + } + } + } + }, + "LookupDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "TodoListDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "colour": { + "type": "string", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItemDto" + } + } + } + }, + "TodoItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "listId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "done": { + "type": "boolean" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "note": { + "type": "string", + "nullable": true + } + } + }, + "CreateTodoListCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "nullable": true + } + } + }, + "UpdateTodoListCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "HttpValidationProblemDetails": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "properties": { + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + ] + }, + "ProblemDetails": { + "type": "object", + "additionalProperties": { + "nullable": true + }, + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + } + } + }, + "RegisterRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "AccessTokenResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "tokenType": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + }, + "refreshToken": { + "type": "string" + } + } + }, + "LoginRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "twoFactorCode": { + "type": "string", + "nullable": true + }, + "twoFactorRecoveryCode": { + "type": "string", + "nullable": true + } + } + }, + "RefreshRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "ResendConfirmationEmailRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + } + } + }, + "ForgotPasswordRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + } + } + }, + "ResetPasswordRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "resetCode": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "TwoFactorResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "sharedKey": { + "type": "string" + }, + "recoveryCodesLeft": { + "type": "integer", + "format": "int32" + }, + "recoveryCodes": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, + "isTwoFactorEnabled": { + "type": "boolean" + }, + "isMachineRemembered": { + "type": "boolean" + } + } + }, + "TwoFactorRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "enable": { + "type": "boolean", + "nullable": true + }, + "twoFactorCode": { + "type": "string", + "nullable": true + }, + "resetSharedKey": { + "type": "boolean" + }, + "resetRecoveryCodes": { + "type": "boolean" + }, + "forgetMachine": { + "type": "boolean" + } + } + }, + "InfoResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "isEmailConfirmed": { + "type": "boolean" + } + } + }, + "InfoRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "newEmail": { + "type": "string", + "nullable": true + }, + "newPassword": { + "type": "string", + "nullable": true + }, + "oldPassword": { + "type": "string", + "nullable": true + } + } + }, + "WeatherForecast": { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string", + "format": "date-time" + }, + "temperatureC": { + "type": "integer", + "format": "int32" + }, + "temperatureF": { + "type": "integer", + "format": "int32" + }, + "summary": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "JWT": { + "type": "apiKey", + "description": "Type into the textbox: Bearer {your JWT token}.", + "name": "Authorization", + "in": "header" + } + } + }, + "security": [ + { + "JWT": [] + } + ] +} \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/favicon.ico b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..63e859b476eff5055e0e557aaa151ca8223fbeef GIT binary patch literal 5430 zcmc&&Yj2xp8Fqnv;>&(QB_ve7>^E#o2mu=cO~A%R>DU-_hfbSRv1t;m7zJ_AMrntN zy0+^f&8be>q&YYzH%(88lQ?#KwiCzaCO*ZEo%j&v;<}&Lj_stKTKK>#U3nin@AF>w zb3ONSAFR{u(S1d?cdw53y}Gt1b-Hirbh;;bm(Rcbnoc*%@jiaXM|4jU^1WO~`TYZ~ zC-~jh9~b-f?fX`DmwvcguQzn*uV}c^Vd&~?H|RUs4Epv~gTAfR(B0lT&?RWQOtduM z^1vUD9{HQsW!{a9|0crA34m7Z6lpG^}f6f?={zD+ zXAzk^i^aKN_}s2$eX81wjSMONE#WVdzf|MT)Ap*}Vsn!XbvsI#6o&ij{87^d%$|A{ z=F{KB%)g%@z76yBzbb7seW**Ju8r4e*Z3PWNX3_tTDgzZatz7)Q6ytwB%@&@A|XT; zecM`Snxx5po$C)%yCP!KEtos~eOS)@2=kX-RIm)4glMCoagTEFxrBeSX%Euz734Fk z%7)x(k~T!@Hbg_37NSQL!vlTBXoURSzt~I**Zw`&F24fH*&kx=%nvZv|49SC*daD( zIw<~%#=lk8{2-l(BcIjy^Q$Q&m#KlWL9?UG{b8@qhlD z;umc+6p%|NsAT~0@DgV4-NKgQuWPWrmPIK&&XhV&n%`{l zOl^bbWYjQNuVXTXESO)@|iUKVmErPUDfz2Wh`4dF@OFiaCW|d`3paV^@|r^8T_ZxM)Z+$p5qx# z#K=z@%;aBPO=C4JNNGqVv6@UGolIz;KZsAro``Rz8X%vq_gpi^qEV&evgHb_=Y9-l z`)imdx0UC>GWZYj)3+3aKh?zVb}=@%oNzg7a8%kfVl)SV-Amp1Okw&+hEZ3|v(k8vRjXW9?ih`&FFM zV$~{j3IzhtcXk?Mu_!12;=+I7XK-IR2>Yd%VB^?oI9c^E&Chb&&je$NV0P-R;ujkP z;cbLCCPEF6|22NDj=S`F^2e~XwT1ZnRX8ra0#DaFa9-X|8(xNW_+JhD75WnSd7cxo z2>I_J5{c|WPfrgl7E2R)^c}F7ry()Z>$Jhk9CzZxiPKL#_0%`&{MX>P_%b~Dx0D^S z7xP1(DQ!d_Icpk!RN3I1w@~|O1ru#CO==h#9M~S4Chx*@?=EKUPGBv$tmU+7Zs_al z`!jR?6T&Z7(%uVq>#yLu`abWk!FBlnY{RFNHlj~6zh*;@u}+}viRKsD`IIxN#R-X3 z@vxu#EA_m}I503U(8Qmx^}u;)KfGP`O9E1H1Q|xeeksX8jC%@!{YT1)!lWgO=+Y3*jr=iSxvOW1}^HSy=y){tOMQJ@an>sOl4FYniE z;GOxd7AqxZNbYFNqobpv&HVO$c-w!Y*6r;$2oJ~h(a#(Bp<-)dg*mNigX~9rPqcHv z^;c*|Md?tD)$y?6FO$DWl$jUGV`F1G_^E&E>sY*YnA~ruv3=z9F8&&~Xpm<<75?N3 z>x~`I&M9q)O1=zWZHN9hZWx>RQ}zLP+iL57Q)%&_^$Sme^^G7;e-P~CR?kqU#Io#( z(nH1Wn*Ig)|M>WLGrxoU?FZrS`4GO&w;+39A3f8w{{Q7eg|$+dIlNFPAe+tN=FOYU z{A&Fg|H73+w1IK(W=j*L>JQgz$g0 z7JpKXLHIh}#$wm|N`s}o-@|L_`>*(gTQ~)wr3Eap7g%PVNisKw82im;Gdv#85x#s+ zoqqtnwu4ycd>cOQgRh-=aEJbnvVK`}ja%+FZx}&ehtX)n(9nVfe4{mn0bgijUbNr7Tf5X^$*{qh2%`?--%+sbSrjE^;1e3>% zqa%jdY16{Y)a1hSy*mr0JGU05Z%=qlx5vGvTjSpTt6k%nR06q}1DU`SQh_ZAeJ}A@`hL~xvv05U?0%=spP`R>dk?cOWM9^KNb7B?xjex>OZo%JMQQ1Q zB|q@}8RiP@DWn-(fB;phPaIOP2Yp)XN3-Fsn)S3w($4&+p8f5W_f%gac}QvmkHfCj$2=!t`boCvQ zCW;&Dto=f8v##}dy^wg3VNaBy&kCe3N;1|@n@pUaMPT?(aJ9b*(gJ28$}(2qFt$H~u5z94xcIQkcOI++)*exzbrk?WOOOf*|%k5#KV zL=&ky3)Eirv$wbRJ2F2s_ILQY--D~~7>^f}W|Aw^e7inXr#WLI{@h`0|jHud2Y~cI~Yn{r_kU^Vo{1gja + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/BaseTestFixture.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/BaseTestFixture.cs new file mode 100644 index 0000000..69ee5d8 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/BaseTestFixture.cs @@ -0,0 +1,13 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests; + +using static Testing; + +[TestFixture] +public abstract class BaseTestFixture +{ + [SetUp] + public async Task TestSetUp() + { + await ResetState(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..d9f6df0 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,53 @@ +using System.Data.Common; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests; + +using static Testing; + +public class CustomWebApplicationFactory : WebApplicationFactory +{ + private readonly DbConnection _connection; + private readonly string _connectionString; + + public CustomWebApplicationFactory(DbConnection connection, string connectionString) + { + _connection = connection; + _connectionString = connectionString; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder + .UseEnvironment("Testing") + .UseSetting("ConnectionStrings:CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolutionDb", _connectionString); + + builder.ConfigureTestServices(services => + { + services + .RemoveAll() + .AddTransient(provider => + { + var mock = new Mock(); + mock.SetupGet(x => x.Roles).Returns(GetRoles()); + mock.SetupGet(x => x.Id).Returns(GetUserId()); + return mock.Object; + }); + services + .RemoveAll>() + .AddDbContext((sp, options) => + { + options.AddInterceptors(sp.GetServices()); + options.UseSqlite(_connection); + }); + }); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/GlobalUsings.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/GlobalUsings.cs new file mode 100644 index 0000000..ead24a2 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Ardalis.GuardClauses; +global using Shouldly; +global using Moq; +global using NUnit.Framework; diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/ITestDatabase.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/ITestDatabase.cs new file mode 100644 index 0000000..c5bc522 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/ITestDatabase.cs @@ -0,0 +1,16 @@ +using System.Data.Common; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests; + +public interface ITestDatabase +{ + Task InitialiseAsync(); + + DbConnection GetConnection(); + + string GetConnectionString(); + + Task ResetAsync(); + + Task DisposeAsync(); +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/SqliteTestDatabase.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/SqliteTestDatabase.cs new file mode 100644 index 0000000..bc75393 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/SqliteTestDatabase.cs @@ -0,0 +1,58 @@ +using System.Data; +using System.Data.Common; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests; + +public class SqliteTestDatabase : ITestDatabase +{ + private readonly string _connectionString; + private readonly SqliteConnection _connection; + + public SqliteTestDatabase() + { + _connectionString = "DataSource=:memory:"; + _connection = new SqliteConnection(_connectionString); + } + + public async Task InitialiseAsync() + { + if (_connection.State == ConnectionState.Open) + { + await _connection.CloseAsync(); + } + + await _connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + var context = new ApplicationDbContext(options); + + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + } + + public DbConnection GetConnection() + { + return _connection; + } + + public string GetConnectionString() + { + return _connectionString; + } + + public async Task ResetAsync() + { + await InitialiseAsync(); + } + + public async Task DisposeAsync() + { + await _connection.DisposeAsync(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TestDatabaseFactory.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TestDatabaseFactory.cs new file mode 100644 index 0000000..e590b83 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TestDatabaseFactory.cs @@ -0,0 +1,13 @@ +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests; + +public static class TestDatabaseFactory +{ + public static async Task CreateAsync() + { + var database = new SqliteTestDatabase(); + + await database.InitialiseAsync(); + + return database; + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/Testing.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/Testing.cs new file mode 100644 index 0000000..aa8f703 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/Testing.cs @@ -0,0 +1,151 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Constants; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Identity; +using MediatR; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests; + +[SetUpFixture] +public partial class Testing +{ + private static ITestDatabase _database = null!; + private static CustomWebApplicationFactory _factory = null!; + private static IServiceScopeFactory _scopeFactory = null!; + private static string? _userId; + private static List? _roles; + [OneTimeSetUp] + public async Task RunBeforeAnyTests() + { + _database = await TestDatabaseFactory.CreateAsync(); + + _factory = new CustomWebApplicationFactory(_database.GetConnection(), _database.GetConnectionString()); + + _scopeFactory = _factory.Services.GetRequiredService(); + } + + public static async Task SendAsync(IRequest request) + { + using var scope = _scopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + return await mediator.Send(request); + } + + public static async Task SendAsync(IBaseRequest request) + { + using var scope = _scopeFactory.CreateScope(); + + var mediator = scope.ServiceProvider.GetRequiredService(); + + await mediator.Send(request); + } + + public static string? GetUserId() + { + return _userId; + } + + public static List? GetRoles() + { + return _roles; + } + + public static async Task RunAsDefaultUserAsync() + { + return await RunAsUserAsync("test@local", "Testing1234!", Array.Empty()); + } + + public static async Task RunAsAdministratorAsync() + { + return await RunAsUserAsync("administrator@local", "Administrator1234!", new[] { Roles.Administrator }); + } + + public static async Task RunAsUserAsync(string userName, string password, string[] roles) + { + using var scope = _scopeFactory.CreateScope(); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = new ApplicationUser { UserName = userName, Email = userName }; + + var result = await userManager.CreateAsync(user, password); + + if (roles.Any()) + { + 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.ToList(); + 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() + { + try + { + await _database.ResetAsync(); + } + catch (Exception) + { + } + + _userId = null; + } + + public static async Task FindAsync(params object[] keyValues) + where TEntity : class + { + using var scope = _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 = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + context.Add(entity); + + await context.SaveChangesAsync(); + } + + public static async Task CountAsync() where TEntity : class + { + using var scope = _scopeFactory.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + return await context.Set().CountAsync(); + } + + [OneTimeTearDown] + public async Task RunAfterAnyTests() + { + await _database.DisposeAsync(); + await _factory.DisposeAsync(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs new file mode 100644 index 0000000..ef35cc7 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/CreateTodoItemTests.cs @@ -0,0 +1,48 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class CreateTodoItemTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireMinimumFields() + { + var command = new CreateTodoItemCommand(); + + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldCreateTodoItem() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var command = new CreateTodoItemCommand + { + ListId = listId, + Title = "Tasks" + }; + + var itemId = await SendAsync(command); + + var item = await FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.ListId.ShouldBe(command.ListId); + item.Title.ShouldBe(command.Title); + item.CreatedBy.ShouldBe(userId); + item.Created.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + item.LastModifiedBy.ShouldBe(userId); + item.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs new file mode 100644 index 0000000..982b0a6 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/DeleteTodoItemTests.cs @@ -0,0 +1,40 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.DeleteTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class DeleteTodoItemTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new DeleteTodoItemCommand(99); + + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldDeleteTodoItem() + { + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + await SendAsync(new DeleteTodoItemCommand(itemId)); + + var item = await FindAsync(itemId); + + item.ShouldBeNull(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs new file mode 100644 index 0000000..449d88d --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemDetailTests.cs @@ -0,0 +1,58 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.UpdateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.UpdateTodoItemDetail; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Enums; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class UpdateTodoItemDetailTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new UpdateTodoItemCommand { Id = 99, Title = "New Title" }; + + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldUpdateTodoItem() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await 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 SendAsync(command); + + var item = await FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.ListId.ShouldBe(command.ListId); + item.Note.ShouldBe(command.Note); + item.Priority.ShouldBe(command.Priority); + item.LastModifiedBy.ShouldNotBeNull(); + item.LastModifiedBy.ShouldBe(userId); + item.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs new file mode 100644 index 0000000..0a9ec05 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoItems/Commands/UpdateTodoItemTests.cs @@ -0,0 +1,51 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.UpdateTodoItem; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoItems.Commands; + +using static Testing; + +public class UpdateTodoItemTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoItemId() + { + var command = new UpdateTodoItemCommand { Id = 99, Title = "New Title" }; + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldUpdateTodoItem() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var itemId = await SendAsync(new CreateTodoItemCommand + { + ListId = listId, + Title = "New Item" + }); + + var command = new UpdateTodoItemCommand + { + Id = itemId, + Title = "Updated Item Title" + }; + + await SendAsync(command); + + var item = await FindAsync(itemId); + + item.ShouldNotBeNull(); + item!.Title.ShouldBe(command.Title); + item.LastModifiedBy.ShouldNotBeNull(); + item.LastModifiedBy.ShouldBe(userId); + item.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs new file mode 100644 index 0000000..1fe91ea --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/CreateTodoListTests.cs @@ -0,0 +1,53 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class CreateTodoListTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireMinimumFields() + { + var command = new CreateTodoListCommand(); + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldRequireUniqueTitle() + { + await SendAsync(new CreateTodoListCommand + { + Title = "Shopping" + }); + + var command = new CreateTodoListCommand + { + Title = "Shopping" + }; + + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldCreateTodoList() + { + var userId = await RunAsDefaultUserAsync(); + + var command = new CreateTodoListCommand + { + Title = "Tasks" + }; + + var id = await SendAsync(command); + + var list = await FindAsync(id); + + list.ShouldNotBeNull(); + list!.Title.ShouldBe(command.Title); + list.CreatedBy.ShouldBe(userId); + list.Created.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs new file mode 100644 index 0000000..077ce33 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/DeleteTodoListTests.cs @@ -0,0 +1,32 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.DeleteTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class DeleteTodoListTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoListId() + { + var command = new DeleteTodoListCommand(99); + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldDeleteTodoList() + { + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + await SendAsync(new DeleteTodoListCommand(listId)); + + var list = await FindAsync(listId); + + list.ShouldBeNull(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs new file mode 100644 index 0000000..18e7d10 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/PurgeTodoListsTests.cs @@ -0,0 +1,78 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Security; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.PurgeTodoLists; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class PurgeTodoListsTests : BaseTestFixture +{ + [Test] + public async Task ShouldDenyAnonymousUser() + { + var command = new PurgeTodoListsCommand(); + + command.GetType().ShouldSatisfyAllConditions( + type => type.ShouldBeDecoratedWith() + ); + + var action = () => SendAsync(command); + + await Should.ThrowAsync(action); + } + + [Test] + public async Task ShouldDenyNonAdministrator() + { + await RunAsDefaultUserAsync(); + + var command = new PurgeTodoListsCommand(); + + var action = () => SendAsync(command); + + await Should.ThrowAsync(action); + } + + [Test] + public async Task ShouldAllowAdministrator() + { + await RunAsAdministratorAsync(); + + var command = new PurgeTodoListsCommand(); + + var action = () => SendAsync(command); + + Func asyncAction = async () => await SendAsync(command); + await asyncAction.ShouldNotThrowAsync(); + } + + [Test] + public async Task ShouldDeleteAllLists() + { + await RunAsAdministratorAsync(); + + await SendAsync(new CreateTodoListCommand + { + Title = "New List #1" + }); + + await SendAsync(new CreateTodoListCommand + { + Title = "New List #2" + }); + + await SendAsync(new CreateTodoListCommand + { + Title = "New List #3" + }); + + await SendAsync(new PurgeTodoListsCommand()); + + var count = await CountAsync(); + + count.ShouldBe(0); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs new file mode 100644 index 0000000..24d4a80 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Commands/UpdateTodoListTests.cs @@ -0,0 +1,70 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.CreateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Commands.UpdateTodoList; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoLists.Commands; + +using static Testing; + +public class UpdateTodoListTests : BaseTestFixture +{ + [Test] + public async Task ShouldRequireValidTodoListId() + { + var command = new UpdateTodoListCommand { Id = 99, Title = "New Title" }; + await Should.ThrowAsync(() => SendAsync(command)); + } + + [Test] + public async Task ShouldRequireUniqueTitle() + { + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + await SendAsync(new CreateTodoListCommand + { + Title = "Other List" + }); + + var command = new UpdateTodoListCommand + { + Id = listId, + Title = "Other List" + }; + + var ex = await Should.ThrowAsync(() => SendAsync(command)); + + ex.Errors.ShouldContainKey("Title"); + ex.Errors["Title"].ShouldContain("'Title' must be unique."); + } + + [Test] + public async Task ShouldUpdateTodoList() + { + var userId = await RunAsDefaultUserAsync(); + + var listId = await SendAsync(new CreateTodoListCommand + { + Title = "New List" + }); + + var command = new UpdateTodoListCommand + { + Id = listId, + Title = "Updated List Title" + }; + + await SendAsync(command); + + var list = await FindAsync(listId); + + list.ShouldNotBeNull(); + list!.Title.ShouldBe(command.Title); + list.LastModifiedBy.ShouldNotBeNull(); + list.LastModifiedBy.ShouldBe(userId); + list.LastModified.ShouldBe(DateTime.Now, TimeSpan.FromMilliseconds(10000)); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs new file mode 100644 index 0000000..c57f80e --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.FunctionalTests/TodoLists/Queries/GetTodosTests.cs @@ -0,0 +1,61 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Queries.GetTodos; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.ValueObjects; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.FunctionalTests.TodoLists.Queries; + +using static Testing; + +public class GetTodosTests : BaseTestFixture +{ + [Test] + public async Task ShouldReturnPriorityLevels() + { + await RunAsDefaultUserAsync(); + + var query = new GetTodosQuery(); + + var result = await SendAsync(query); + + result.PriorityLevels.ShouldNotBeEmpty(); + } + + [Test] + public async Task ShouldReturnAllListsAndItems() + { + await RunAsDefaultUserAsync(); + + await 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 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 = () => SendAsync(query); + + await Should.ThrowAsync(action); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Application.UnitTests.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Application.UnitTests.csproj new file mode 100644 index 0000000..03929b3 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Application.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.UnitTests + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.UnitTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs new file mode 100644 index 0000000..18e0552 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Behaviours/RequestLoggerTests.cs @@ -0,0 +1,45 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Behaviours; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Commands.CreateTodoItem; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.UnitTests.Common.Behaviours; + +public class RequestLoggerTests +{ + private Mock> _logger = null!; + private Mock _user = null!; + private Mock _identityService = null!; + + [SetUp] + public void Setup() + { + _logger = new Mock>(); + _user = new Mock(); + _identityService = new Mock(); + } + + [Test] + public async Task ShouldCallGetUserNameAsyncOnceIfAuthenticated() + { + _user.Setup(x => x.Id).Returns(Guid.NewGuid().ToString()); + + var requestLogger = new LoggingBehaviour(_logger.Object, _user.Object, _identityService.Object); + + await requestLogger.Process(new CreateTodoItemCommand { ListId = 1, Title = "title" }, new CancellationToken()); + + _identityService.Verify(i => i.GetUserNameAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task ShouldNotCallGetUserNameAsyncOnceIfUnauthenticated() + { + var requestLogger = new LoggingBehaviour(_logger.Object, _user.Object, _identityService.Object); + + await requestLogger.Process(new CreateTodoItemCommand { ListId = 1, Title = "title" }, new CancellationToken()); + + _identityService.Verify(i => i.GetUserNameAsync(It.IsAny()), Times.Never); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs new file mode 100644 index 0000000..ca0a0f9 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Exceptions/ValidationExceptionTests.cs @@ -0,0 +1,63 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Exceptions; +using FluentValidation.Results; +using NUnit.Framework; +using Shouldly; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.UnitTests.Common.Exceptions; + +public class ValidationExceptionTests +{ + [Test] + public void DefaultConstructorCreatesAnEmptyErrorDictionary() + { + var actual = new ValidationException().Errors; + + actual.Keys.ShouldBeEmpty(); + } + + [Test] + public void SingleValidationFailureCreatesASingleElementErrorDictionary() + { + var failures = new List + { + new ValidationFailure("Age", "must be over 18"), + }; + + var actual = new ValidationException(failures).Errors; + + actual.Keys.ShouldBe(new string[] { "Age" }); + actual["Age"].ShouldBe(new string[] { "must be over 18" }); + } + + [Test] + public void MulitpleValidationFailureForMultiplePropertiesCreatesAMultipleElementErrorDictionaryEachWithMultipleValues() + { + var failures = new List + { + new ValidationFailure("Age", "must be 18 or older"), + new ValidationFailure("Age", "must be 25 or younger"), + new ValidationFailure("Password", "must contain at least 8 characters"), + new ValidationFailure("Password", "must contain a digit"), + new ValidationFailure("Password", "must contain upper case letter"), + new ValidationFailure("Password", "must contain lower case letter"), + }; + + var actual = new ValidationException(failures).Errors; + + actual.Keys.ShouldBe(new string[] { "Password", "Age" }, ignoreOrder: true); + + actual["Age"].ShouldBe(new string[] + { + "must be 25 or younger", + "must be 18 or older", + }, ignoreOrder: true); + + actual["Password"].ShouldBe(new string[] + { + "must contain lower case letter", + "must contain upper case letter", + "must contain at least 8 characters", + "must contain a digit", + }, ignoreOrder: true); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Mappings/MappingTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Mappings/MappingTests.cs new file mode 100644 index 0000000..3c6bf68 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Application.UnitTests/Common/Mappings/MappingTests.cs @@ -0,0 +1,66 @@ +using System.Runtime.CompilerServices; +using AutoMapper; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Models; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoItems.Queries.GetTodoItemsWithPagination; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.TodoLists.Queries.GetTodos; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Entities; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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(); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/Domain.UnitTests.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/Domain.UnitTests.csproj new file mode 100644 index 0000000..1457d85 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/Domain.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.UnitTests + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.UnitTests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/ValueObjects/ColourTests.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/ValueObjects/ColourTests.cs new file mode 100644 index 0000000..5022111 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Domain.UnitTests/ValueObjects/ColourTests.cs @@ -0,0 +1,49 @@ +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.Exceptions; +using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Domain.ValueObjects; +using NUnit.Framework; +using Shouldly; + +namespace CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.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")); + } +} diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/GlobalUsings.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj new file mode 100644 index 0000000..87826c1 --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/tests/Infrastructure.IntegrationTests/Infrastructure.IntegrationTests.csproj @@ -0,0 +1,22 @@ + + + + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.IntegrationTests + CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + From 6915e33818a29c237c50cb5298f04f283b8f6000 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Mon, 12 Jan 2026 17:56:12 +0300 Subject: [PATCH 3/6] Use multitenancy NuGet packages in sample --- .../header-and-route-resolution.md | 28 +++++++++++- ...Multitenancy.HeaderAndRouteResolution.slnx | 2 + .../Directory.Packages.props | 9 +++- .../README.md | 43 ++++++++++++++++++- .../src/Application/Application.csproj | 3 ++ .../src/Web/Web.csproj | 3 ++ 6 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docs/samples/multitenancy/header-and-route-resolution.md b/docs/samples/multitenancy/header-and-route-resolution.md index 98508a8..4615ac0 100644 --- a/docs/samples/multitenancy/header-and-route-resolution.md +++ b/docs/samples/multitenancy/header-and-route-resolution.md @@ -32,7 +32,33 @@ Document a sample that shows deterministic tenant resolution from route first, h dotnet new ca-sln -cf None -o CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution --database sqlite ``` - Verify the output folder exists and contains the new solution file plus `src/` and `tests/`. -2. Add project references to `CleanArchitecture.Extensions.Multitenancy` and `CleanArchitecture.Extensions.Multitenancy.AspNetCore` from `src/`, and include those projects in the sample solution. +2. Add NuGet package references for the multitenancy extensions (use the latest published versions from NuGet). + - Packages: [CleanArchitecture.Extensions.Multitenancy](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy) and [CleanArchitecture.Extensions.Multitenancy.AspNetCore](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy.AspNetCore). + - You can either use `dotnet add package` or edit the files directly as shown below. The sample uses central package management, so versions live in `Directory.Packages.props`. + - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props`: + ```xml + + 0.1.1-preview.1 + + ``` + ```xml + + + + + ``` + - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj`: + ```xml + + + + ``` + - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj`: + ```xml + + + + ``` 3. Configure `MultitenancyOptions` for route-first ordering (`Route > Host > Header > Query > Claim`), set header name `X-Tenant-ID`, require tenants by default, and disable fallback tenants. 4. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; place `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. 5. Add route conventions that group tenant-bound APIs under `/tenants/{tenantId}/...`; keep health/status endpoints outside the group for anonymous access. diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx index aa51817..345a20a 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.slnx @@ -13,6 +13,8 @@ + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props index 14feb67..943a3c8 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props @@ -6,12 +6,19 @@ 10.0.0 10.0.0 10.0.0 + + 0.1.1-preview.1 + + + + + @@ -46,4 +53,4 @@ - \ No newline at end of file + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md index 4490d56..19b5947 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md @@ -2,6 +2,47 @@ The project was generated using the [Clean.Architecture.Solution.Template](https://github.com/jasontaylordev/CleanArchitecture) version 10.0.0-preview. +## Sample Steps + +### Step 1: Create the base solution +Generate the empty Web API-only solution with SQLite using the Clean Architecture template. This matches the baseline template so the multitenancy changes are easy to compare and repeat. + +### Step 2: Add multitenancy NuGet packages +Reference the published Multitenancy packages from NuGet (use the latest versions available). + +Packages: +- [CleanArchitecture.Extensions.Multitenancy](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy) +- [CleanArchitecture.Extensions.Multitenancy.AspNetCore](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy.AspNetCore) + +The sample uses central package management, so versions live in `Directory.Packages.props`. You can use `dotnet add package` or edit the files directly as shown below. + +`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props`: +```xml + +0.1.1-preview.1 + +``` +```xml + + + + +``` + +`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj`: +```xml + + + +``` + +`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj`: +```xml + + + +``` + ## Build Run `dotnet build -tl` to build the solution. @@ -55,4 +96,4 @@ dotnet test ``` ## Help -To learn more about the template go to the [project website](https://github.com/jasontaylordev/CleanArchitecture). Here you can find additional guidance, request new features, report a bug, and discuss the template with other users. \ No newline at end of file +To learn more about the template go to the [project website](https://github.com/jasontaylordev/CleanArchitecture). Here you can find additional guidance, request new features, report a bug, and discuss the template with other users. diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj index b4dfbc8..cd6fcad 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj @@ -8,6 +8,9 @@ + + + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj index 875455f..510f5ee 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj @@ -13,6 +13,9 @@ + + + From e0fd5e717ee785a36e69d7e885389d9259499793 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Mon, 12 Jan 2026 18:24:12 +0300 Subject: [PATCH 4/6] Document multitenancy setup and pin packages --- .../header-and-route-resolution.md | 43 +++++++++------ .../Directory.Packages.props | 7 --- .../README.md | 49 ++++++++++++------ .../src/Application/Application.csproj | 2 +- .../src/Web/DependencyInjection.cs | 22 ++++++++ .../src/Web/Web.csproj | 2 +- .../src/Web/app.db | Bin 4096 -> 106496 bytes .../src/Web/app.db-shm | Bin 32768 -> 0 bytes .../src/Web/app.db-wal | Bin 177192 -> 0 bytes 9 files changed, 84 insertions(+), 41 deletions(-) delete mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-shm delete mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-wal diff --git a/docs/samples/multitenancy/header-and-route-resolution.md b/docs/samples/multitenancy/header-and-route-resolution.md index 4615ac0..1baab1b 100644 --- a/docs/samples/multitenancy/header-and-route-resolution.md +++ b/docs/samples/multitenancy/header-and-route-resolution.md @@ -34,32 +34,45 @@ Document a sample that shows deterministic tenant resolution from route first, h - Verify the output folder exists and contains the new solution file plus `src/` and `tests/`. 2. Add NuGet package references for the multitenancy extensions (use the latest published versions from NuGet). - Packages: [CleanArchitecture.Extensions.Multitenancy](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy) and [CleanArchitecture.Extensions.Multitenancy.AspNetCore](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy.AspNetCore). - - You can either use `dotnet add package` or edit the files directly as shown below. The sample uses central package management, so versions live in `Directory.Packages.props`. - - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props`: - ```xml - - 0.1.1-preview.1 - - ``` - ```xml - - - - - ``` - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj`: ```xml - + ``` - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj`: ```xml - + ``` 3. Configure `MultitenancyOptions` for route-first ordering (`Route > Host > Header > Query > Claim`), set header name `X-Tenant-ID`, require tenants by default, and disable fallback tenants. + - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs`: + ```csharp + // Step 3: (Begin) Multitenancy configuration imports + using CleanArchitecture.Extensions.Multitenancy; + using CleanArchitecture.Extensions.Multitenancy.Configuration; + // Step 3: (End) Multitenancy configuration imports + ``` + ```csharp + // Step 3: (Begin) Configure multitenancy resolution defaults + builder.Services.Configure(options => + { + options.RequireTenantByDefault = true; + options.HeaderNames = new[] { "X-Tenant-ID" }; + options.ResolutionOrder = new List + { + TenantResolutionSource.Route, + TenantResolutionSource.Host, + TenantResolutionSource.Header, + TenantResolutionSource.QueryString, + TenantResolutionSource.Claim + }; + options.FallbackTenant = null; + options.FallbackTenantId = null; + }); + // Step 3: (End) Configure multitenancy resolution defaults + ``` 4. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; place `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. 5. Add route conventions that group tenant-bound APIs under `/tenants/{tenantId}/...`; keep health/status endpoints outside the group for anonymous access. 6. Decorate tenant-bound endpoints with `RequireTenant`, and mark public endpoints with `AllowAnonymousTenant` to keep resolution optional without enforcement. diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props index 943a3c8..bf73593 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props @@ -6,19 +6,12 @@ 10.0.0 10.0.0 10.0.0 - - 0.1.1-preview.1 - - - - - diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md index 19b5947..52a64e7 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md @@ -14,35 +14,50 @@ Packages: - [CleanArchitecture.Extensions.Multitenancy](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy) - [CleanArchitecture.Extensions.Multitenancy.AspNetCore](https://www.nuget.org/packages/CleanArchitecture.Extensions.Multitenancy.AspNetCore) -The sample uses central package management, so versions live in `Directory.Packages.props`. You can use `dotnet add package` or edit the files directly as shown below. - -`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/Directory.Packages.props`: -```xml - -0.1.1-preview.1 - -``` -```xml - - - - -``` - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj`: ```xml - + ``` `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj`: ```xml - + ``` +### Step 3: Configure multitenancy resolution defaults +Set route-first ordering, require tenants by default, and disable fallback tenants. + +`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs`: +```csharp +// Step 3: (Begin) Multitenancy configuration imports +using CleanArchitecture.Extensions.Multitenancy; +using CleanArchitecture.Extensions.Multitenancy.Configuration; +// Step 3: (End) Multitenancy configuration imports +``` +```csharp +// Step 3: (Begin) Configure multitenancy resolution defaults +builder.Services.Configure(options => +{ + options.RequireTenantByDefault = true; + options.HeaderNames = new[] { "X-Tenant-ID" }; + options.ResolutionOrder = new List + { + TenantResolutionSource.Route, + TenantResolutionSource.Host, + TenantResolutionSource.Header, + TenantResolutionSource.QueryString, + TenantResolutionSource.Claim + }; + options.FallbackTenant = null; + options.FallbackTenantId = null; +}); +// Step 3: (End) Configure multitenancy resolution defaults +``` + ## Build Run `dotnet build -tl` to build the solution. diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj index cd6fcad..b2df343 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Application/Application.csproj @@ -9,7 +9,7 @@ - + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs index 37e852a..ae84b0b 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs @@ -1,4 +1,8 @@ using Azure.Identity; +// Step 3: (Begin) Multitenancy configuration imports +using CleanArchitecture.Extensions.Multitenancy; +using CleanArchitecture.Extensions.Multitenancy.Configuration; +// Step 3: (End) Multitenancy configuration imports using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Services; @@ -23,6 +27,24 @@ public static void AddWebServices(this IHostApplicationBuilder builder) builder.Services.AddExceptionHandler(); + // Step 3: (Begin) Configure multitenancy resolution defaults + builder.Services.Configure(options => + { + options.RequireTenantByDefault = true; + options.HeaderNames = new[] { "X-Tenant-ID" }; + options.ResolutionOrder = new List + { + TenantResolutionSource.Route, + TenantResolutionSource.Host, + TenantResolutionSource.Header, + TenantResolutionSource.QueryString, + TenantResolutionSource.Claim + }; + options.FallbackTenant = null; + options.FallbackTenantId = null; + }); + // Step 3: (End) Configure multitenancy resolution defaults + // Customise default API behaviour builder.Services.Configure(options => diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj index 510f5ee..9ca7580 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Web.csproj @@ -14,7 +14,7 @@ - + diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db index 4e86411b5803e34b1e4767ce981907694f15c1ed..77ed45e368b789f13de6a64a8e5f64d5c1b68695 100644 GIT binary patch literal 106496 zcmeI5&rcg?8pmfaU`zm~KfT*_%hVhai%er<8{4U>)G-5bV{BvW6k1hu@C?Z`wug9z zBt+_gl1AIps@g+U?Ml70d+4Qy{R{Sg*h8=TqnDLd-S)DTx`%z|m;H|M#H1}%0^bpk znR$Oa&-;0v_j!Neu)0!EbeY>y+f7mD;>>AgXoz`@;~0h+BHz!EZ|8b~+zdMpg@o&dJ4ZPt0WaM-In(w2LE%r-aIrzO-6?jh(*ne@- zAL4oT163C{8**0LF3NgMliOAGhTPJ6pQmyaAzKx=YW7k=;KIGhVeahYI7hCqQAJ(~ zb5&uj$`woGyH+Tivl9#IHKkQ8jY+>7;e5&6SF))k-#Bta9P< zm5qbCl`Hw>Y-OFhBCK;~9SzTMVYfQug(*8?YfD$Vc(7VZj<(^nda+ay@=HaxqzG3L z7KMsX%n7SBI*m{Z6IKzfROA+ffv`PEE3D+MTey2t|IY99`z!LUNB?$mEGXJ`L)@h!LASR%{kN|W zPcL&6cDtEdZ4v++4GbLzx|~%f)fJD#;upsc7YA%@D3e=nPVF;eR-}*j*gV$|l_q6# z>p9?Mb1S)PbEBt|m@6y^l}@1NJPqKKrVWs-RZICIk+&=qtIinVgwnnPh*?W@ce~Fh z5H`~eF+{jxxuw=o+i;)pA2fl{BQ5b(%;-Lk^)o>?`Nx=mE;R@>5L zQM=wp(5hVDX)F5fsxCIS`=k>IBtx5cy=Rv4b+siIcbc2sDLT~>D)kWBR&T3|VqI6; zLd#fXJfK|#wSGh0(S=sW|0toI!~vCL>!dSli^K-9lv8(F`azDw-IBwjq5RC?Ih00E zsFf^QxfTu^w%0{U2I%e zHQjZl{#v1w%N8z|R;v$}JG+u4S3a9v7q1z2LRpFGH;cDomrLhMiS_fDckk||#M*jx zyME)UcJ*>>yH;tu9#zV(-&#>GtyJGwTPB~xc;i~@_M7u}uHIeFti1YaZegXe7GEi? z&0k(Bq~fW|%j*kE@vAFK#g+Nj*Du8r(S(>tW<-8ds%Q9wv{fgqpRe=r^k!Pxtk2a` zQJnujR-O%IK>!3m00ck)1V8`;KmY_l00cn5O~5$+Kg^zo_4)tLnBZsb@^}IP5C8!X z009sH0T2KI5C8!X009vAWeL2=jxp2EmzWY`{I8;%@!yrsESlF`bZ(xH#`xGA7fW7< zWiHIcUrwZw(PT0+8;xFwMh9jv*8fAne=x!C$PZi~00JNY0w4eaAOHd&00JNY0w4ea zk1K)6A>TA>>>T8Exv33Lu)gU)=g*TE>woe`|8Ri-2!H?xfB*=900@8p2!H?xfB*;_ zV**(JA7kU9X&?XsAOHd&00JNY0w4eaAOHd&U=zUlAFcobAOHd&00JNY0w4eaAOHd& z00PIKz_`&w{Qm#(H#AxZ0w4eaAOHd&00JNY0w4eaAaJ4y80Y_N@XrkS!UX~#00JNY z0w4eaAOHd&00JNY0wD0{5V*qnYPG}sZNEe&7Eh*ANj{N~Wj-OMV|*r)uJh@+SiGK& z*5j#k)Hwgg`v1`}E;IoF5C8!X009sH0T2KI5C8!X00EmoVYpTc{9NmQeEuJP1p*)d z0w4eaAOHd&00JNY0w4ea$DaT`|9|{#j240b2!H?xfB*=900@8p2!H?xfPiuSKRk7g z3H~8?e(F!;1{Vl`00@8p2!H?xfB*=900@ADQIkHH}kS{f{4b%8I84 z$Z|5&St2Xt$XXU}kd|_~Dye3RNP~XA?TFd6)w8S{U_O#LnRmx))Vz8L>@ z{L{b-{!d0e_pkXr8rfpM^p!~#T#p8U{TCCvQ>erW-k>4F5H_O=FU!zbL0veRpg~GR~6Q(T(LyHYlXr&JF%c%Q(EP=dP|Yy zwnv_#*pxkzu8NJFPFlCxT&cKPtz`4XDi(eNA>cB?~P zn6e|bwsf_N2dkyzXd6ze7fTf(zf^Qfif|QSQK$&ToUlrx(+IUNVHM#@MQ%YT2!wAg zyPC@`2$SQH;P=D%Ri0&(mL%WNZZ=3b*bsMg)qJLeTlU(B^)@@WUpeIu&Cas--|De& zMQzBGfz8JWFZ-IQhcvH|*UR=aO_z~u0bVw;>l|nlvl<(Vb#IsC+%|FQ>thqMn72&^ zxrHUQYlb88ilO+*>|n33l%br$d@;En4Vb}rxT>Nz zI$fh@?v+y;WP!DjQzg$FWbvcRUURpcm3pZYys3~}5H)>Sm6R>TvpAi_RR^)ny@t;p znweogobHKGRB+OU-889j*3HuuH@xE!nS4<=>EPor>=e~@lPnhR%90VGJTeJQQE51% z8wIFa*ei$mKBuDtflX+x#fbk71LxZuHIyqR(2On9^ju}f@yTwPqTX?=7vUoZNr%81vdIXkB2iwnXU7w(faZ0w+z6D+d=5^BR%9pT9Kv;NST8Fp{R6yprtd1F(BnnL{A z7EvqaS84(od`(HN+?EWgj3K2Pa-F>4$UD#YLoX4D0aKzmuJ?-jmAggVJOhMSlZVKA zPy0g`XW6~qm=Xw&u2xonHh$)%p0^>lP|sq$I+#@&jkRhGNA}pK{Glw*?!9zSzP$=H zXwV8+ub+K@2Cjm=Dp-kz4pzm64o`aOV6W+Vb+BGP^8g)O1$%X{5@{V8Pgt(?yote} zV~o3$txa-I+XCIL<;qQqGRwGK%N)%~i%<7$u~EQQ+_-h9cV9o{(c*574Xa?U8r>G# z>5+TqgXWUww|r;GV|KVsS3PaZvbTvl?@5E~E#SlK8c@Zk*$>xuyTI<}p7Z4gEs2V% z-dPJ#v(0i_QH?qD!O5dD39raa!{(hWY^v}AnLK+Jv=>p%#JqQjc0@}oTWxx8hHQd# zD4TcY($$W<;hsHHCziVQOpW4ZA;VdTRr~K^Uz|GJ?v?j0HqI#??Q@D(Cq_d*Wk(vP z6y{#ru{x#r>3h~Vr=X84&H0aWHW7X_&nfWve=O=i00ck)1V8`;KmY_l00ck)1VG^U z6R`gN-#1L~o8#YBv=9V900ck)1V8`;KmY_l00ck)1WpKn)4nr-?80)sm^VIqT`g5` z{(nL&k7j}Z2!H?xfB*=900@8p2!H?xoM-}A|DR~{qwydB0w4eaAOHd&00JNY0w4ea zCxn3Y`~P1t!LLq8gV9V7009sH0T2KI5C8!X009sH0T4Jw1kU(sfkY-2Po`5zK9P`R zJ|U)Kd?u5w^Xa)*yq=EM< z5_rz{iu+UluN6wUY~gZgwTkusNpuLb83aH81V8`;KmY_l00ck)1VG?e5*V=l9}fPT z3H~qm-{5!T1{Vl`00@8p2!H?xfB*=900@8p2!O!jO<>p;V26z#KI6xb&ld>%9|@GC A8vp;zWBOL$6J9Q^6d509e2VssI20 diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-shm b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-shm deleted file mode 100644 index bfd61a3a3fd3720da4029fb23c032a0f767642b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*OEN=26vpu*uPX#W$W1(gcq?nL0AmAXWEoaqVrs<1LTtc9*@B6g6{zAnDOY9I zF-WEE?fdHv<{?BV9F^qO3M zT&4dUuc-!gpwI82`u+Lup|!1~l$DC&`>833GOg5=8D&;!D09lZvY<4TMP*4@R$9u6 zvZ}O|ROu+*SGII79Rfwt`xtD&)VQ?j+O&CSG<5lC&z_9wG+&Q>kkdd_ytc0&Rk z+crOGiV6u-Y}0l^gLt1Sfv)-KZB$sGYQ69vMj%i~ftno_vM-uKAS{sBQFss|5GbU8 zpQTG75T}5jB26I>r+}ZIP9YGdfZrTIArPlP!}@VHPS*(NwwPTM5QtR3?@6K%h*Q9C z2%`{)Q=nWe CGc8mA diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-wal b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db-wal deleted file mode 100644 index 64e9391756b4731646ba9b3de7233ab4d75d30d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 177192 zcmeI*eQX=&eFtz-ltfW)wBr}XPGX%Ybhc_S5%sQko`i3EaleB-VD2l8L(q%)?VjY$hSh8jfineQ;H0xp@ z-MZ(wd+xcrhexXKk=nC1-&?oZY@2NK^||R0*Q>ug zcIpz3FP0Ie9 zW88hM+~!zTBCxcp$?5U=?B_+9n@#fpX+FZsaf#2x#1nj0DxGf*M%h4&WnzItVV3ch zuJ$r}o9Y?z^3q*|l3pgpPQ{qW82O2Z!|jEYVez<-9nXmm3Q0a^ktV`rc#Bo{a_M|= zoxa=PSY$F54TK^w#ydVTv%GBM(a>lhI?ar*)68D2gzb#iTpY4M$-AvkTH|hqmhaY( zk2IszdT1=lhK3{NoZ6TuJH$rWNRXX0(n&zGt(7HY0~7;Z?b zIM7(@nb=pkRj7qAGzw)w(^7{^IqENLDQOeRpU_Obt58?#&BA;nl|T5k<9>u+ zusFW86oY~^pXL^fJ%N6-YsYWdOe|ZOR$y~8GZ&k509pxX87nq9b)SYq@rFeFVEyX) zfkJ5vAy=0)EZOSRp1!Lj=D{>4WDGG^=UrAYS6AyXr$;*JbJ<}wTI}c<%K&Iw8v_uC z$HqbtlHVvBiD~Butt%~i0#Wu7TbM6%7Vs+TR&k0j_2p`@ZgdQnY|iB&phu*dz1nB! z^L6Dyps)UYLSWpwVhCtrtA1Q%{$qteFV+q1y2*})o8U`k zohJU0m3y<~`lOv~*>-lg#_4Hkv7bAt>@y}N#jqgBQgNczv>$JA`Sr!muvx{x#RNHB zJTywKy@Fy|%;#o;V$w22j^||F>e?-+u02%jybWD)n3LpDF)5@3%gv2RO#2|QIhS@g zJ*}l%l;uG&n-X#vidfpSY9$=!B&vMH`K(ib`_OREWOKEpJ zR@g}(K@MhVh{S+T2E}|or_KfTW{ju*T-^2qKh z5_h(VE>wpS=oDTMfB*y_009U<00Izz00bZa0SK&50cDIpdyb&ye?5V-4WHjsPwR?t zf%O@2)CvL+fB*y_009U<00Izz00bbQ3n-rf(8dM6_`A>EH#4;T%XnTu2f`EtAOHaf zKmY;|fB*y_009Uyt4kNForNATsjzw(H$efv>7FR+dyj;cWb0uX=z1Rwwb2tWV= z5P$##bb(qrsXZ_7)Q6UQKYrrd=kUCM4umNPKmY;|fB*y_009U<00Izzz&aLi(zst6f2id@ zBN!LZfiMLD2tWV=5P$##AOHafKmY;|SjPe`S~u-^f$;M`aldf2{aY9pSjQ1Z)gS-? z2tWV=5P$##AOHafKmY=|fbtcC+PJ`D-+lZ;=`U^j7{AWT630uX=z1Rwwb2tWV= z5P$##*0DeXt(!J3uxILymmm7L>rVVVf^{5mR1E?UfB*y_009U<00Izz00bbQ3pCP6 zZCv1QF5h+OlV3TB&k^W8n1TQVAOHafKmY;|fB*y_009WBX949S1KPO2jw~2tWV=5P$##AOHafKmYkS-?#zr;Q7|x&Mn3u1mk1!t(;_IpnAt1Rwwb2tWV=5P$## zAOHafK%gkFiB4(b0$<(z-cSDhw$Hlpyg<fu&tdPLI!LKQGGMY?=>9^ATQ-OMEUSp5U`m>3nlA$_8RA6AK&)vy8WNwU^o3 zRL_u?m+m5z^fEDaD#k>{$WJ^RZZE71i^qlRcuss!Nb)(0G!ZVtTdca5OXrL0^xX!> zB9pObAQXu)-tm!{hDHO?X=a3-X7*|&Y-haY;*bSO-fe}_8h1Oie7A;tq#3Q& zLt{}kG#oMK)W$^FAvVfJg6yP`P9kbvV%5fsMVLW0%o5*VU@{mOWSi>S+}El@lRmpm z$R_#I(#fW76&@xtRa_T+}hvE&1_`&*G&uaRCLTL;k zSC=#_k<+r$(|47`JecN$j3MUgyvr))>S{ga^hhUtE<4Odiyb{<831i-V*mp2*jOk+ z@*8C%G3^|ob){ubAj)213-e{p0$yd^DozomzFaNVjgH}x&AB`T^oUfmSNjZozOGyd z^wqyl2#i}-3;|7S)sL&pf2{Q%hepY@ zS5Qog`P@uUOj@SM@tn+CUAqOxw$ckX&)pu=h6a2h)wi_=a-%w@Q=aE4FP9?BvSo8g4Cc6KuW z=^I<6P?iV9Y)Z&wC}L^Ps+Dk@lcZB(E_s-f=E`I=$tUtTL0*`Yxy*c-^&|(9AV*rg zWs>nZG0R8tnOXf3-B1(5+EtV`c1j%L60(?Mv-B=wg?fd>#0fDkv)N+%Gh|v^Sz#xE z1UZV85HwbdATELZavkto=|J$YbeQhQ7zRh9nQ@TY*}B3l?Je|wZCt?i-Ur7%{k`V5 z=<@>Z>JD4;Xmev@U&FQf`|7tlf9yO~`|H|l&ChC1)VQjjB&+d)00bal<2_DKSBrhI zMj7Y`P0i2?ppeXGq#1g76-pN8cNY5EP-KvuV!UONdg;iDa^a=yfONrLbsyfg`Rz{6 zo>u!}tCEd&N)OS&7Q>~*jzSjkNN6I?l9RHMlPR^DhhZCo$df>d^DCA=ZlA)L7&bFp6)|Op5emn-<#qH*w@MLFF5V*se_*gdHzc9X)sa*eP?^$?n z&i60KEzK5sTceE&Jh5Xo`TnQ7pThSG;ARg35P$##AOHafKmY;|fB*y_uptDt(pqZc z0x$gR?zyKQfA&u>F0dhDk4i!S0uX=z1Rwwb2tWV=5P$##wow9YT;O}h7XJFNe||TO zaRD?82tWV=5P$##AOHafKmY;|fWU?j*iLJyjSKwr`3L6Pzwn(`F)pwnVvkBf00Izz z00bZa0SG_<0uX=z1U!^L8yEQ9%g_GTe|Inp#s$zYAOHafKmY;|fB*y_009U<00J9A z;8t2oZCs#f|I4R8@r@T>!??hPh&?I^0SG_<0uX=z1Rwwb2tWV=5ZFNpv~hv@?cqQD z;P!ug5#s`A7!ZH}1Rwwb2tWV=5P$##AOL|4A+VFwa;G*f@I({)(NBK=&pR*1K1!Ojwo2xF_IosQp@^||BI+bVYlYQjr`rf3EAK-fj z65U<>i3HCXpUA%_Ehf11oG8hr`{;YZW5Gc9@YrPR`celb0^}761g5#;bc!9zNJozE z>-USJX@2;SNFMt?lFH8CJ25*wc_cPI*B1#Net^yO_D@X@P6>VN!oYBB=*)2E>EvY3 z{F#BSXyCwsP`rEOzQN#;@xHOao_OD(;nC^o?#a<;@5K0YXn10Xo8|mHNq@rE&kywb zy8Wr7kL%}qd>#G$iT;k>p4skXk_5Jf23H#wc&6%ycl=8ta~Yo_SW`_Ng@phFAOHaf zKmY;|fB*y_009V?1nB1oRx=htHoBE=sErFm4!tBj>^t_3Ixg_G&Hc7%x0rzd1Rwwb z2tWV=5P$##AOHafKwzy2>~!ome|kWF>VZ^c2l**}zrgdmA94vjhtFeNV66oi<%IwQ zAOHafKmY;|fB*y_009VC2&_CVVETT6SKj#4k;Ee(J&*4fu&~5p2tWV=5P$##AOHaf zKmY;|xakG({Q~Ou3-t8*ySlh!%GW#FOTJ&Aqq~QEzkr|oN+tRRI{Rk_QvMzi%tjhe z?Q;ZI-?;lXwy&PPQyCXdl|xdk5EU>``(EKmY;|fB*y_009U<00IzzfJtEG?;SM#J%Z{N zAN;q5jsrQ23z!5k0|5v?00Izz00bZa0SG_<0uWfc0xORT)Y6J+pCkDA=8L}>*wuJa z{ocVVHusgaTWypa0uX=z1Rwwb2tWV=5P$##Ag~?;j@avLEw_!?#%wV$DKcR}l3RzA zS7%3Oudl=J^LH}-?z_7>?(XQlv!k!SyT?DUuY=5WR7^m8oYY7g7pQ*y=@(uO?&_iA z0#)w+u(^Lte&GcH2tWV=5P$##AOHafKmY;|fWTT6XsU9w*y)HuNaizAb)((U;-a&3 zd_f|W+C(d+jSHN5{mNG+ets!J#|5gJ+imVk?z@`5L?-Zp00bZa0SG_<0uX=z1Rwx` z4KHxIy3yw1Y<0Fe`rdVvKgH#e%z~KDNqjoRoD$?YhGViqf^TQiV)i(b<>r}pE`RER z#hcds{T=-)zN@V#SgduBtRL31=F)=1)4caQ$TM6z$8*UACMjllMm{An@|=)8E-_-3 zOz{j!D(@x*x$>NKL3%0`q>$i&DpC$sD@SmSPn=-HR7%MwCFCTT5y;Iq^Va9iTj%Ak zlvg1^!cOw))AAbSPLP^1GJRrz)`(Q-u_dca`uZ!#788(0y_5cE;{sp!*7xJTZT-u~ z>Gum%yMJMG|JMCJd1l}j8@}-a00Izz00bZa0SG_<0uX=z1R${11*#n`do}&#puegd I4wviy0KQaiP5=M^ From abdf14c71bd86e214a85ccd710c5c5b9c26a17e9 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Mon, 12 Jan 2026 18:35:41 +0300 Subject: [PATCH 5/6] Normalize sample line endings and ignore sqlite db --- .../.gitattributes | 2 ++ .../.gitignore | 5 +++++ .../src/Web/app.db | Bin 106496 -> 0 bytes 3 files changed, 7 insertions(+) create mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitattributes delete mode 100644 samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitattributes b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitattributes new file mode 100644 index 0000000..62648fe --- /dev/null +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitattributes @@ -0,0 +1,2 @@ +# Keep sample files consistently LF to match .editorconfig and avoid mixed endings +* text=auto eol=lf diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore index 164fea3..1001eb3 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/.gitignore @@ -270,6 +270,11 @@ ServiceFabricBackup/ *.ldf *.ndf +# SQLite files +app.db +app.db-shm +app.db-wal + # Business Intelligence projects *.rdl.data *.bim.layout diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/app.db deleted file mode 100644 index 77ed45e368b789f13de6a64a8e5f64d5c1b68695..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 106496 zcmeI5&rcg?8pmfaU`zm~KfT*_%hVhai%er<8{4U>)G-5bV{BvW6k1hu@C?Z`wug9z zBt+_gl1AIps@g+U?Ml70d+4Qy{R{Sg*h8=TqnDLd-S)DTx`%z|m;H|M#H1}%0^bpk znR$Oa&-;0v_j!Neu)0!EbeY>y+f7mD;>>AgXoz`@;~0h+BHz!EZ|8b~+zdMpg@o&dJ4ZPt0WaM-In(w2LE%r-aIrzO-6?jh(*ne@- zAL4oT163C{8**0LF3NgMliOAGhTPJ6pQmyaAzKx=YW7k=;KIGhVeahYI7hCqQAJ(~ zb5&uj$`woGyH+Tivl9#IHKkQ8jY+>7;e5&6SF))k-#Bta9P< zm5qbCl`Hw>Y-OFhBCK;~9SzTMVYfQug(*8?YfD$Vc(7VZj<(^nda+ay@=HaxqzG3L z7KMsX%n7SBI*m{Z6IKzfROA+ffv`PEE3D+MTey2t|IY99`z!LUNB?$mEGXJ`L)@h!LASR%{kN|W zPcL&6cDtEdZ4v++4GbLzx|~%f)fJD#;upsc7YA%@D3e=nPVF;eR-}*j*gV$|l_q6# z>p9?Mb1S)PbEBt|m@6y^l}@1NJPqKKrVWs-RZICIk+&=qtIinVgwnnPh*?W@ce~Fh z5H`~eF+{jxxuw=o+i;)pA2fl{BQ5b(%;-Lk^)o>?`Nx=mE;R@>5L zQM=wp(5hVDX)F5fsxCIS`=k>IBtx5cy=Rv4b+siIcbc2sDLT~>D)kWBR&T3|VqI6; zLd#fXJfK|#wSGh0(S=sW|0toI!~vCL>!dSli^K-9lv8(F`azDw-IBwjq5RC?Ih00E zsFf^QxfTu^w%0{U2I%e zHQjZl{#v1w%N8z|R;v$}JG+u4S3a9v7q1z2LRpFGH;cDomrLhMiS_fDckk||#M*jx zyME)UcJ*>>yH;tu9#zV(-&#>GtyJGwTPB~xc;i~@_M7u}uHIeFti1YaZegXe7GEi? z&0k(Bq~fW|%j*kE@vAFK#g+Nj*Du8r(S(>tW<-8ds%Q9wv{fgqpRe=r^k!Pxtk2a` zQJnujR-O%IK>!3m00ck)1V8`;KmY_l00cn5O~5$+Kg^zo_4)tLnBZsb@^}IP5C8!X z009sH0T2KI5C8!X009vAWeL2=jxp2EmzWY`{I8;%@!yrsESlF`bZ(xH#`xGA7fW7< zWiHIcUrwZw(PT0+8;xFwMh9jv*8fAne=x!C$PZi~00JNY0w4eaAOHd&00JNY0w4ea zk1K)6A>TA>>>T8Exv33Lu)gU)=g*TE>woe`|8Ri-2!H?xfB*=900@8p2!H?xfB*;_ zV**(JA7kU9X&?XsAOHd&00JNY0w4eaAOHd&U=zUlAFcobAOHd&00JNY0w4eaAOHd& z00PIKz_`&w{Qm#(H#AxZ0w4eaAOHd&00JNY0w4eaAaJ4y80Y_N@XrkS!UX~#00JNY z0w4eaAOHd&00JNY0wD0{5V*qnYPG}sZNEe&7Eh*ANj{N~Wj-OMV|*r)uJh@+SiGK& z*5j#k)Hwgg`v1`}E;IoF5C8!X009sH0T2KI5C8!X00EmoVYpTc{9NmQeEuJP1p*)d z0w4eaAOHd&00JNY0w4ea$DaT`|9|{#j240b2!H?xfB*=900@8p2!H?xfPiuSKRk7g z3H~8?e(F!;1{Vl`00@8p2!H?xfB*=900@ADQIkHH}kS{f{4b%8I84 z$Z|5&St2Xt$XXU}kd|_~Dye3RNP~XA?TFd6)w8S{U_O#LnRmx))Vz8L>@ z{L{b-{!d0e_pkXr8rfpM^p!~#T#p8U{TCCvQ>erW-k>4F5H_O=FU!zbL0veRpg~GR~6Q(T(LyHYlXr&JF%c%Q(EP=dP|Yy zwnv_#*pxkzu8NJFPFlCxT&cKPtz`4XDi(eNA>cB?~P zn6e|bwsf_N2dkyzXd6ze7fTf(zf^Qfif|QSQK$&ToUlrx(+IUNVHM#@MQ%YT2!wAg zyPC@`2$SQH;P=D%Ri0&(mL%WNZZ=3b*bsMg)qJLeTlU(B^)@@WUpeIu&Cas--|De& zMQzBGfz8JWFZ-IQhcvH|*UR=aO_z~u0bVw;>l|nlvl<(Vb#IsC+%|FQ>thqMn72&^ zxrHUQYlb88ilO+*>|n33l%br$d@;En4Vb}rxT>Nz zI$fh@?v+y;WP!DjQzg$FWbvcRUURpcm3pZYys3~}5H)>Sm6R>TvpAi_RR^)ny@t;p znweogobHKGRB+OU-889j*3HuuH@xE!nS4<=>EPor>=e~@lPnhR%90VGJTeJQQE51% z8wIFa*ei$mKBuDtflX+x#fbk71LxZuHIyqR(2On9^ju}f@yTwPqTX?=7vUoZNr%81vdIXkB2iwnXU7w(faZ0w+z6D+d=5^BR%9pT9Kv;NST8Fp{R6yprtd1F(BnnL{A z7EvqaS84(od`(HN+?EWgj3K2Pa-F>4$UD#YLoX4D0aKzmuJ?-jmAggVJOhMSlZVKA zPy0g`XW6~qm=Xw&u2xonHh$)%p0^>lP|sq$I+#@&jkRhGNA}pK{Glw*?!9zSzP$=H zXwV8+ub+K@2Cjm=Dp-kz4pzm64o`aOV6W+Vb+BGP^8g)O1$%X{5@{V8Pgt(?yote} zV~o3$txa-I+XCIL<;qQqGRwGK%N)%~i%<7$u~EQQ+_-h9cV9o{(c*574Xa?U8r>G# z>5+TqgXWUww|r;GV|KVsS3PaZvbTvl?@5E~E#SlK8c@Zk*$>xuyTI<}p7Z4gEs2V% z-dPJ#v(0i_QH?qD!O5dD39raa!{(hWY^v}AnLK+Jv=>p%#JqQjc0@}oTWxx8hHQd# zD4TcY($$W<;hsHHCziVQOpW4ZA;VdTRr~K^Uz|GJ?v?j0HqI#??Q@D(Cq_d*Wk(vP z6y{#ru{x#r>3h~Vr=X84&H0aWHW7X_&nfWve=O=i00ck)1V8`;KmY_l00ck)1VG^U z6R`gN-#1L~o8#YBv=9V900ck)1V8`;KmY_l00ck)1WpKn)4nr-?80)sm^VIqT`g5` z{(nL&k7j}Z2!H?xfB*=900@8p2!H?xoM-}A|DR~{qwydB0w4eaAOHd&00JNY0w4ea zCxn3Y`~P1t!LLq8gV9V7009sH0T2KI5C8!X009sH0T4Jw1kU(sfkY-2Po`5zK9P`R zJ|U)Kd?u5w^Xa)*yq=EM< z5_rz{iu+UluN6wUY~gZgwTkusNpuLb83aH81V8`;KmY_l00ck)1VG?e5*V=l9}fPT z3H~qm-{5!T1{Vl`00@8p2!H?xfB*=900@8p2!O!jO<>p;V26z#KI6xb&ld>%9|@GC A8vp Date: Mon, 12 Jan 2026 19:04:29 +0300 Subject: [PATCH 6/6] Revert sample to UseCleanArchitectureMultitenancy --- .../header-and-route-resolution.md | 28 ++++++++++++++++- .../README.md | 31 +++++++++++++++++++ .../src/Web/DependencyInjection.cs | 8 +++++ .../src/Web/Program.cs | 10 ++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/samples/multitenancy/header-and-route-resolution.md b/docs/samples/multitenancy/header-and-route-resolution.md index 1baab1b..065c31f 100644 --- a/docs/samples/multitenancy/header-and-route-resolution.md +++ b/docs/samples/multitenancy/header-and-route-resolution.md @@ -73,7 +73,33 @@ Document a sample that shows deterministic tenant resolution from route first, h }); // Step 3: (End) Configure multitenancy resolution defaults ``` -4. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; place `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. +4. Register services with `AddCleanArchitectureMultitenancy` then `AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false)`; add `UseCleanArchitectureMultitenancy` after routing and before authentication/authorization. + - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs`: + ```csharp + // Step 4: (Begin) Multitenancy ASP.NET Core registration imports + using CleanArchitecture.Extensions.Multitenancy.AspNetCore; + // Step 4: (End) Multitenancy ASP.NET Core registration imports + ``` + ```csharp + // Step 4: (Begin) Register multitenancy services and ASP.NET Core adapter + builder.Services.AddCleanArchitectureMultitenancy(); + builder.Services.AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false); + // Step 4: (End) Register multitenancy services and ASP.NET Core adapter + ``` + - `samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs`: + ```csharp + // Step 4: (Begin) Multitenancy middleware import + using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; + // Step 4: (End) Multitenancy middleware import + ``` + ```csharp + // Step 4: (Begin) Add multitenancy middleware between routing and auth + app.UseRouting(); + app.UseCleanArchitectureMultitenancy(); + app.UseAuthentication(); + app.UseAuthorization(); + // Step 4: (End) Add multitenancy middleware between routing and auth + ``` 5. Add route conventions that group tenant-bound APIs under `/tenants/{tenantId}/...`; keep health/status endpoints outside the group for anonymous access. 6. Decorate tenant-bound endpoints with `RequireTenant`, and mark public endpoints with `AllowAnonymousTenant` to keep resolution optional without enforcement. 7. Enable `TenantExceptionHandler`/ProblemDetails so unresolved tenants return 400, missing tenants return 404, and suspended tenants return 403. diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md index 52a64e7..4469b91 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/README.md @@ -58,6 +58,37 @@ builder.Services.Configure(options => // Step 3: (End) Configure multitenancy resolution defaults ``` +### Step 4: Register multitenancy services and middleware +Register the core and ASP.NET Core services, then add the middleware between routing and auth. + +`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs`: +```csharp +// Step 4: (Begin) Multitenancy ASP.NET Core registration imports +using CleanArchitecture.Extensions.Multitenancy.AspNetCore; +// Step 4: (End) Multitenancy ASP.NET Core registration imports +``` +```csharp +// Step 4: (Begin) Register multitenancy services and ASP.NET Core adapter +builder.Services.AddCleanArchitectureMultitenancy(); +builder.Services.AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false); +// Step 4: (End) Register multitenancy services and ASP.NET Core adapter +``` + +`samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs`: +```csharp +// Step 4: (Begin) Multitenancy middleware import +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; +// Step 4: (End) Multitenancy middleware import +``` +```csharp +// Step 4: (Begin) Add multitenancy middleware between routing and auth +app.UseRouting(); +app.UseCleanArchitectureMultitenancy(); +app.UseAuthentication(); +app.UseAuthorization(); +// Step 4: (End) Add multitenancy middleware between routing and auth +``` + ## Build Run `dotnet build -tl` to build the solution. diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs index ae84b0b..5387e37 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/DependencyInjection.cs @@ -3,6 +3,9 @@ using CleanArchitecture.Extensions.Multitenancy; using CleanArchitecture.Extensions.Multitenancy.Configuration; // Step 3: (End) Multitenancy configuration imports +// Step 4: (Begin) Multitenancy ASP.NET Core registration imports +using CleanArchitecture.Extensions.Multitenancy.AspNetCore; +// Step 4: (End) Multitenancy ASP.NET Core registration imports using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Application.Common.Interfaces; using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Web.Services; @@ -45,6 +48,11 @@ public static void AddWebServices(this IHostApplicationBuilder builder) }); // Step 3: (End) Configure multitenancy resolution defaults + // Step 4: (Begin) Register multitenancy services and ASP.NET Core adapter + builder.Services.AddCleanArchitectureMultitenancy(); + builder.Services.AddCleanArchitectureMultitenancyAspNetCore(autoUseMiddleware: false); + // Step 4: (End) Register multitenancy services and ASP.NET Core adapter + // Customise default API behaviour builder.Services.Configure(options => diff --git a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs index 4f94a76..42a683a 100644 --- a/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs +++ b/samples/CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution/src/Web/Program.cs @@ -1,3 +1,6 @@ +// Step 4: (Begin) Multitenancy middleware import +using CleanArchitecture.Extensions.Multitenancy.AspNetCore.Middleware; +// Step 4: (End) Multitenancy middleware import using CleanArchitecture.Extensions.Samples.Multitenancy.HeaderAndRouteResolution.Infrastructure.Data; var builder = WebApplication.CreateBuilder(args); @@ -34,6 +37,13 @@ app.UseExceptionHandler(options => { }); +// Step 4: (Begin) Add multitenancy middleware between routing and auth +app.UseRouting(); +app.UseCleanArchitectureMultitenancy(); +app.UseAuthentication(); +app.UseAuthorization(); +// Step 4: (End) Add multitenancy middleware between routing and auth + app.Map("/", () => Results.Redirect("/api")); app.MapEndpoints();