From fe8fad869487c71efe45006f8b5472fe69c44377 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Wed, 17 Jun 2026 20:01:52 +0800 Subject: [PATCH 01/18] restructure: relocate layered variant into layered/ subfolder Move the existing multi-project template into a self-contained layered/ folder in preparation for a second 'modular' variant. Repo-level files (README, LICENSE, CONTRIBUTING, docs/, .github) stay at the root and are shared across variants. Behavior preserving: layered/ builds, unit tests pass, the template packs and instantiates with database/migrations intact. The shared-database hoist to root and the .github CI rework are intentionally deferred to later steps. --- README.md | 615 +--------------- ...dalis-clean-architecture-vsa-transcript.md | 180 +++++ .../clean-architecture-and-vertical-slices.md | 159 +++++ .../.config}/dotnet-tools.json | 0 .editorconfig => layered/.editorconfig | 0 layered/.gitignore | 405 +++++++++++ .../.template.config}/template.json | 1 + AGENTS.md => layered/AGENTS.md | 0 .../CleanApiStarter.Template.csproj | 0 .../CleanApiStarter.slnx | 0 .../Directory.Build.props | 0 .../Directory.Packages.props | 0 layered/LICENSE | 674 ++++++++++++++++++ layered/README.md | 600 ++++++++++++++++ ...V001__create_projects_and_tasks_tables.sql | 0 .../V002__create_identity_tables.sql | 0 .../docker-compose.yml | 0 global.json => layered/global.json | 0 .../scripts}/install-template.sh | 0 {scripts => layered/scripts}/test-coverage.sh | 0 .../src}/CleanApiStarter.Api/Api.http | 0 .../CleanApiStarter.Api.csproj | 0 .../Endpoints/GoogleLoginPage.cs | 0 .../CleanApiStarter.Api/Endpoints/V1/Auth.cs | 0 .../Endpoints/V1/Projects.cs | 0 .../Endpoints/V2/Projects.cs | 0 .../src}/CleanApiStarter.Api/GlobalUsings.cs | 0 .../src}/CleanApiStarter.Api/Program.cs | 0 .../Properties/launchSettings.json | 0 .../Services/CurrentUser.cs | 0 .../appsettings.Development.json | 0 .../src}/CleanApiStarter.Api/appsettings.json | 0 .../CleanApiStarter.AppHost.csproj | 0 .../src}/CleanApiStarter.AppHost/Program.cs | 0 .../Properties/launchSettings.json | 0 .../CleanApiStarter.Application.csproj | 0 .../Common/Interfaces/IUser.cs | 0 .../Common/Models/ArrayResult.cs | 0 .../Common/Models/PaginatedQuery.cs | 0 .../Common/Models/PaginatedQueryValidator.cs | 0 .../Common/Models/PaginatedResult.cs | 0 .../DependencyInjection.cs | 0 .../Features/Auth/AuthTokenDto.cs | 0 .../Features/Auth/CurrentUserDto.cs | 0 .../Features/Auth/GoogleSignInDto.cs | 0 .../Features/Auth/GoogleSignInDtoValidator.cs | 0 .../Features/Auth/IAuthService.cs | 0 .../Projects/CreateProjectDtoValidator.cs | 0 .../Projects/CreateProjectTaskDtoValidator.cs | 0 .../Features/Projects/IProjectRepository.cs | 0 .../Features/Projects/IProjectService.cs | 0 .../Features/Projects/ProjectDto.cs | 0 .../Projects/ProjectOperationResults.cs | 0 .../Features/Projects/ProjectService.cs | 0 .../Features/Projects/ProjectTaskDto.cs | 0 .../Projects/UpdateProjectTaskDtoValidator.cs | 0 .../GlobalUsings.cs | 0 .../AspNetCoreDefaultServices.cs | 0 .../CleanApiStarter.AspNetCore.csproj | 0 .../CleanApiStarter.AspNetCore/Extensions.cs | 0 .../GlobalUsings.cs | 0 .../IEndpointGroup.cs | 0 .../OpenApiDocumentationExtensions.cs | 0 .../OpenTelemetryDefaults.cs | 0 .../ProblemDetailsExceptionHandler.cs | 0 .../RequestIdMiddleware.cs | 0 .../UserIdHttpLoggingInterceptor.cs | 0 .../ValidationFilter.cs | 0 .../WebApplicationExtensions.cs | 0 .../AppSettings.cs | 0 .../AuthenticationSettings.cs | 0 .../CleanApiStarter.Configuration.csproj | 0 .../ConnectionStringSettings.cs | 0 .../GlobalUsings.cs | 0 .../GoogleAuthenticationSettings.cs | 0 .../JwtAuthenticationSettings.cs | 0 .../OptionsRegistrationExtensions.cs | 0 .../CleanApiStarter.Domain.csproj | 0 .../Entities/Project.cs | 0 .../Entities/ProjectMember.cs | 0 .../Entities/ProjectTask.cs | 0 .../Entities/ProjectTaskStatus.cs | 0 .../CleanApiStarter.Infrastructure.csproj | 0 .../DependencyInjection.cs | 0 .../GlobalUsings.cs | 0 .../Identity/ApplicationUser.cs | 0 .../Identity/GoogleAuthService.cs | 0 .../Persistence/ApplicationDbContext.cs | 0 .../Configuration/ProjectConfiguration.cs | 0 .../ProjectMemberConfiguration.cs | 0 .../Configuration/ProjectTaskConfiguration.cs | 0 .../Repositories/ProjectRepository.cs | 0 ...leanApiStarter.Api.IntegrationTests.csproj | 0 .../Features/Projects/ProjectsTests.cs | 0 .../GlobalUsings.cs | 0 .../appsettings.Testing.json | 0 ...eanApiStarter.Application.UnitTests.csproj | 0 .../Features/Projects/ProjectServiceTests.cs | 0 .../GlobalUsings.cs | 0 .../CleanApiStarter.Tests.csproj | 0 .../Common/ApiApplicationFactory.cs | 0 .../Common/AutoNSubstituteDataAttribute.cs | 0 .../CleanApiStarter.Tests/GlobalUsings.cs | 0 103 files changed, 2056 insertions(+), 578 deletions(-) create mode 100644 docs/architecture/ardalis-clean-architecture-vsa-transcript.md create mode 100644 docs/architecture/clean-architecture-and-vertical-slices.md rename {.config => layered/.config}/dotnet-tools.json (100%) rename .editorconfig => layered/.editorconfig (100%) create mode 100644 layered/.gitignore rename {.template.config => layered/.template.config}/template.json (98%) rename AGENTS.md => layered/AGENTS.md (100%) rename CleanApiStarter.Template.csproj => layered/CleanApiStarter.Template.csproj (100%) rename CleanApiStarter.slnx => layered/CleanApiStarter.slnx (100%) rename Directory.Build.props => layered/Directory.Build.props (100%) rename Directory.Packages.props => layered/Directory.Packages.props (100%) create mode 100644 layered/LICENSE create mode 100644 layered/README.md rename {database => layered/database}/migrations/V001__create_projects_and_tasks_tables.sql (100%) rename {database => layered/database}/migrations/V002__create_identity_tables.sql (100%) rename docker-compose.yml => layered/docker-compose.yml (100%) rename global.json => layered/global.json (100%) rename {scripts => layered/scripts}/install-template.sh (100%) rename {scripts => layered/scripts}/test-coverage.sh (100%) rename {src => layered/src}/CleanApiStarter.Api/Api.http (100%) rename {src => layered/src}/CleanApiStarter.Api/CleanApiStarter.Api.csproj (100%) rename {src => layered/src}/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/Endpoints/V1/Auth.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/Endpoints/V1/Projects.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/Endpoints/V2/Projects.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/GlobalUsings.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/Program.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/Properties/launchSettings.json (100%) rename {src => layered/src}/CleanApiStarter.Api/Services/CurrentUser.cs (100%) rename {src => layered/src}/CleanApiStarter.Api/appsettings.Development.json (100%) rename {src => layered/src}/CleanApiStarter.Api/appsettings.json (100%) rename {src => layered/src}/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj (100%) rename {src => layered/src}/CleanApiStarter.AppHost/Program.cs (100%) rename {src => layered/src}/CleanApiStarter.AppHost/Properties/launchSettings.json (100%) rename {src => layered/src}/CleanApiStarter.Application/CleanApiStarter.Application.csproj (100%) rename {src => layered/src}/CleanApiStarter.Application/Common/Interfaces/IUser.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Common/Models/ArrayResult.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Common/Models/PaginatedResult.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/DependencyInjection.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Auth/IAuthService.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/IProjectService.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/ProjectDto.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/ProjectService.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs (100%) rename {src => layered/src}/CleanApiStarter.Application/GlobalUsings.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/Extensions.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/GlobalUsings.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/IEndpointGroup.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/ValidationFilter.cs (100%) rename {src => layered/src}/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/AppSettings.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/AuthenticationSettings.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj (100%) rename {src => layered/src}/CleanApiStarter.Configuration/ConnectionStringSettings.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/GlobalUsings.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs (100%) rename {src => layered/src}/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs (100%) rename {src => layered/src}/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj (100%) rename {src => layered/src}/CleanApiStarter.Domain/Entities/Project.cs (100%) rename {src => layered/src}/CleanApiStarter.Domain/Entities/ProjectMember.cs (100%) rename {src => layered/src}/CleanApiStarter.Domain/Entities/ProjectTask.cs (100%) rename {src => layered/src}/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/DependencyInjection.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/GlobalUsings.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs (100%) rename {src => layered/src}/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj (100%) rename {tests => layered/tests}/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json (100%) rename {tests => layered/tests}/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj (100%) rename {tests => layered/tests}/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Application.UnitTests/GlobalUsings.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj (100%) rename {tests => layered/tests}/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs (100%) rename {tests => layered/tests}/CleanApiStarter.Tests/GlobalUsings.cs (100%) diff --git a/README.md b/README.md index a8c68c4..67a652d 100644 --- a/README.md +++ b/README.md @@ -1,600 +1,59 @@ # CleanApiStarter -`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. +A Clean Architecture API starter for .NET 10, shipped as two `dotnet new` +template variants so you can pick the structure that fits the project — without +giving up enforced dependency boundaries in either. -The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. +| Variant | Folder | `dotnet new` | Shape | Boundaries enforced by | +| --- | --- | --- | --- | --- | +| **Layered** | [`layered/`](layered/) | `clean-api-layered` | Multi-project (Api, Application, Domain, Infrastructure, …) | Project references (compiler) | +| **Modular** | `modular/` *(in progress)* | `clean-api-modular` | One business project + reused platform projects | NsDepCop analyzer (build-breaking) | -## Features +Both are genuine Clean Architectures — they differ in feature organization and in +*how* boundaries are enforced, not in whether dependencies point inward. See +[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) +for the reasoning behind the two variants, including how this relates to Vertical +Slice Architecture and the modular monolith. -- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. -- Minimal APIs organized by endpoint group and API version folders. -- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. -- Versioned OpenAPI documents shown with Scalar. -- Google ID token sign-in that issues the API's own JWT. -- ASP.NET Core Identity for local users, roles, and external login storage. -- Real JWT bearer authentication for protected APIs. -- EF Core with PostgreSQL for application data and Identity storage. -- PostgreSQL local development through Aspire or Docker Compose. -- Database schema scripts under `database/migrations`. -- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. -- HTTP request logging with authenticated user id. -- `X-Request-ID` response header containing the current trace id. -- Problem Details exception responses. -- Response compression with Brotli and gzip. -- FluentValidation endpoint validation returning `422 Unprocessable Entity`. -- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. -- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. -- xUnit v3 API integration tests with Testcontainers for PostgreSQL. -- Local coverage script that generates an HTML report with ReportGenerator. -- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. +## Which one should I use? -## Solution Layout +- **Layered** — when you want hard, physical boundaries and don't mind the + ceremony of multiple projects. Violations literally don't compile. +- **Modular** — for MVPs, smaller services, or teams that want feature-centric + organization and less ceremony, with boundaries still enforced at build time. -```text -CleanApiStarter -├── database -│ └── migrations -├── scripts -├── src -│ ├── CleanApiStarter.Api -│ ├── CleanApiStarter.Application -│ ├── CleanApiStarter.Domain -│ ├── CleanApiStarter.Infrastructure -│ └── Common -│ ├── CleanApiStarter.AppHost -│ ├── CleanApiStarter.AspNetCore -│ └── CleanApiStarter.Configuration -└── tests - ├── CleanApiStarter.Api.IntegrationTests - ├── CleanApiStarter.Application.UnitTests - └── CleanApiStarter.Tests -``` - -Layer dependencies point inward: - -- `Domain` references nothing. -- `Application` references `Domain`. -- `Infrastructure` references `Application` and `Configuration`. -- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- `Configuration` contains plain settings classes and options registration. -- `AspNetCore` contains reusable web/runtime defaults. -- `AppHost` contains Aspire orchestration. - -## Requirements - -- .NET SDK `10.0.203` or compatible latest feature SDK -- Docker Desktop -- Google Chrome, only for the local coverage script opening the HTML report - -The SDK is pinned in `global.json`: - -```json -{ - "sdk": { - "version": "10.0.203", - "rollForward": "latestFeature" - } -} -``` - -## Use As A Template - -Install the template package from NuGet: - -```bash -dotnet new install CleanApiStarter.Template -``` - -Create a new solution: - -```bash -dotnet new clean-api-starter -n MyProduct -``` - -The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. - -While developing the template locally, run: - -```bash -scripts/install-template.sh -``` - -That script: - -- uninstalls the previous local template package -- packs the current repo with `CleanApiStarter.Template.csproj` -- installs the generated local `.nupkg` -- deletes the temporary package from `artifacts` - -Then create a local test solution: - -```bash -dotnet new clean-api-starter -n DemoProduct -``` - -## Run With Aspire - -```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj -``` - -Aspire starts: - -- the API -- a PostgreSQL container using `postgres:latest` -- pgAdmin -- the Aspire dashboard - -Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. - -The AppHost configures PostgreSQL like this: - -- server resource: `postgres-server` -- database resource: `postgres` -- data volume: `clean-api-starter-postgres-data` -- volume mount: `/var/lib/postgresql` -- init scripts: `database/migrations` - -PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. - -## Run With Docker Compose - -If you want only PostgreSQL without Aspire: - -```bash -docker compose up -d -dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. - -## Database - -Database scripts live here: - -```text -database/migrations -├── V001__create_projects_and_tasks_tables.sql -└── V002__create_identity_tables.sql -``` - -The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. - -`ApplicationDbContext` lives in: - -```text -src/CleanApiStarter.Infrastructure/Persistence -``` - -EF Core mappings live in: - -```text -src/CleanApiStarter.Infrastructure/Persistence/Configuration -``` - -## API Style - -The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. - -Endpoint groups live under version folders: - -```text -src/CleanApiStarter.Api/Endpoints/V1 -src/CleanApiStarter.Api/Endpoints/V2 -``` - -Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: - -```csharp -app.MapEndpoints(Assembly.GetExecutingAssembly()); -``` - -Endpoint names are globally unique across versions, for example: - -```text -GetProjectsV1 -GetProjectsV2 -``` - -## API Versioning - -API versioning uses header-based version selection: - -```http -X-Api-Version: 1.0 -``` - -The header is optional. Requests without `X-Api-Version` use v1. - -The project uses the .NET 10 API versioning/OpenAPI package setup: - -- `Asp.Versioning.Http` -- `Asp.Versioning.Mvc.ApiExplorer` -- `Asp.Versioning.OpenApi` -- `Microsoft.AspNetCore.OpenApi` - -OpenAPI documents are generated per version and displayed in Scalar. - -## Scalar - -This project uses Scalar instead of Swagger/Swashbuckle. - -When running in Development, OpenAPI and Scalar are mapped by the API: - -- versioned OpenAPI documents -- Scalar API reference with version selection - -## Authentication - -Authentication is API-first. - -The intended production flow is: - -1. A client obtains a Google ID token. -2. The client sends it to `POST /api/auth/google`. -3. The API validates the Google ID token. -4. The API creates or updates the local ASP.NET Core Identity user. -5. The API issues its own JWT. -6. Protected API calls use: - -```http -Authorization: Bearer -``` - -The API does not use cookies as the default authentication mechanism. - -### Google Client ID - -Create an OAuth client in Google Cloud Console and set the client id with user secrets: - -```bash -dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -For local browser testing, open the development helper page: - -```text -https://localhost:7285/auth/google-login -``` - -The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. - -## Authorization - -Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. - -Project authorization rules are implemented in the application service and repository queries: - -- users can only see projects they belong to -- users can only see and manage tasks inside projects they belong to -- project deletion is owner-only - -## Application Features - -The reference feature is project and task management. - -Projects: - -- create project -- list current user's projects -- get project by id -- delete project as owner - -Tasks: - -- create task under a project -- list tasks with `limit` and `offset` -- filter tasks by status -- get task by id -- update task -- complete task -- delete task - -The application project is organized by feature: - -```text -src/CleanApiStarter.Application/Features/Auth -src/CleanApiStarter.Application/Features/Projects -``` - -Cross-cutting abstractions live under: - -```text -src/CleanApiStarter.Application/Common -``` - -## Response Shapes - -Single-resource endpoints return the resource DTO directly. - -Paginated endpoints return `PaginatedResult`: - -```json -{ - "items": [], - "limit": 20, - "offset": 0, - "totalCount": 0, - "hasPreviousPage": false, - "hasNextPage": false -} -``` - -Non-paginated collection endpoints should return `ArrayResult`: - -```json -{ - "items": [], - "count": 0 -} -``` - -## Validation - -Request validation uses FluentValidation, not DataAnnotations. - -Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. - -Validation failures return: - -```http -422 Unprocessable Entity -``` - -## Configuration - -Configuration classes live in `CleanApiStarter.Configuration`. - -The root configuration object is: - -```csharp -AppSettings -``` - -It includes: - -- `ConnectionStrings.Postgres` -- `Authentication.Google.ClientId` -- `Authentication.Jwt.Issuer` -- `Authentication.Jwt.Audience` -- `Authentication.Jwt.SigningKey` -- `Authentication.Jwt.ExpirationMinutes` - -The API registers settings once: - -```csharp -builder.Services.AddAppSettings(builder.Configuration); -``` - -Services can inject `AppSettings` directly. - -## Observability - -`CleanApiStarter.AspNetCore` centralizes runtime defaults: - -- OpenTelemetry traces -- OpenTelemetry metrics -- OpenTelemetry logs -- OTLP export for Aspire -- structured log formatting -- HTTP request logging -- health checks -- service discovery -- HTTP client resilience -- problem details -- response compression -- request id header -- Kestrel `Server` header removal - -When the API runs under Aspire, inspect the dashboard for: - -- API request traces -- Npgsql database traces -- structured logs -- request logs with `UserId` -- runtime metrics -- resource health - -Every response includes: - -```http -X-Request-ID: -``` - -Use this value to correlate client responses with logs and traces. - -## Health And Version Endpoints - -The shared ASP.NET Core defaults map: - -```text -/health -/alive -/version -``` - -`/version` exposes the running application version. - -## Testing - -### Unit Tests - -Application unit tests use: - -- xUnit v3 -- AutoFixture.xUnit3 -- AutoFixture.AutoNSubstitute -- NSubstitute -- Shouldly - -Common test helpers live in: - -```text -tests/CleanApiStarter.Tests -``` - -Current reusable helpers: - -- `AutoNSubstituteDataAttribute` -- `ApiApplicationFactory` - -Test names follow: - -```text -UnitOfWork_StateUnderTest_ExpectedBehavior -``` - -Tests use AAA sections: - -```csharp -// Arrange -// Act -// Assert -``` +## Getting started -Run unit tests: +Each variant is a self-contained solution. Pick one and read its README: ```bash -dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +# Layered (available now) +cd layered +dotnet build CleanApiStarter.slnx ``` -### API Integration Tests - -API integration tests use: - -- xUnit v3 -- Microsoft.AspNetCore.Mvc.Testing -- Testcontainers for PostgreSQL -- Shouldly - -The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. - -The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. - -Run integration tests: - -```bash -dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj -``` - -Run all tests: - -```bash -dotnet test CleanApiStarter.slnx -``` - -## Coverage - -Generate coverage and open the HTML report in Chrome: - -```bash -scripts/test-coverage.sh -``` - -The script: - -- deletes the previous `artifacts/coverage` folder -- restores local .NET tools -- runs tests with `XPlat Code Coverage` -- generates an HTML report with ReportGenerator -- opens the report in Google Chrome - -Coverage output: - -```text -artifacts/coverage/report/index.html -``` - -## Build - -Restore and build: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -## CI - -GitHub Actions workflows live in `.github/workflows`: - -- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. -- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. -- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. -- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. - -## Package Management - -Package versions are managed centrally in: - -```text -Directory.Packages.props -``` - -Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. - -## Coding Conventions - -- Use explicit local types. -- Use project-level `GlobalUsings.cs`. -- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. -- Use `required` and `init` for required non-null DTO and domain properties. -- Use nullable types only for genuinely optional values. -- Use structured logging message templates instead of interpolated log strings. -- Keep repository interfaces in `Application`. -- Keep repository implementations and EF Core details in `Infrastructure`. -- Keep domain models persistence-agnostic. -- Do not reintroduce Dapper for the default data access path. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. -- Do not add Swashbuckle; use Scalar. - -## Troubleshooting - -### Aspire certificate errors - -If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: +To generate a new project from a variant once the templates are published: ```bash -dotnet dev-certs https --check --trust +dotnet new clean-api-layered -n MyApi +# dotnet new clean-api-modular -n MyApi (coming soon) ``` -If needed, reset and trust again: +## Repository layout -```bash -dotnet dev-certs https --clean -dotnet dev-certs https --trust -dotnet dev-certs https --check --trust ``` - -On macOS, accept the Keychain prompt and enter your password if requested. - -### PostgreSQL init scripts did not run - -Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. - -For Docker Compose: - -```bash -docker compose down -v -docker compose up -d +clean-api-starter/ +├── docs/architecture/ ← reasoning: Clean Architecture, vertical slices, duplication +├── layered/ ← variant 1: multi-project template (self-contained) +├── modular/ ← variant 2: single-project + NsDepCop template (in progress) +├── CONTRIBUTING.md +└── LICENSE ``` -For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. - -### Postgres latest and data volumes - -This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: - -```text -/var/lib/postgresql -``` - -Do not mount the volume at: - -```text -/var/lib/postgresql/data -``` +Repo-level files (this README, `LICENSE`, `CONTRIBUTING.md`, `docs/`, CI) live at +the root and are shared across variants. Everything a generated project needs is +inside the variant folder. -## License +## Contributing -This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/architecture/ardalis-clean-architecture-vsa-transcript.md b/docs/architecture/ardalis-clean-architecture-vsa-transcript.md new file mode 100644 index 0000000..dea55bd --- /dev/null +++ b/docs/architecture/ardalis-clean-architecture-vsa-transcript.md @@ -0,0 +1,180 @@ +# Transcript — Ardalis on Clean Architecture vs. Vertical Slices + +> **Attribution / source.** This is a lightly edited transcript of a talk by +> **Steve Smith (Ardalis)** responding to a video by Nick Chapsas about .NET +> practices he no longer recommends. It is reproduced here only as a reference +> for the architectural decisions recorded in +> [`clean-architecture-and-vertical-slices.md`](clean-architecture-and-vertical-slices.md). +> +> The text was auto-transcribed and then lightly cleaned for readability; +> wording is approximate and may contain transcription errors. **Prefer linking +> to the original video** rather than redistributing this transcript — if this +> repository is public, consider replacing the body below with a link and a short +> summary to respect the author's copyright. + +--- + +## On the "six-project solution" criticism + +I recently watched a video from Nick Chapsas about software practices he used to +recommend for .NET developers that he doesn't really use anymore, and one of the +first topics was Clean Architecture. I actually agree with a lot of what Nick +said — especially that a giant six-project solution is probably not the right +default for many applications today. (Granted, Microsoft is responsible for a +third of those projects now, due to Aspire.) A six-project solution is not a +prerequisite for good architecture — which is slightly awkward for me to admit, +since I maintain one of the more popular Clean Architecture templates in the .NET +ecosystem. More than one, it turns out. + +The industry has evolved, technology has evolved, and that's a good thing. + +## Clean Architecture and Vertical Slices are not opposites + +One thing I want to clarify: **Vertical Slice Architecture and Clean Architecture +are not opposites. They solve different problems.** Many people confuse Clean +Architecture with a specific folder structure from around 2018. Those are not the +same thing. + +To me, Clean Architecture is fundamentally about **dependency direction**. It's +about protecting your business logic from external dependencies, isolating +infrastructure, encapsulation, and enforcing boundaries. It is *not* about +whether you have one, two, or twelve projects. + +And separately from that, **you should build in vertical slices.** I've been +advocating for feature-based organization for over a decade — I wrote about +vertical slices and vertical features for ASP.NET on DevIQ around 2015, and in an +MSDN magazine article around 2016, a couple of years before Jimmy Bogard's +vertical slice architecture article. + +So when Nick says applications are easier to understand when organized around +features — yes, absolutely. If I need to add a feature related to orders, I should +navigate to the orders feature set and add it there, with little need to look +elsewhere. That part is not controversial. + +## Where older layered architectures went wrong + +Early layered architectures put *all* data access in one layer, *all* business +logic in another, and the UI in a third. With MVC, the UI layer also had root +folders for controllers, models, view models, and views. So every time you wanted +to add a feature, you had to add files in every one of these layers and folders. +The constant scrolling around the directory structure added a lot of unnecessary +friction. That's the whole reason many of us in the .NET community started +recommending vertical slices and feature folders. + +It didn't help that the .NET community decided over time that every application +needed a dozen or more projects. We may have gone a little overboard. Some clients +had solutions containing *hundreds* of projects; we've worked to bring those +numbers down to something more sane. + +## The duplication myth in "pure" vertical slices + +Nick talks about vertical slice architecture as a great alternative to Clean +Architecture, but never shows what his preferred architecture actually looks like. +I looked at the second most popular VSA template on GitHub. Everyone touts that +vertical slices are great because you have everything you need in one folder and +never bounce around. Is that really true? + +There's a root-level `features` folder with, say, heroes and teams, and inside an +endpoint you'll find the records, the endpoint, the handler, a validator, and a +summary — all together. Great, the endpoint-specific types are in one file. + +But what *isn't* in there? The actual logic — adding the hero, saving changes for +persistence — where does that live? In a `common` folder, which contains all the +usual Clean Architecture folders: domain, persistence, infrastructure. To see how +"add hero" works, you go into the team aggregate where the domain logic lives. + +So it's really not that different structurally. In this template, the only thing +centralized *as a feature* are the types needed for an endpoint — not the business +logic, not the persistence, not other infrastructure. If you add a feature that +touches a new domain entity or changes how it's stored, you'll need to bounce +around to those other folders. That's fine — that's just how things work. + +**Understand that in most cases, even when people talk about vertical slices, +they're mostly only talking about the UI layer.** Very few developers or +architects suggest you put *all* the business logic and persistence separately in +each feature folder. Not everything belongs duplicated into every feature folder +forever. Some abstractions are useful. Sharing domain concepts instead of +duplicating them is helpful. Reusing consistent infrastructure — like how you +configure your DbContext — makes sense, instead of having a DbContext in every +feature folder. + +## The right framing: three separate decisions + +This is why the conversation shouldn't be framed as "vertical slices *or* Clean +Architecture," as if they're against each other. That's the wrong framing. +**Feature organization, code reuse, and dependency management are separate aspects +of your architectural choices:** + +- **Feature organization** answers *"where do I find the code?"* +- **Code reuse** ensures the logic in your application is consistent between + features. +- **Dependency management** is about deciding what is allowed to depend on what. + +These are all valid things to optimize. + +## Why "minimal clean" exists, and enforcing rules without projects + +I created the minimal Clean Architecture template because I agreed with a lot of +the criticisms of classic Clean Architecture over the years. Giant multi-repo / +multi-project solutions became cumbersome — too much ceremony, too much project +hopping. I also figured out a way to **still enforce the rules of dependency +management without having separate projects.** + +Nick makes the point that, especially with AI, having locality of files helps the +AI discover things. Maybe — they have tools like ripgrep to find anything. But +**consistency really matters for LLM/agent-driven development**: a consistent way +things are organized and behavior is applied matters a lot. And humans naturally +do better when related things are contained together too. Many practices people +are discovering for making AI write better code turn out to be the same things +that were great for human developers. + +So minimal clean keeps the benefits I still cared about — dependency inversion, +isolation of infrastructure, proper encapsulation, testability — but is much +simpler, with far less ceremony and fewer projects. With Aspire it's about three +projects, but just one for the application, which makes it simpler to navigate and +allows a more feature-centric organization. There's just one web project, and at +the top you immediately see the features (e.g. cart features, product features), +and inside each you see vertical slices for each endpoint. + +## Toward modular monoliths + +I was also trying to solve another problem: proper **modules**. Most ASP.NET Core +solutions lack them, partly because the traditional Clean Architecture layout +doesn't map well to real modules — unless you're building microservices, where +each application *is* the module. + +But most teams don't need microservices. Many reach for them to solve +*organizational* problems more than technical ones, and many who justify them +technically really just needed **modularity with real boundaries** and couldn't +figure out how to get that without putting a network hop between modules. If you +need real modules, having only `core`, `infrastructure`, and `web` projects +doesn't cut it. Real modules require autonomy, encapsulation, and clearly defined +public contracts. A giant shared core project and a big infrastructure project +provide no boundaries between modules — they exist to fix the *dependency* +problem, which is the point of Clean Architecture, not the *modularity* problem. + +So with minimal clean I didn't move away from boundaries — I moved toward +**module boundaries**. These days, for most serious business applications, my +go-to is a **modular monolith**: not distributed microservices, not a giant shared +layered monolith, but a modular system organized around business capabilities +(books, email, orders, reports, user management) at the top level. To work on +reporting, you stay in the reporting module. You get clear contracts, explicit +module boundaries, and strong encapsulation that keeps modules independent — all +still organized around features and built in vertical slices. These ideas +absolutely coexist. + +This gets you many of the benefits people wanted from microservices without the +operational tax of a distributed system. The minimal clean template works great as +a single module in this style. You get feature-centric organization top to bottom, +pragmatic boundaries, strong modularity, and less ceremony — and you can scale up +to different teams owning different modules, with fewer accidental distributed +systems. + +## Closing + +I appreciated Nick's video — he's reacting against a very real problem. But the +answer isn't to throw away architectural boundaries and rules; it's to use +*better* boundaries. Microservices have lost some luster, but the need for +modularity remains. And keeping more of the system in a monorepo makes it much +easier for AI agents to work on effectively — which further argues for modular +monoliths over a microservices-first approach. diff --git a/docs/architecture/clean-architecture-and-vertical-slices.md b/docs/architecture/clean-architecture-and-vertical-slices.md new file mode 100644 index 0000000..ed09d87 --- /dev/null +++ b/docs/architecture/clean-architecture-and-vertical-slices.md @@ -0,0 +1,159 @@ +# Clean Architecture, Vertical Slices, and Duplication + +This document records the architectural reasoning behind CleanApiStarter and, in +particular, why the template ships in two flavors (`layered` and `modular`). It +exists so that contributors and template users understand *why* the structure is +the way it is, not just *what* it is. + +The thinking here was heavily shaped by a talk from Steve Smith (Ardalis) +responding to the "is Clean Architecture dead?" debate. A cleaned-up transcript +of that talk is kept alongside this file in +[`ardalis-clean-architecture-vsa-transcript.md`](ardalis-clean-architecture-vsa-transcript.md). + +## The core idea: three independent decisions + +The single most useful reframing is that "architecture" is not one choice. It is +at least three *separate, orthogonal* decisions, and most online arguments +conflate them: + +1. **Feature organization** — *"Where do I find the code?"* + Do related files for one capability live together, or are they scattered + across `Controllers/`, `Services/`, `Repositories/` folders? + +2. **Code reuse** — *"Is logic consistent across features?"* + When two features need the same rule or the same infrastructure, do they + share one implementation or each carry their own? + +3. **Dependency management** — *"What is allowed to depend on what?"* + Which directions of dependency are legal, and how is that enforced? + +Vertical Slice Architecture (VSA) is primarily an answer to **#1**. +Clean Architecture is primarily an answer to **#3**. They are not opposites and +they are not mutually exclusive — they answer different questions. You can, and +generally should, do both. + +## What Clean Architecture actually is + +Clean Architecture is **about dependency direction**, not about a project count +or a specific 2018-era folder structure. Its essential rules: + +- Protect business logic from external concerns (UI, DB, frameworks). +- Isolate infrastructure behind abstractions. +- Make dependencies point *inward* — outer layers depend on inner layers, never + the reverse. + +That is the whole of it. "Six projects" is one *implementation* of that rule, not +the rule itself. A single project can be a Clean Architecture as long as the +dependency direction is preserved and enforced. + +## What Vertical Slices actually are (and the duplication myth) + +A "vertical slice" groups everything needed for one operation together — for a +"create project" endpoint, that's the route, request/response DTOs, validation, +and the handler logic in one place — so adding or changing a feature means +working in one location instead of scrolling through parallel `Controllers/`, +`Services/`, `Models/` folders. + +The common misconception is that VSA means **"duplicate everything into every +feature folder."** It does not, and almost no real-world VSA codebase does this. +Even popular VSA templates centralize the domain model, persistence, and +infrastructure in a shared/common area; only the *endpoint-layer* types live in +the slice. In practice, "vertical slices" usually means *the UI/endpoint layer is +organized by feature* — not that business rules and persistence are copied per +feature. + +> Not everything belongs duplicated into every feature folder forever. Some +> abstractions are useful. Sharing domain concepts instead of duplicating them is +> helpful. Reusing consistent infrastructure (e.g. one DbContext) makes sense. + +## Duplication: the practical rule + +"Is duplication okay?" is a **code-reuse** decision (#2) and the answer is +*"yes for some things, no for others."* The line that has served us well: + +| Keep per-slice (duplication is fine, often preferred) | Share (duplication is a bug) | +| --- | --- | +| Request / response DTOs | Domain entities and their invariants | +| Validators | The DbContext and EF configuration | +| Handler / orchestration logic | Cross-cutting infrastructure (auth, logging) | +| Query shapes and projections | Genuinely shared business rules | + +Why per-slice duplication is *good*: forcing two features through one shared +service couples them, so a change for feature A can break feature B (the "ripple +effect"). Two near-identical handlers that evolve independently are cheaper than +one shared abstraction fighting both callers. This is the classic *"prefer +duplication over the wrong abstraction"* rule — but scoped to slice-level glue. + +Why domain/infrastructure duplication is *bad*: if a business rule (e.g. "a +task's `CompletedAt` is set when its status becomes `Done`") is copied into +multiple handlers, the copies drift out of sync. That rule belongs on the entity, +defined once. + +## How the boundaries are enforced + +Dependency rules are only real if something *fails the build* when they are +broken. The two variants enforce the same rule with different mechanisms: + +- **`layered` (multi-project):** enforced by **project references**. `Domain` + has no reference to `Infrastructure`, so a violating `using` simply does not + compile. This is the strongest possible enforcement and comes "for free" from + the project graph — at the cost of more projects and more ceremony. + +- **`modular` (single app project):** enforced by **[NsDepCop](https://github.com/realvizu/NsDepCop)**, + a Roslyn analyzer that polices *namespace* dependencies at compile time. A + declarative `config.nsdepcop` lists illegal directions, and + `NSDEPCOP01` in the csproj turns a + violation into a build-breaking error. This recreates the layered guarantee + inside one project. The ruleset is default-allow, then blacklists the few + inward-violating directions: + + ```xml + + + ``` + +## The two variants in this repo + +| | `layered` | `modular` | +| --- | --- | --- | +| Shape | Multi-project (Api, Application, Domain, Infrastructure, …) | One business project (`Api`) + reused platform projects (`AspNetCore`, `Configuration`) + `AppHost` | +| Feature organization | Feature folders inside layered projects | Vertical slices in `Features/` | +| Enforcement | Project references (compiler) | NsDepCop (analyzer, build-breaking) | +| Best for | Teams who want hard, physical boundaries | MVPs, smaller apps, less ceremony, feature-centric work | + +Both are legitimate Clean Architectures — they differ in feature organization and +enforcement mechanism, not in whether they respect dependency direction. + +## Where this is heading: the modular monolith + +The natural destination of "fewer projects + feature-centric + enforced +boundaries" is the **modular monolith**: top-level folders organized by *business +capability* (not by technical layer), each module owning its own slices, domain, +and persistence, exposing an explicit public contract, with a shared kernel only +for genuinely shared concepts. NsDepCop then enforces both the inward dependency +rule *and* inter-module isolation (e.g. `Modules.Projects.* ✗→ +Modules.Billing.Internal.*`). + +This gives much of the modularity and independence people reach microservices for +— autonomy, encapsulation, clear contracts — without the operational tax of a +distributed system. The `modular` variant is intentionally a stepping stone in +this direction: its `Features/` layout can grow into `Modules/` over time. + +## Summary + +- Clean Architecture = dependency direction. Vertical Slices = feature + organization. Code reuse = a third, separate decision. Don't conflate them. +- Project count is an implementation detail, not an architectural principle. +- Duplicate slice-level glue freely; never duplicate domain rules or + infrastructure. +- Enforce boundaries with something that breaks the build — project references + (`layered`) or NsDepCop (`modular`). +- The modular monolith is the long-term target for serious business apps. + +## Source + +Steve Smith (Ardalis), talk on Clean Architecture vs. Vertical Slice +Architecture (responding to Nick Chapsas). See the transcript in +[`ardalis-clean-architecture-vsa-transcript.md`](ardalis-clean-architecture-vsa-transcript.md). +Prefer linking to the original video over redistributing the transcript; see the +attribution note in that file. diff --git a/.config/dotnet-tools.json b/layered/.config/dotnet-tools.json similarity index 100% rename from .config/dotnet-tools.json rename to layered/.config/dotnet-tools.json diff --git a/.editorconfig b/layered/.editorconfig similarity index 100% rename from .editorconfig rename to layered/.editorconfig diff --git a/layered/.gitignore b/layered/.gitignore new file mode 100644 index 0000000..0e2fcbc --- /dev/null +++ b/layered/.gitignore @@ -0,0 +1,405 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +.DS_Store + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[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 Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# 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 + +# JetBrains IDE and Junie local assistant files +.idea/ +.junie/ + +# 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 +.DS_Store diff --git a/.template.config/template.json b/layered/.template.config/template.json similarity index 98% rename from .template.config/template.json rename to layered/.template.config/template.json index 970f2ba..b54a2e3 100644 --- a/.template.config/template.json +++ b/layered/.template.config/template.json @@ -43,6 +43,7 @@ ".github/workflows/template.yml", "CleanApiStarter.Template.csproj", "CONTRIBUTING.md", + "docs/**", "scripts/install-template.sh" ] } diff --git a/AGENTS.md b/layered/AGENTS.md similarity index 100% rename from AGENTS.md rename to layered/AGENTS.md diff --git a/CleanApiStarter.Template.csproj b/layered/CleanApiStarter.Template.csproj similarity index 100% rename from CleanApiStarter.Template.csproj rename to layered/CleanApiStarter.Template.csproj diff --git a/CleanApiStarter.slnx b/layered/CleanApiStarter.slnx similarity index 100% rename from CleanApiStarter.slnx rename to layered/CleanApiStarter.slnx diff --git a/Directory.Build.props b/layered/Directory.Build.props similarity index 100% rename from Directory.Build.props rename to layered/Directory.Build.props diff --git a/Directory.Packages.props b/layered/Directory.Packages.props similarity index 100% rename from Directory.Packages.props rename to layered/Directory.Packages.props diff --git a/layered/LICENSE b/layered/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/layered/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/layered/README.md b/layered/README.md new file mode 100644 index 0000000..a8c68c4 --- /dev/null +++ b/layered/README.md @@ -0,0 +1,600 @@ +# CleanApiStarter + +`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. + +The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. + +## Features + +- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. +- Minimal APIs organized by endpoint group and API version folders. +- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. +- Versioned OpenAPI documents shown with Scalar. +- Google ID token sign-in that issues the API's own JWT. +- ASP.NET Core Identity for local users, roles, and external login storage. +- Real JWT bearer authentication for protected APIs. +- EF Core with PostgreSQL for application data and Identity storage. +- PostgreSQL local development through Aspire or Docker Compose. +- Database schema scripts under `database/migrations`. +- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. +- HTTP request logging with authenticated user id. +- `X-Request-ID` response header containing the current trace id. +- Problem Details exception responses. +- Response compression with Brotli and gzip. +- FluentValidation endpoint validation returning `422 Unprocessable Entity`. +- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. +- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. +- xUnit v3 API integration tests with Testcontainers for PostgreSQL. +- Local coverage script that generates an HTML report with ReportGenerator. +- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. + +## Solution Layout + +```text +CleanApiStarter +├── database +│ └── migrations +├── scripts +├── src +│ ├── CleanApiStarter.Api +│ ├── CleanApiStarter.Application +│ ├── CleanApiStarter.Domain +│ ├── CleanApiStarter.Infrastructure +│ └── Common +│ ├── CleanApiStarter.AppHost +│ ├── CleanApiStarter.AspNetCore +│ └── CleanApiStarter.Configuration +└── tests + ├── CleanApiStarter.Api.IntegrationTests + ├── CleanApiStarter.Application.UnitTests + └── CleanApiStarter.Tests +``` + +Layer dependencies point inward: + +- `Domain` references nothing. +- `Application` references `Domain`. +- `Infrastructure` references `Application` and `Configuration`. +- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. +- `Configuration` contains plain settings classes and options registration. +- `AspNetCore` contains reusable web/runtime defaults. +- `AppHost` contains Aspire orchestration. + +## Requirements + +- .NET SDK `10.0.203` or compatible latest feature SDK +- Docker Desktop +- Google Chrome, only for the local coverage script opening the HTML report + +The SDK is pinned in `global.json`: + +```json +{ + "sdk": { + "version": "10.0.203", + "rollForward": "latestFeature" + } +} +``` + +## Use As A Template + +Install the template package from NuGet: + +```bash +dotnet new install CleanApiStarter.Template +``` + +Create a new solution: + +```bash +dotnet new clean-api-starter -n MyProduct +``` + +The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. + +While developing the template locally, run: + +```bash +scripts/install-template.sh +``` + +That script: + +- uninstalls the previous local template package +- packs the current repo with `CleanApiStarter.Template.csproj` +- installs the generated local `.nupkg` +- deletes the temporary package from `artifacts` + +Then create a local test solution: + +```bash +dotnet new clean-api-starter -n DemoProduct +``` + +## Run With Aspire + +```bash +dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +``` + +Aspire starts: + +- the API +- a PostgreSQL container using `postgres:latest` +- pgAdmin +- the Aspire dashboard + +Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. + +The AppHost configures PostgreSQL like this: + +- server resource: `postgres-server` +- database resource: `postgres` +- data volume: `clean-api-starter-postgres-data` +- volume mount: `/var/lib/postgresql` +- init scripts: `database/migrations` + +PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. + +## Run With Docker Compose + +If you want only PostgreSQL without Aspire: + +```bash +docker compose up -d +dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. + +## Database + +Database scripts live here: + +```text +database/migrations +├── V001__create_projects_and_tasks_tables.sql +└── V002__create_identity_tables.sql +``` + +The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. + +`ApplicationDbContext` lives in: + +```text +src/CleanApiStarter.Infrastructure/Persistence +``` + +EF Core mappings live in: + +```text +src/CleanApiStarter.Infrastructure/Persistence/Configuration +``` + +## API Style + +The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. + +Endpoint groups live under version folders: + +```text +src/CleanApiStarter.Api/Endpoints/V1 +src/CleanApiStarter.Api/Endpoints/V2 +``` + +Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: + +```csharp +app.MapEndpoints(Assembly.GetExecutingAssembly()); +``` + +Endpoint names are globally unique across versions, for example: + +```text +GetProjectsV1 +GetProjectsV2 +``` + +## API Versioning + +API versioning uses header-based version selection: + +```http +X-Api-Version: 1.0 +``` + +The header is optional. Requests without `X-Api-Version` use v1. + +The project uses the .NET 10 API versioning/OpenAPI package setup: + +- `Asp.Versioning.Http` +- `Asp.Versioning.Mvc.ApiExplorer` +- `Asp.Versioning.OpenApi` +- `Microsoft.AspNetCore.OpenApi` + +OpenAPI documents are generated per version and displayed in Scalar. + +## Scalar + +This project uses Scalar instead of Swagger/Swashbuckle. + +When running in Development, OpenAPI and Scalar are mapped by the API: + +- versioned OpenAPI documents +- Scalar API reference with version selection + +## Authentication + +Authentication is API-first. + +The intended production flow is: + +1. A client obtains a Google ID token. +2. The client sends it to `POST /api/auth/google`. +3. The API validates the Google ID token. +4. The API creates or updates the local ASP.NET Core Identity user. +5. The API issues its own JWT. +6. Protected API calls use: + +```http +Authorization: Bearer +``` + +The API does not use cookies as the default authentication mechanism. + +### Google Client ID + +Create an OAuth client in Google Cloud Console and set the client id with user secrets: + +```bash +dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +For local browser testing, open the development helper page: + +```text +https://localhost:7285/auth/google-login +``` + +The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. + +## Authorization + +Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. + +Project authorization rules are implemented in the application service and repository queries: + +- users can only see projects they belong to +- users can only see and manage tasks inside projects they belong to +- project deletion is owner-only + +## Application Features + +The reference feature is project and task management. + +Projects: + +- create project +- list current user's projects +- get project by id +- delete project as owner + +Tasks: + +- create task under a project +- list tasks with `limit` and `offset` +- filter tasks by status +- get task by id +- update task +- complete task +- delete task + +The application project is organized by feature: + +```text +src/CleanApiStarter.Application/Features/Auth +src/CleanApiStarter.Application/Features/Projects +``` + +Cross-cutting abstractions live under: + +```text +src/CleanApiStarter.Application/Common +``` + +## Response Shapes + +Single-resource endpoints return the resource DTO directly. + +Paginated endpoints return `PaginatedResult`: + +```json +{ + "items": [], + "limit": 20, + "offset": 0, + "totalCount": 0, + "hasPreviousPage": false, + "hasNextPage": false +} +``` + +Non-paginated collection endpoints should return `ArrayResult`: + +```json +{ + "items": [], + "count": 0 +} +``` + +## Validation + +Request validation uses FluentValidation, not DataAnnotations. + +Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. + +Validation failures return: + +```http +422 Unprocessable Entity +``` + +## Configuration + +Configuration classes live in `CleanApiStarter.Configuration`. + +The root configuration object is: + +```csharp +AppSettings +``` + +It includes: + +- `ConnectionStrings.Postgres` +- `Authentication.Google.ClientId` +- `Authentication.Jwt.Issuer` +- `Authentication.Jwt.Audience` +- `Authentication.Jwt.SigningKey` +- `Authentication.Jwt.ExpirationMinutes` + +The API registers settings once: + +```csharp +builder.Services.AddAppSettings(builder.Configuration); +``` + +Services can inject `AppSettings` directly. + +## Observability + +`CleanApiStarter.AspNetCore` centralizes runtime defaults: + +- OpenTelemetry traces +- OpenTelemetry metrics +- OpenTelemetry logs +- OTLP export for Aspire +- structured log formatting +- HTTP request logging +- health checks +- service discovery +- HTTP client resilience +- problem details +- response compression +- request id header +- Kestrel `Server` header removal + +When the API runs under Aspire, inspect the dashboard for: + +- API request traces +- Npgsql database traces +- structured logs +- request logs with `UserId` +- runtime metrics +- resource health + +Every response includes: + +```http +X-Request-ID: +``` + +Use this value to correlate client responses with logs and traces. + +## Health And Version Endpoints + +The shared ASP.NET Core defaults map: + +```text +/health +/alive +/version +``` + +`/version` exposes the running application version. + +## Testing + +### Unit Tests + +Application unit tests use: + +- xUnit v3 +- AutoFixture.xUnit3 +- AutoFixture.AutoNSubstitute +- NSubstitute +- Shouldly + +Common test helpers live in: + +```text +tests/CleanApiStarter.Tests +``` + +Current reusable helpers: + +- `AutoNSubstituteDataAttribute` +- `ApiApplicationFactory` + +Test names follow: + +```text +UnitOfWork_StateUnderTest_ExpectedBehavior +``` + +Tests use AAA sections: + +```csharp +// Arrange +// Act +// Assert +``` + +Run unit tests: + +```bash +dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +``` + +### API Integration Tests + +API integration tests use: + +- xUnit v3 +- Microsoft.AspNetCore.Mvc.Testing +- Testcontainers for PostgreSQL +- Shouldly + +The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. + +The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. + +Run integration tests: + +```bash +dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +``` + +Run all tests: + +```bash +dotnet test CleanApiStarter.slnx +``` + +## Coverage + +Generate coverage and open the HTML report in Chrome: + +```bash +scripts/test-coverage.sh +``` + +The script: + +- deletes the previous `artifacts/coverage` folder +- restores local .NET tools +- runs tests with `XPlat Code Coverage` +- generates an HTML report with ReportGenerator +- opens the report in Google Chrome + +Coverage output: + +```text +artifacts/coverage/report/index.html +``` + +## Build + +Restore and build: + +```bash +dotnet restore CleanApiStarter.slnx +dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal +``` + +## CI + +GitHub Actions workflows live in `.github/workflows`: + +- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. +- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. +- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. +- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. + +## Package Management + +Package versions are managed centrally in: + +```text +Directory.Packages.props +``` + +Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. + +## Coding Conventions + +- Use explicit local types. +- Use project-level `GlobalUsings.cs`. +- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. +- Use `required` and `init` for required non-null DTO and domain properties. +- Use nullable types only for genuinely optional values. +- Use structured logging message templates instead of interpolated log strings. +- Keep repository interfaces in `Application`. +- Keep repository implementations and EF Core details in `Infrastructure`. +- Keep domain models persistence-agnostic. +- Do not reintroduce Dapper for the default data access path. +- Do not reintroduce MVC controllers unless the template intentionally changes direction. +- Do not add Swashbuckle; use Scalar. + +## Troubleshooting + +### Aspire certificate errors + +If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: + +```bash +dotnet dev-certs https --check --trust +``` + +If needed, reset and trust again: + +```bash +dotnet dev-certs https --clean +dotnet dev-certs https --trust +dotnet dev-certs https --check --trust +``` + +On macOS, accept the Keychain prompt and enter your password if requested. + +### PostgreSQL init scripts did not run + +Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. + +For Docker Compose: + +```bash +docker compose down -v +docker compose up -d +``` + +For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. + +### Postgres latest and data volumes + +This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: + +```text +/var/lib/postgresql +``` + +Do not mount the volume at: + +```text +/var/lib/postgresql/data +``` + +## License + +This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. diff --git a/database/migrations/V001__create_projects_and_tasks_tables.sql b/layered/database/migrations/V001__create_projects_and_tasks_tables.sql similarity index 100% rename from database/migrations/V001__create_projects_and_tasks_tables.sql rename to layered/database/migrations/V001__create_projects_and_tasks_tables.sql diff --git a/database/migrations/V002__create_identity_tables.sql b/layered/database/migrations/V002__create_identity_tables.sql similarity index 100% rename from database/migrations/V002__create_identity_tables.sql rename to layered/database/migrations/V002__create_identity_tables.sql diff --git a/docker-compose.yml b/layered/docker-compose.yml similarity index 100% rename from docker-compose.yml rename to layered/docker-compose.yml diff --git a/global.json b/layered/global.json similarity index 100% rename from global.json rename to layered/global.json diff --git a/scripts/install-template.sh b/layered/scripts/install-template.sh similarity index 100% rename from scripts/install-template.sh rename to layered/scripts/install-template.sh diff --git a/scripts/test-coverage.sh b/layered/scripts/test-coverage.sh similarity index 100% rename from scripts/test-coverage.sh rename to layered/scripts/test-coverage.sh diff --git a/src/CleanApiStarter.Api/Api.http b/layered/src/CleanApiStarter.Api/Api.http similarity index 100% rename from src/CleanApiStarter.Api/Api.http rename to layered/src/CleanApiStarter.Api/Api.http diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/layered/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj similarity index 100% rename from src/CleanApiStarter.Api/CleanApiStarter.Api.csproj rename to layered/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/layered/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs similarity index 100% rename from src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs rename to layered/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/layered/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs similarity index 100% rename from src/CleanApiStarter.Api/Endpoints/V1/Auth.cs rename to layered/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/layered/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs similarity index 100% rename from src/CleanApiStarter.Api/Endpoints/V1/Projects.cs rename to layered/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/layered/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs similarity index 100% rename from src/CleanApiStarter.Api/Endpoints/V2/Projects.cs rename to layered/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/layered/src/CleanApiStarter.Api/GlobalUsings.cs similarity index 100% rename from src/CleanApiStarter.Api/GlobalUsings.cs rename to layered/src/CleanApiStarter.Api/GlobalUsings.cs diff --git a/src/CleanApiStarter.Api/Program.cs b/layered/src/CleanApiStarter.Api/Program.cs similarity index 100% rename from src/CleanApiStarter.Api/Program.cs rename to layered/src/CleanApiStarter.Api/Program.cs diff --git a/src/CleanApiStarter.Api/Properties/launchSettings.json b/layered/src/CleanApiStarter.Api/Properties/launchSettings.json similarity index 100% rename from src/CleanApiStarter.Api/Properties/launchSettings.json rename to layered/src/CleanApiStarter.Api/Properties/launchSettings.json diff --git a/src/CleanApiStarter.Api/Services/CurrentUser.cs b/layered/src/CleanApiStarter.Api/Services/CurrentUser.cs similarity index 100% rename from src/CleanApiStarter.Api/Services/CurrentUser.cs rename to layered/src/CleanApiStarter.Api/Services/CurrentUser.cs diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/layered/src/CleanApiStarter.Api/appsettings.Development.json similarity index 100% rename from src/CleanApiStarter.Api/appsettings.Development.json rename to layered/src/CleanApiStarter.Api/appsettings.Development.json diff --git a/src/CleanApiStarter.Api/appsettings.json b/layered/src/CleanApiStarter.Api/appsettings.json similarity index 100% rename from src/CleanApiStarter.Api/appsettings.json rename to layered/src/CleanApiStarter.Api/appsettings.json diff --git a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/layered/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj similarity index 100% rename from src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj rename to layered/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj diff --git a/src/CleanApiStarter.AppHost/Program.cs b/layered/src/CleanApiStarter.AppHost/Program.cs similarity index 100% rename from src/CleanApiStarter.AppHost/Program.cs rename to layered/src/CleanApiStarter.AppHost/Program.cs diff --git a/src/CleanApiStarter.AppHost/Properties/launchSettings.json b/layered/src/CleanApiStarter.AppHost/Properties/launchSettings.json similarity index 100% rename from src/CleanApiStarter.AppHost/Properties/launchSettings.json rename to layered/src/CleanApiStarter.AppHost/Properties/launchSettings.json diff --git a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/layered/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj similarity index 100% rename from src/CleanApiStarter.Application/CleanApiStarter.Application.csproj rename to layered/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj diff --git a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/layered/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs similarity index 100% rename from src/CleanApiStarter.Application/Common/Interfaces/IUser.cs rename to layered/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs diff --git a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs b/layered/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs similarity index 100% rename from src/CleanApiStarter.Application/Common/Models/ArrayResult.cs rename to layered/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs b/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs similarity index 100% rename from src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs rename to layered/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs b/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs similarity index 100% rename from src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs rename to layered/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs b/layered/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs similarity index 100% rename from src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs rename to layered/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/layered/src/CleanApiStarter.Application/DependencyInjection.cs similarity index 100% rename from src/CleanApiStarter.Application/DependencyInjection.cs rename to layered/src/CleanApiStarter.Application/DependencyInjection.cs diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/layered/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs rename to layered/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/layered/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs rename to layered/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs rename to layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs rename to layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs diff --git a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/layered/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Auth/IAuthService.cs rename to layered/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs b/layered/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs b/layered/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/IProjectService.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/ProjectService.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs diff --git a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs similarity index 100% rename from src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs rename to layered/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/layered/src/CleanApiStarter.Application/GlobalUsings.cs similarity index 100% rename from src/CleanApiStarter.Application/GlobalUsings.cs rename to layered/src/CleanApiStarter.Application/GlobalUsings.cs diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/layered/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs rename to layered/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/layered/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj similarity index 100% rename from src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj rename to layered/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/layered/src/CleanApiStarter.AspNetCore/Extensions.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/Extensions.cs rename to layered/src/CleanApiStarter.AspNetCore/Extensions.cs diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/layered/src/CleanApiStarter.AspNetCore/GlobalUsings.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/GlobalUsings.cs rename to layered/src/CleanApiStarter.AspNetCore/GlobalUsings.cs diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/layered/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/IEndpointGroup.cs rename to layered/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs diff --git a/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/layered/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs rename to layered/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/layered/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs rename to layered/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/layered/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs rename to layered/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs diff --git a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/layered/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs rename to layered/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs diff --git a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/layered/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs rename to layered/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs diff --git a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/layered/src/CleanApiStarter.AspNetCore/ValidationFilter.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/ValidationFilter.cs rename to layered/src/CleanApiStarter.AspNetCore/ValidationFilter.cs diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/layered/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs similarity index 100% rename from src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs rename to layered/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/layered/src/CleanApiStarter.Configuration/AppSettings.cs similarity index 100% rename from src/CleanApiStarter.Configuration/AppSettings.cs rename to layered/src/CleanApiStarter.Configuration/AppSettings.cs diff --git a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/layered/src/CleanApiStarter.Configuration/AuthenticationSettings.cs similarity index 100% rename from src/CleanApiStarter.Configuration/AuthenticationSettings.cs rename to layered/src/CleanApiStarter.Configuration/AuthenticationSettings.cs diff --git a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/layered/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj similarity index 100% rename from src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj rename to layered/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/layered/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs similarity index 100% rename from src/CleanApiStarter.Configuration/ConnectionStringSettings.cs rename to layered/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/layered/src/CleanApiStarter.Configuration/GlobalUsings.cs similarity index 100% rename from src/CleanApiStarter.Configuration/GlobalUsings.cs rename to layered/src/CleanApiStarter.Configuration/GlobalUsings.cs diff --git a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/layered/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs similarity index 100% rename from src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs rename to layered/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs diff --git a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/layered/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs similarity index 100% rename from src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs rename to layered/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/layered/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs similarity index 100% rename from src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs rename to layered/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs diff --git a/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj b/layered/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj similarity index 100% rename from src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj rename to layered/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj diff --git a/src/CleanApiStarter.Domain/Entities/Project.cs b/layered/src/CleanApiStarter.Domain/Entities/Project.cs similarity index 100% rename from src/CleanApiStarter.Domain/Entities/Project.cs rename to layered/src/CleanApiStarter.Domain/Entities/Project.cs diff --git a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs b/layered/src/CleanApiStarter.Domain/Entities/ProjectMember.cs similarity index 100% rename from src/CleanApiStarter.Domain/Entities/ProjectMember.cs rename to layered/src/CleanApiStarter.Domain/Entities/ProjectMember.cs diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs b/layered/src/CleanApiStarter.Domain/Entities/ProjectTask.cs similarity index 100% rename from src/CleanApiStarter.Domain/Entities/ProjectTask.cs rename to layered/src/CleanApiStarter.Domain/Entities/ProjectTask.cs diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs b/layered/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs similarity index 100% rename from src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs rename to layered/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/layered/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj similarity index 100% rename from src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj rename to layered/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/layered/src/CleanApiStarter.Infrastructure/DependencyInjection.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/DependencyInjection.cs rename to layered/src/CleanApiStarter.Infrastructure/DependencyInjection.cs diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/layered/src/CleanApiStarter.Infrastructure/GlobalUsings.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/GlobalUsings.cs rename to layered/src/CleanApiStarter.Infrastructure/GlobalUsings.cs diff --git a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/layered/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs rename to layered/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs diff --git a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/layered/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs rename to layered/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs rename to layered/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs rename to layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs rename to layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs rename to layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs diff --git a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs b/layered/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs similarity index 100% rename from src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs rename to layered/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs diff --git a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/layered/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj rename to layered/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/layered/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs rename to layered/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/layered/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs rename to layered/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs diff --git a/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/layered/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json rename to layered/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/layered/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj similarity index 100% rename from tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj rename to layered/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/layered/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs similarity index 100% rename from tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs rename to layered/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/layered/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs similarity index 100% rename from tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs rename to layered/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/layered/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj similarity index 100% rename from tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj rename to layered/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/layered/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs similarity index 100% rename from tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs rename to layered/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs diff --git a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/layered/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs similarity index 100% rename from tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs rename to layered/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/layered/tests/CleanApiStarter.Tests/GlobalUsings.cs similarity index 100% rename from tests/CleanApiStarter.Tests/GlobalUsings.cs rename to layered/tests/CleanApiStarter.Tests/GlobalUsings.cs From c045defcc562f21a8af61e6a141671c87b51d129 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Wed, 17 Jun 2026 20:27:01 +0800 Subject: [PATCH 02/18] feat: add modular variant (single Api project, NsDepCop-enforced) Second dotnet new variant 'clean-api-modular' alongside layered. Collapses Domain/Application/Infrastructure into one CleanApiStarter.Api project organized by folders (Domain/, Infrastructure/, Common/, Features/), reusing the AspNetCore and Configuration platform projects plus AppHost. Namespaces are CleanApiStarter.Api.*. Boundaries are enforced by NsDepCop (config.nsdepcop + WarningsAsErrors=NSDEPCOP01): Domain may not reference Features/Infrastructure, Features may not reference Infrastructure. Verified that an illegal Domain->Infrastructure reference fails the build with NSDEPCOP01. Both variants given distinct template identities/package ids: clean-api-layered and clean-api-modular. Verified: modular builds, unit + integration tests pass, and the template packs, instantiates, and the generated project builds. Deferred to step 3: shared database/ at repo root (modular currently has its own copy), .github CI matrix, CONTRIBUTING refresh, and a modular-specific README. --- layered/.template.config/template.json | 6 +- layered/CleanApiStarter.Template.csproj | 4 +- modular/.config/dotnet-tools.json | 12 + modular/.editorconfig | 367 ++++++++++ modular/.gitignore | 405 +++++++++++ modular/.template.config/template.json | 53 ++ modular/AGENTS.md | 181 +++++ modular/CleanApiStarter.Template.csproj | 31 + modular/CleanApiStarter.slnx | 41 ++ modular/Directory.Build.props | 10 + modular/Directory.Packages.props | 61 ++ modular/LICENSE | 674 ++++++++++++++++++ modular/README.md | 600 ++++++++++++++++ ...V001__create_projects_and_tasks_tables.sql | 34 + .../V002__create_identity_tables.sql | 91 +++ modular/docker-compose.yml | 18 + modular/global.json | 6 + modular/scripts/install-template.sh | 13 + modular/scripts/test-coverage.sh | 60 ++ modular/src/CleanApiStarter.Api/Api.http | 88 +++ .../CleanApiStarter.Api.csproj | 28 + .../Common/Interfaces/IUser.cs | 7 + .../Common/Models/ArrayResult.cs | 8 + .../Common/Models/PaginatedQuery.cs | 8 + .../Common/Models/PaginatedQueryValidator.cs | 13 + .../Common/Models/PaginatedResult.cs | 27 + .../DependencyInjection.cs | 12 + .../Domain/Entities/Project.cs | 18 + .../Domain/Entities/ProjectMember.cs | 12 + .../Domain/Entities/ProjectTask.cs | 22 + .../Domain/Entities/ProjectTaskStatus.cs | 8 + .../Endpoints/GoogleLoginPage.cs | 100 +++ .../CleanApiStarter.Api/Endpoints/V1/Auth.cs | 39 + .../Endpoints/V1/Projects.cs | 176 +++++ .../Endpoints/V2/Projects.cs | 35 + .../Features/Auth/AuthTokenDto.cs | 8 + .../Features/Auth/CurrentUserDto.cs | 12 + .../Features/Auth/GoogleSignInDto.cs | 6 + .../Features/Auth/GoogleSignInDtoValidator.cs | 10 + .../Features/Auth/IAuthService.cs | 8 + .../Projects/CreateProjectDtoValidator.cs | 14 + .../Projects/CreateProjectTaskDtoValidator.cs | 19 + .../Features/Projects/IProjectRepository.cs | 35 + .../Features/Projects/IProjectService.cs | 32 + .../Features/Projects/ProjectDto.cs | 21 + .../Projects/ProjectOperationResults.cs | 15 + .../Features/Projects/ProjectService.cs | 248 +++++++ .../Features/Projects/ProjectTaskDto.cs | 40 ++ .../Projects/UpdateProjectTaskDtoValidator.cs | 24 + .../src/CleanApiStarter.Api/GlobalUsings.cs | 37 + .../Infrastructure/DependencyInjection.cs | 22 + .../Identity/ApplicationUser.cs | 3 + .../Identity/GoogleAuthService.cs | 151 ++++ .../Persistence/ApplicationDbContext.cs | 18 + .../Configuration/ProjectConfiguration.cs | 31 + .../ProjectMemberConfiguration.cs | 30 + .../Configuration/ProjectTaskConfiguration.cs | 46 ++ .../Repositories/ProjectRepository.cs | 158 ++++ modular/src/CleanApiStarter.Api/Program.cs | 20 + .../Properties/launchSettings.json | 15 + .../Services/CurrentUser.cs | 9 + .../appsettings.Development.json | 16 + .../src/CleanApiStarter.Api/appsettings.json | 9 + .../src/CleanApiStarter.Api/config.nsdepcop | 18 + .../CleanApiStarter.AppHost.csproj | 23 + .../src/CleanApiStarter.AppHost/Program.cs | 19 + .../Properties/launchSettings.json | 17 + .../AspNetCoreDefaultServices.cs | 116 +++ .../CleanApiStarter.AspNetCore.csproj | 32 + .../CleanApiStarter.AspNetCore/Extensions.cs | 75 ++ .../GlobalUsings.cs | 38 + .../IEndpointGroup.cs | 10 + .../OpenApiDocumentationExtensions.cs | 32 + .../OpenTelemetryDefaults.cs | 41 ++ .../ProblemDetailsExceptionHandler.cs | 82 +++ .../RequestIdMiddleware.cs | 23 + .../UserIdHttpLoggingInterceptor.cs | 28 + .../ValidationFilter.cs | 45 ++ .../WebApplicationExtensions.cs | 40 ++ .../AppSettings.cs | 12 + .../AuthenticationSettings.cs | 12 + .../CleanApiStarter.Configuration.csproj | 15 + .../ConnectionStringSettings.cs | 7 + .../GlobalUsings.cs | 5 + .../GoogleAuthenticationSettings.cs | 7 + .../JwtAuthenticationSettings.cs | 17 + .../OptionsRegistrationExtensions.cs | 19 + ...leanApiStarter.Api.IntegrationTests.csproj | 29 + .../Features/Projects/ProjectsTests.cs | 40 ++ .../GlobalUsings.cs | 9 + .../appsettings.Testing.json | 16 + ...eanApiStarter.Application.UnitTests.csproj | 28 + .../Features/Projects/ProjectServiceTests.cs | 40 ++ .../GlobalUsings.cs | 14 + .../CleanApiStarter.Tests.csproj | 22 + .../Common/ApiApplicationFactory.cs | 100 +++ .../Common/AutoNSubstituteDataAttribute.cs | 13 + .../CleanApiStarter.Tests/GlobalUsings.cs | 18 + 98 files changed, 5382 insertions(+), 5 deletions(-) create mode 100644 modular/.config/dotnet-tools.json create mode 100644 modular/.editorconfig create mode 100644 modular/.gitignore create mode 100644 modular/.template.config/template.json create mode 100644 modular/AGENTS.md create mode 100644 modular/CleanApiStarter.Template.csproj create mode 100644 modular/CleanApiStarter.slnx create mode 100644 modular/Directory.Build.props create mode 100644 modular/Directory.Packages.props create mode 100644 modular/LICENSE create mode 100644 modular/README.md create mode 100644 modular/database/migrations/V001__create_projects_and_tasks_tables.sql create mode 100644 modular/database/migrations/V002__create_identity_tables.sql create mode 100644 modular/docker-compose.yml create mode 100644 modular/global.json create mode 100755 modular/scripts/install-template.sh create mode 100755 modular/scripts/test-coverage.sh create mode 100644 modular/src/CleanApiStarter.Api/Api.http create mode 100644 modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj create mode 100644 modular/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs create mode 100644 modular/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs create mode 100644 modular/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs create mode 100644 modular/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs create mode 100644 modular/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs create mode 100644 modular/src/CleanApiStarter.Api/DependencyInjection.cs create mode 100644 modular/src/CleanApiStarter.Api/Domain/Entities/Project.cs create mode 100644 modular/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs create mode 100644 modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs create mode 100644 modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs create mode 100644 modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs create mode 100644 modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs create mode 100644 modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs create mode 100644 modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs create mode 100644 modular/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs create mode 100644 modular/src/CleanApiStarter.Api/GlobalUsings.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs create mode 100644 modular/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs create mode 100644 modular/src/CleanApiStarter.Api/Program.cs create mode 100644 modular/src/CleanApiStarter.Api/Properties/launchSettings.json create mode 100644 modular/src/CleanApiStarter.Api/Services/CurrentUser.cs create mode 100644 modular/src/CleanApiStarter.Api/appsettings.Development.json create mode 100644 modular/src/CleanApiStarter.Api/appsettings.json create mode 100644 modular/src/CleanApiStarter.Api/config.nsdepcop create mode 100644 modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj create mode 100644 modular/src/CleanApiStarter.AppHost/Program.cs create mode 100644 modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json create mode 100644 modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj create mode 100644 modular/src/CleanApiStarter.AspNetCore/Extensions.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs create mode 100644 modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs create mode 100644 modular/src/CleanApiStarter.Configuration/AppSettings.cs create mode 100644 modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs create mode 100644 modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj create mode 100644 modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs create mode 100644 modular/src/CleanApiStarter.Configuration/GlobalUsings.cs create mode 100644 modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs create mode 100644 modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs create mode 100644 modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs create mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj create mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs create mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs create mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json create mode 100644 modular/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj create mode 100644 modular/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs create mode 100644 modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs create mode 100644 modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj create mode 100644 modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs create mode 100644 modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs create mode 100644 modular/tests/CleanApiStarter.Tests/GlobalUsings.cs diff --git a/layered/.template.config/template.json b/layered/.template.config/template.json index b54a2e3..7102935 100644 --- a/layered/.template.config/template.json +++ b/layered/.template.config/template.json @@ -9,9 +9,9 @@ "PostgreSQL", "OpenTelemetry" ], - "identity": "CleanApiStarter.Template", - "name": "Clean API Starter", - "shortName": "clean-api-starter", + "identity": "CleanApiStarter.Template.Layered", + "name": "Clean API Starter (Layered)", + "shortName": "clean-api-layered", "sourceName": "CleanApiStarter", "preferNameDirectory": true, "symbols": { diff --git a/layered/CleanApiStarter.Template.csproj b/layered/CleanApiStarter.Template.csproj index f3497b3..36b0398 100644 --- a/layered/CleanApiStarter.Template.csproj +++ b/layered/CleanApiStarter.Template.csproj @@ -8,9 +8,9 @@ true $(NoWarn);NU5128 - CleanApiStarter.Template + CleanApiStarter.Template.Layered 0.0.0 - Clean API Starter Template + Clean API Starter Template (Layered) Chathuranga Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. dotnet-new;template;aspnetcore;api;clean-architecture;aspire;postgresql;opentelemetry;scalar;jwt;efcore diff --git a/modular/.config/dotnet-tools.json b/modular/.config/dotnet-tools.json new file mode 100644 index 0000000..9288b0e --- /dev/null +++ b/modular/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ] + } + } +} \ No newline at end of file diff --git a/modular/.editorconfig b/modular/.editorconfig new file mode 100644 index 0000000..5755f36 --- /dev/null +++ b/modular/.editorconfig @@ -0,0 +1,367 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +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 = true +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:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:warning + +# Prefer explicit types instead of var +dotnet_diagnostic.IDE0008.severity = warning + +# 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 + +#### 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 = namespace +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 + diff --git a/modular/.gitignore b/modular/.gitignore new file mode 100644 index 0000000..0e2fcbc --- /dev/null +++ b/modular/.gitignore @@ -0,0 +1,405 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +.DS_Store + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[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 Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# 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 + +# JetBrains IDE and Junie local assistant files +.idea/ +.junie/ + +# 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 +.DS_Store diff --git a/modular/.template.config/template.json b/modular/.template.config/template.json new file mode 100644 index 0000000..ed05d10 --- /dev/null +++ b/modular/.template.config/template.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Chathuranga", + "classifications": [ + "Web", + "API", + "Clean Architecture", + "Aspire", + "PostgreSQL", + "OpenTelemetry" + ], + "identity": "CleanApiStarter.Template.Modular", + "name": "Clean API Starter (Modular)", + "shortName": "clean-api-modular", + "sourceName": "CleanApiStarter", + "preferNameDirectory": true, + "symbols": { + "caPackageVersion": { + "type": "parameter", + "datatype": "text", + "defaultValue": "0.0.0", + "replaces": "0.0.0" + } + }, + "tags": { + "language": "C#", + "type": "solution" + }, + "sources": [ + { + "modifiers": [ + { + "exclude": [ + ".git/**", + ".idea/**", + ".junie/**", + ".vs/**", + "**/.DS_Store", + "**/bin/**", + "**/obj/**", + "artifacts/**", + ".github/workflows/release.yml", + ".github/workflows/template.yml", + "CleanApiStarter.Template.csproj", + "CONTRIBUTING.md", + "docs/**", + "scripts/install-template.sh" + ] + } + ] + } + ] +} diff --git a/modular/AGENTS.md b/modular/AGENTS.md new file mode 100644 index 0000000..e92b9af --- /dev/null +++ b/modular/AGENTS.md @@ -0,0 +1,181 @@ +# Agent Instructions + +This repository is a Clean Architecture API starter template named `CleanApiStarter`. + +## Naming + +- Use `CleanApiStarter` for solution, project, assembly, and namespace naming. +- Do not introduce `CleanArchitecture` namespaces or assembly names. +- Keep project names fully qualified: + - `CleanApiStarter.Api` + - `CleanApiStarter.Application` + - `CleanApiStarter.Configuration` + - `CleanApiStarter.Domain` + - `CleanApiStarter.Infrastructure` + - `CleanApiStarter.AppHost` + - `CleanApiStarter.AspNetCore` + - `CleanApiStarter.UnitTests` + +## Solution Structure + +- Keep clean architecture application layers under the `/src/` solution folder: + - API + - Application + - Configuration + - Domain + - Infrastructure +- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`: + - AppHost + - AspNetCore +- Keep database scripts under top-level `database/migrations`. +- Keep important root files in solution items, including `README.md`, `docker-compose.yml`, `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, and `global.json`. + +## Clean Architecture Rules + +- Dependencies point inward: + - `Domain` references nothing. + - `Application` references `Domain`. + - `Configuration` references no application layers and contains only plain options classes plus options registration helpers. + - `Infrastructure` references `Application` and `Configuration`. + - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. +- Keep repository interfaces in `Application`, not `Domain`. +- Keep database implementation details in `Infrastructure`. +- Keep domain models persistence-agnostic. Persistence mapping details belong in Infrastructure EF Core configuration. +- Use `required` for non-null required scalar properties on DTOs and domain entities. Do not use `= string.Empty` only to satisfy nullable reference type warnings. +- Keep collection properties initialized with `= []`. +- Keep EF navigation properties as `= null!` when EF is responsible for materializing them. +- Use nullable types such as `string?` or `DateTime?` only for genuinely optional values. +- Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path. +- Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern. +- Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason. +- Keep Identity-specific classes, such as `ApplicationUser` and auth services, under `CleanApiStarter.Infrastructure/Identity`. + +## API and Application Conventions + +- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Projects` or `Application/Features/Auth`. +- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. +- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. +- Return single resources as their DTO object directly. +- Use `ArrayResult` instead of returning raw arrays from non-paginated list endpoints. +- Use `PaginatedQuery` for paginated request parameters and return `PaginatedResult` directly for paginated collection responses. +- Use FluentValidation for request validation. Put validators beside feature request models in `Application`, and rely on the shared Minimal API validation endpoint filter in `CleanApiStarter.AspNetCore`. FluentValidation failures should return `422 Unprocessable Entity`. +- Do not use DataAnnotations for application request validation. +- Use Scalar, not Swagger/Swashbuckle. +- Use the .NET 10 API versioning/OpenAPI setup from the Microsoft .NET blog: + - `Asp.Versioning.Http` v10 + - `Asp.Versioning.Mvc.ApiExplorer` v10 + - `Asp.Versioning.OpenApi` + - `builder.Services.AddApiVersioning(...).AddApiExplorer(...).AddOpenApi();` + - `app.MapOpenApi().WithDocumentPerVersion();` + - `app.MapScalarApiReference(...)` configured from `app.DescribeApiVersions()` +- Do not add `Swashbuckle.AspNetCore`. +- Cancellation tokens are explicit: + - Do not use `CancellationToken cancellationToken = default` in service or repository contracts. + - API actions should accept `CancellationToken cancellationToken` and pass it through. +- Use explicit local types. The `.editorconfig` prefers explicit types over `var`. +- Use project-level `GlobalUsings.cs`; avoid adding file-level `using` directives unless there is a very specific reason. + +## Database and Aspire + +- Use a single Postgres database: `postgres`. +- Do not create or reference a separate feature-specific database. +- AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`. +- `docker-compose.yml` should use `POSTGRES_DB=postgres`. +- Database schema scripts live in `database/migrations`, for example: + - `database/migrations/V001__create_projects_and_tasks_tables.sql` +- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. +- EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`. +- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. +- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`. +- In Aspire, use a server resource name that does not conflict with the database resource name, for example: + - server resource: `postgres-server` + - database resource: `postgres` +- Aspire Postgres should use a volume mounted at `/var/lib/postgresql`. + +## ASP.NET Core Defaults + +- Keep `CleanApiStarter.AspNetCore`. +- It centralizes Aspire-friendly runtime defaults: + - OpenTelemetry traces, metrics, logs, and OTLP export + - problem-details exception handling + - `/version` endpoint + - health endpoints + - service discovery + - default HTTP client resilience + - security defaults such as removing the Kestrel `Server` response header +- API `Program.cs` should stay small and call: + - `builder.AddAspNetCoreDefaults();` + - `app.UseAspNetCoreDefaults();` + - `app.MapDefaultEndpoints();` +- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. +- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. +- Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`. +- Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers. +- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{ProjectId}`, `{TaskId}`, and `{UserId}`. +- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. +- Do not add generic options registration helpers until the template has multiple real options sections that need them. +- Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`. +- Keep dependency injection validation enabled with `ValidateOnBuild` and `ValidateScopes`. + +## API Style + +- Prefer Minimal APIs for this template. +- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Projects.cs`. +- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. +- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. +- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. +- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. +- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetProjectsV1` and `GetProjectsV2`. +- Do not reintroduce MVC controllers unless the template intentionally changes direction. + +## Authentication + +- Authentication is API-first: + - clients obtain a Google ID token + - `POST /api/auth/google` validates it + - the API issues its own JWT +- Keep JWT bearer authentication setup in `CleanApiStarter.AspNetCore`. +- Keep local user/role storage in ASP.NET Core Identity under `CleanApiStarter.Infrastructure`. +- Do not use cookies as the default API auth mechanism. +- Keep the development Google login helper page unversioned so it can be opened directly in a browser. +- Protected API calls should send `Authorization: Bearer `. Send `X-Api-Version` only when selecting a non-default API version. + +## Packages + +- Manage versions centrally in `Directory.Packages.props`. +- Keep `PackageVersion` items sorted alphabetically by `Include`. +- Do not add package versions directly in individual `.csproj` files. + +## Template Packaging + +- This repository is also the `dotnet new` template source. +- Keep template metadata in `.template.config/template.json`. +- Keep NuGet template package metadata in `CleanApiStarter.Template.csproj`. +- Use `dotnet pack` and `dotnet nuget push` for template packaging and publishing. Do not use `nuget pack`, `nuget.exe`, or Mono. +- Use `scripts/install-template.sh` to pack and install the local template. +- Keep repo-only template packaging scripts excluded from generated template output. +- Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. +- Keep release publishing triggered by GitHub Release publication with `vX.Y.Z` tags, not manual version inputs. + +## Testing + +- Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. +- Reusable test helpers belong in `CleanApiStarter.Tests`. +- Application unit tests should reference `CleanApiStarter.Tests` instead of duplicating common test setup. +- API integration tests use MSTest and Testcontainers for Postgres. +- API integration tests should keep real JWT bearer authentication active. Generate test JWTs from `appsettings.Testing.json` instead of replacing authentication with a fake scheme. +- API integration tests should start a Postgres Testcontainer and apply SQL scripts from `database/migrations`. +- Keep test app settings in `appsettings.Testing.json`. +- Test methods must follow the naming pattern `UnitOfWork_StateUnderTest_ExpectedBehavior`. +- Tests must follow AAA format with explicit `// Arrange`, `// Act`, and `// Assert` sections. + +## Verification + +- After structural or package changes, run: + +```bash +dotnet restore CleanApiStarter.slnx +dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal +``` + +- Aspire AppHost builds may need to run outside a sandbox because the Aspire SDK touches local runtime/process resources. diff --git a/modular/CleanApiStarter.Template.csproj b/modular/CleanApiStarter.Template.csproj new file mode 100644 index 0000000..87b4dfb --- /dev/null +++ b/modular/CleanApiStarter.Template.csproj @@ -0,0 +1,31 @@ + + + net10.0 + false + true + false + true + true + $(NoWarn);NU5128 + + CleanApiStarter.Template.Modular + 0.0.0 + Clean API Starter Template (Modular) + Chathuranga + Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. + dotnet-new;template;aspnetcore;api;clean-architecture;aspire;postgresql;opentelemetry;scalar;jwt;efcore + Template + LICENSE + README.md + + + + + + + + diff --git a/modular/CleanApiStarter.slnx b/modular/CleanApiStarter.slnx new file mode 100644 index 0000000..9ba3db1 --- /dev/null +++ b/modular/CleanApiStarter.slnx @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modular/Directory.Build.props b/modular/Directory.Build.props new file mode 100644 index 0000000..d2894e9 --- /dev/null +++ b/modular/Directory.Build.props @@ -0,0 +1,10 @@ + + + + net10.0 + true + + enable + enable + + \ No newline at end of file diff --git a/modular/Directory.Packages.props b/modular/Directory.Packages.props new file mode 100644 index 0000000..5d6efd1 --- /dev/null +++ b/modular/Directory.Packages.props @@ -0,0 +1,61 @@ + + + + true + 13.4.4 + 10.0.9 + 10.0.0 + 10.0.9 + 10.7.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modular/LICENSE b/modular/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/modular/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/modular/README.md b/modular/README.md new file mode 100644 index 0000000..a8c68c4 --- /dev/null +++ b/modular/README.md @@ -0,0 +1,600 @@ +# CleanApiStarter + +`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. + +The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. + +## Features + +- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. +- Minimal APIs organized by endpoint group and API version folders. +- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. +- Versioned OpenAPI documents shown with Scalar. +- Google ID token sign-in that issues the API's own JWT. +- ASP.NET Core Identity for local users, roles, and external login storage. +- Real JWT bearer authentication for protected APIs. +- EF Core with PostgreSQL for application data and Identity storage. +- PostgreSQL local development through Aspire or Docker Compose. +- Database schema scripts under `database/migrations`. +- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. +- HTTP request logging with authenticated user id. +- `X-Request-ID` response header containing the current trace id. +- Problem Details exception responses. +- Response compression with Brotli and gzip. +- FluentValidation endpoint validation returning `422 Unprocessable Entity`. +- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. +- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. +- xUnit v3 API integration tests with Testcontainers for PostgreSQL. +- Local coverage script that generates an HTML report with ReportGenerator. +- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. + +## Solution Layout + +```text +CleanApiStarter +├── database +│ └── migrations +├── scripts +├── src +│ ├── CleanApiStarter.Api +│ ├── CleanApiStarter.Application +│ ├── CleanApiStarter.Domain +│ ├── CleanApiStarter.Infrastructure +│ └── Common +│ ├── CleanApiStarter.AppHost +│ ├── CleanApiStarter.AspNetCore +│ └── CleanApiStarter.Configuration +└── tests + ├── CleanApiStarter.Api.IntegrationTests + ├── CleanApiStarter.Application.UnitTests + └── CleanApiStarter.Tests +``` + +Layer dependencies point inward: + +- `Domain` references nothing. +- `Application` references `Domain`. +- `Infrastructure` references `Application` and `Configuration`. +- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. +- `Configuration` contains plain settings classes and options registration. +- `AspNetCore` contains reusable web/runtime defaults. +- `AppHost` contains Aspire orchestration. + +## Requirements + +- .NET SDK `10.0.203` or compatible latest feature SDK +- Docker Desktop +- Google Chrome, only for the local coverage script opening the HTML report + +The SDK is pinned in `global.json`: + +```json +{ + "sdk": { + "version": "10.0.203", + "rollForward": "latestFeature" + } +} +``` + +## Use As A Template + +Install the template package from NuGet: + +```bash +dotnet new install CleanApiStarter.Template +``` + +Create a new solution: + +```bash +dotnet new clean-api-starter -n MyProduct +``` + +The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. + +While developing the template locally, run: + +```bash +scripts/install-template.sh +``` + +That script: + +- uninstalls the previous local template package +- packs the current repo with `CleanApiStarter.Template.csproj` +- installs the generated local `.nupkg` +- deletes the temporary package from `artifacts` + +Then create a local test solution: + +```bash +dotnet new clean-api-starter -n DemoProduct +``` + +## Run With Aspire + +```bash +dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +``` + +Aspire starts: + +- the API +- a PostgreSQL container using `postgres:latest` +- pgAdmin +- the Aspire dashboard + +Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. + +The AppHost configures PostgreSQL like this: + +- server resource: `postgres-server` +- database resource: `postgres` +- data volume: `clean-api-starter-postgres-data` +- volume mount: `/var/lib/postgresql` +- init scripts: `database/migrations` + +PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. + +## Run With Docker Compose + +If you want only PostgreSQL without Aspire: + +```bash +docker compose up -d +dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. + +## Database + +Database scripts live here: + +```text +database/migrations +├── V001__create_projects_and_tasks_tables.sql +└── V002__create_identity_tables.sql +``` + +The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. + +`ApplicationDbContext` lives in: + +```text +src/CleanApiStarter.Infrastructure/Persistence +``` + +EF Core mappings live in: + +```text +src/CleanApiStarter.Infrastructure/Persistence/Configuration +``` + +## API Style + +The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. + +Endpoint groups live under version folders: + +```text +src/CleanApiStarter.Api/Endpoints/V1 +src/CleanApiStarter.Api/Endpoints/V2 +``` + +Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: + +```csharp +app.MapEndpoints(Assembly.GetExecutingAssembly()); +``` + +Endpoint names are globally unique across versions, for example: + +```text +GetProjectsV1 +GetProjectsV2 +``` + +## API Versioning + +API versioning uses header-based version selection: + +```http +X-Api-Version: 1.0 +``` + +The header is optional. Requests without `X-Api-Version` use v1. + +The project uses the .NET 10 API versioning/OpenAPI package setup: + +- `Asp.Versioning.Http` +- `Asp.Versioning.Mvc.ApiExplorer` +- `Asp.Versioning.OpenApi` +- `Microsoft.AspNetCore.OpenApi` + +OpenAPI documents are generated per version and displayed in Scalar. + +## Scalar + +This project uses Scalar instead of Swagger/Swashbuckle. + +When running in Development, OpenAPI and Scalar are mapped by the API: + +- versioned OpenAPI documents +- Scalar API reference with version selection + +## Authentication + +Authentication is API-first. + +The intended production flow is: + +1. A client obtains a Google ID token. +2. The client sends it to `POST /api/auth/google`. +3. The API validates the Google ID token. +4. The API creates or updates the local ASP.NET Core Identity user. +5. The API issues its own JWT. +6. Protected API calls use: + +```http +Authorization: Bearer +``` + +The API does not use cookies as the default authentication mechanism. + +### Google Client ID + +Create an OAuth client in Google Cloud Console and set the client id with user secrets: + +```bash +dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +For local browser testing, open the development helper page: + +```text +https://localhost:7285/auth/google-login +``` + +The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. + +## Authorization + +Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. + +Project authorization rules are implemented in the application service and repository queries: + +- users can only see projects they belong to +- users can only see and manage tasks inside projects they belong to +- project deletion is owner-only + +## Application Features + +The reference feature is project and task management. + +Projects: + +- create project +- list current user's projects +- get project by id +- delete project as owner + +Tasks: + +- create task under a project +- list tasks with `limit` and `offset` +- filter tasks by status +- get task by id +- update task +- complete task +- delete task + +The application project is organized by feature: + +```text +src/CleanApiStarter.Application/Features/Auth +src/CleanApiStarter.Application/Features/Projects +``` + +Cross-cutting abstractions live under: + +```text +src/CleanApiStarter.Application/Common +``` + +## Response Shapes + +Single-resource endpoints return the resource DTO directly. + +Paginated endpoints return `PaginatedResult`: + +```json +{ + "items": [], + "limit": 20, + "offset": 0, + "totalCount": 0, + "hasPreviousPage": false, + "hasNextPage": false +} +``` + +Non-paginated collection endpoints should return `ArrayResult`: + +```json +{ + "items": [], + "count": 0 +} +``` + +## Validation + +Request validation uses FluentValidation, not DataAnnotations. + +Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. + +Validation failures return: + +```http +422 Unprocessable Entity +``` + +## Configuration + +Configuration classes live in `CleanApiStarter.Configuration`. + +The root configuration object is: + +```csharp +AppSettings +``` + +It includes: + +- `ConnectionStrings.Postgres` +- `Authentication.Google.ClientId` +- `Authentication.Jwt.Issuer` +- `Authentication.Jwt.Audience` +- `Authentication.Jwt.SigningKey` +- `Authentication.Jwt.ExpirationMinutes` + +The API registers settings once: + +```csharp +builder.Services.AddAppSettings(builder.Configuration); +``` + +Services can inject `AppSettings` directly. + +## Observability + +`CleanApiStarter.AspNetCore` centralizes runtime defaults: + +- OpenTelemetry traces +- OpenTelemetry metrics +- OpenTelemetry logs +- OTLP export for Aspire +- structured log formatting +- HTTP request logging +- health checks +- service discovery +- HTTP client resilience +- problem details +- response compression +- request id header +- Kestrel `Server` header removal + +When the API runs under Aspire, inspect the dashboard for: + +- API request traces +- Npgsql database traces +- structured logs +- request logs with `UserId` +- runtime metrics +- resource health + +Every response includes: + +```http +X-Request-ID: +``` + +Use this value to correlate client responses with logs and traces. + +## Health And Version Endpoints + +The shared ASP.NET Core defaults map: + +```text +/health +/alive +/version +``` + +`/version` exposes the running application version. + +## Testing + +### Unit Tests + +Application unit tests use: + +- xUnit v3 +- AutoFixture.xUnit3 +- AutoFixture.AutoNSubstitute +- NSubstitute +- Shouldly + +Common test helpers live in: + +```text +tests/CleanApiStarter.Tests +``` + +Current reusable helpers: + +- `AutoNSubstituteDataAttribute` +- `ApiApplicationFactory` + +Test names follow: + +```text +UnitOfWork_StateUnderTest_ExpectedBehavior +``` + +Tests use AAA sections: + +```csharp +// Arrange +// Act +// Assert +``` + +Run unit tests: + +```bash +dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +``` + +### API Integration Tests + +API integration tests use: + +- xUnit v3 +- Microsoft.AspNetCore.Mvc.Testing +- Testcontainers for PostgreSQL +- Shouldly + +The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. + +The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. + +Run integration tests: + +```bash +dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +``` + +Run all tests: + +```bash +dotnet test CleanApiStarter.slnx +``` + +## Coverage + +Generate coverage and open the HTML report in Chrome: + +```bash +scripts/test-coverage.sh +``` + +The script: + +- deletes the previous `artifacts/coverage` folder +- restores local .NET tools +- runs tests with `XPlat Code Coverage` +- generates an HTML report with ReportGenerator +- opens the report in Google Chrome + +Coverage output: + +```text +artifacts/coverage/report/index.html +``` + +## Build + +Restore and build: + +```bash +dotnet restore CleanApiStarter.slnx +dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal +``` + +## CI + +GitHub Actions workflows live in `.github/workflows`: + +- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. +- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. +- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. +- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. + +## Package Management + +Package versions are managed centrally in: + +```text +Directory.Packages.props +``` + +Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. + +## Coding Conventions + +- Use explicit local types. +- Use project-level `GlobalUsings.cs`. +- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. +- Use `required` and `init` for required non-null DTO and domain properties. +- Use nullable types only for genuinely optional values. +- Use structured logging message templates instead of interpolated log strings. +- Keep repository interfaces in `Application`. +- Keep repository implementations and EF Core details in `Infrastructure`. +- Keep domain models persistence-agnostic. +- Do not reintroduce Dapper for the default data access path. +- Do not reintroduce MVC controllers unless the template intentionally changes direction. +- Do not add Swashbuckle; use Scalar. + +## Troubleshooting + +### Aspire certificate errors + +If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: + +```bash +dotnet dev-certs https --check --trust +``` + +If needed, reset and trust again: + +```bash +dotnet dev-certs https --clean +dotnet dev-certs https --trust +dotnet dev-certs https --check --trust +``` + +On macOS, accept the Keychain prompt and enter your password if requested. + +### PostgreSQL init scripts did not run + +Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. + +For Docker Compose: + +```bash +docker compose down -v +docker compose up -d +``` + +For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. + +### Postgres latest and data volumes + +This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: + +```text +/var/lib/postgresql +``` + +Do not mount the volume at: + +```text +/var/lib/postgresql/data +``` + +## License + +This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. diff --git a/modular/database/migrations/V001__create_projects_and_tasks_tables.sql b/modular/database/migrations/V001__create_projects_and_tasks_tables.sql new file mode 100644 index 0000000..5c7b4f9 --- /dev/null +++ b/modular/database/migrations/V001__create_projects_and_tasks_tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + owner_user_id VARCHAR(450) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID NOT NULL, + user_id VARCHAR(450) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT pk_project_members PRIMARY KEY (project_id, user_id), + CONSTRAINT fk_project_members_projects_project_id + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS project_tasks ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL, + title VARCHAR(150) NOT NULL, + description TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + due_date TIMESTAMP WITH TIME ZONE NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE NULL, + CONSTRAINT fk_project_tasks_projects_project_id + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_projects_owner_user_id ON projects(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members(user_id); +CREATE INDEX IF NOT EXISTS idx_project_tasks_project_id ON project_tasks(project_id); +CREATE INDEX IF NOT EXISTS idx_project_tasks_project_id_status ON project_tasks(project_id, status); diff --git a/modular/database/migrations/V002__create_identity_tables.sql b/modular/database/migrations/V002__create_identity_tables.sql new file mode 100644 index 0000000..43c1f17 --- /dev/null +++ b/modular/database/migrations/V002__create_identity_tables.sql @@ -0,0 +1,91 @@ +CREATE TABLE IF NOT EXISTS "AspNetRoles" ( + "Id" TEXT PRIMARY KEY, + "Name" VARCHAR(256) NULL, + "NormalizedName" VARCHAR(256) NULL, + "ConcurrencyStamp" TEXT NULL +); + +CREATE TABLE IF NOT EXISTS "AspNetUsers" ( + "Id" TEXT PRIMARY KEY, + "UserName" VARCHAR(256) NULL, + "NormalizedUserName" VARCHAR(256) NULL, + "Email" VARCHAR(256) NULL, + "NormalizedEmail" VARCHAR(256) NULL, + "EmailConfirmed" BOOLEAN NOT NULL, + "PasswordHash" TEXT NULL, + "SecurityStamp" TEXT NULL, + "ConcurrencyStamp" TEXT NULL, + "PhoneNumber" TEXT NULL, + "PhoneNumberConfirmed" BOOLEAN NOT NULL, + "TwoFactorEnabled" BOOLEAN NOT NULL, + "LockoutEnd" TIMESTAMP WITH TIME ZONE NULL, + "LockoutEnabled" BOOLEAN NOT NULL, + "AccessFailedCount" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( + "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "RoleId" TEXT NOT NULL, + "ClaimType" TEXT NULL, + "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" + FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( + "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "UserId" TEXT NOT NULL, + "ClaimType" TEXT NULL, + "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( + "LoginProvider" TEXT NOT NULL, + "ProviderKey" TEXT NOT NULL, + "ProviderDisplayName" TEXT NULL, + "UserId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserLogins" + PRIMARY KEY ("LoginProvider", "ProviderKey"), + CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( + "UserId" TEXT NOT NULL, + "RoleId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserRoles" + PRIMARY KEY ("UserId", "RoleId"), + CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" + FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( + "UserId" TEXT NOT NULL, + "LoginProvider" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Value" TEXT NULL, + CONSTRAINT "PK_AspNetUserTokens" + PRIMARY KEY ("UserId", "LoginProvider", "Name"), + CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail"); +CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName"); +CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName"); +CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId"); +CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId"); +CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId"); +CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId"); + +INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") +VALUES ('user', 'User', 'USER', gen_random_uuid()::text) +ON CONFLICT ("Id") DO NOTHING; + +INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") +VALUES ('admin', 'Admin', 'ADMIN', gen_random_uuid()::text) +ON CONFLICT ("Id") DO NOTHING; diff --git a/modular/docker-compose.yml b/modular/docker-compose.yml new file mode 100644 index 0000000..1536d3e --- /dev/null +++ b/modular/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + postgres: + image: postgres:latest + container_name: clean-api-starter-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql + - ./database/migrations:/docker-entrypoint-initdb.d:ro + +volumes: + postgres-data: diff --git a/modular/global.json b/modular/global.json new file mode 100644 index 0000000..8994a62 --- /dev/null +++ b/modular/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.203", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/modular/scripts/install-template.sh b/modular/scripts/install-template.sh new file mode 100755 index 0000000..e41dcbe --- /dev/null +++ b/modular/scripts/install-template.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +name="CleanApiStarter.Template.0.0.0.nupkg" +pkg="$root/artifacts/$name" + +cd "$root" + +dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true +dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" +dotnet new install "$pkg" --force +rm -f "$pkg" diff --git a/modular/scripts/test-coverage.sh b/modular/scripts/test-coverage.sh new file mode 100755 index 0000000..d7f0b9d --- /dev/null +++ b/modular/scripts/test-coverage.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +coverage_dir="$repo_root/artifacts/coverage" +test_results_dir="$coverage_dir/test-results" +coverage_report_dir="$coverage_dir/report" + +cd "$repo_root" + +rm -rf "$coverage_dir" +mkdir -p "$test_results_dir" "$coverage_report_dir" + +dotnet tool restore + +dotnet test CleanApiStarter.slnx \ + --collect:"XPlat Code Coverage" \ + --disable-build-servers \ + --results-directory "$test_results_dir" \ + --logger trx \ + /m:1 \ + /nr:false \ + -v:minimal + +coverage_files=() + +while IFS= read -r coverage_file; do + coverage_files+=("$coverage_file") +done < <(find "$test_results_dir" -path "*/In/*" -prune -o -type f -name "coverage.cobertura.xml" -print | sort) + +if [ "${#coverage_files[@]}" -eq 0 ]; then + echo "No coverage.cobertura.xml files were generated." + exit 1 +fi + +coverage_reports="$(IFS=';'; echo "${coverage_files[*]}")" + +dotnet reportgenerator \ + "-reports:$coverage_reports" \ + "-targetdir:$coverage_report_dir" \ + "-reporttypes:Html" \ + "-title:CleanApiStarter Coverage" + +coverage_index="$coverage_report_dir/index.html" + +echo "Coverage report: $coverage_index" + +case "$(uname -s)" in + Darwin) + open "$coverage_index" + ;; + Linux) + if command -v xdg-open > /dev/null && [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + xdg-open "$coverage_index" + fi + ;; + MINGW*|MSYS*|CYGWIN*) + cmd.exe //c start "" "$(cygpath -w "$coverage_index")" + ;; +esac diff --git a/modular/src/CleanApiStarter.Api/Api.http b/modular/src/CleanApiStarter.Api/Api.http new file mode 100644 index 0000000..07f57a3 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Api.http @@ -0,0 +1,88 @@ +@CleanApiStarter_Api_HostAddress = https://localhost:7285 +@AccessToken = paste-api-jwt-here + +POST {{CleanApiStarter_Api_HostAddress}}/api/auth/google +Content-Type: application/json + +{ + "idToken": "paste-google-id-token-here" +} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects?limit=20&offset=0 +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{CleanApiStarter_Api_HostAddress}}/api/projects +Content-Type: application/json +Authorization: Bearer {{AccessToken}} + +{ + "name": "Clean API Starter", + "description": "Template implementation work." +} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000 +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks +Content-Type: application/json +Authorization: Bearer {{AccessToken}} + +{ + "title": "Add observability defaults", + "description": "Configure logs, traces, metrics, and request ids.", + "dueDate": "2026-12-31T00:00:00Z" +} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks?limit=20&offset=0&status=Todo +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +PUT {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks/00000000-0000-0000-0000-000000000000 +Content-Type: application/json +Authorization: Bearer {{AccessToken}} + +{ + "title": "Add observability defaults", + "description": "Configure logs, traces, metrics, and request ids.", + "status": "InProgress", + "dueDate": "2026-12-31T00:00:00Z" +} + +### + +POST {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks/00000000-0000-0000-0000-000000000000/complete +Authorization: Bearer {{AccessToken}} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects?limit=20&offset=0 +Accept: application/json +X-Api-Version: 2.0 +Authorization: Bearer {{AccessToken}} + +### + +DELETE {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{AccessToken}} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/auth/me +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### diff --git a/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj new file mode 100644 index 0000000..21e2e3a --- /dev/null +++ b/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -0,0 +1,28 @@ + + + + CleanApiStarter.Api + CleanApiStarter.Api + clean-api-starter + NSDEPCOP01 + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/modular/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs b/modular/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs new file mode 100644 index 0000000..9747b92 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs @@ -0,0 +1,7 @@ +namespace CleanApiStarter.Api.Common.Interfaces; + +public interface IUser +{ + string? Id { get; } + List? Roles { get; } +} diff --git a/modular/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs b/modular/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs new file mode 100644 index 0000000..e7af6b4 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Common.Models; + +public sealed class ArrayResult +{ + public required IReadOnlyCollection Items { get; init; } + + public int Count => Items.Count; +} diff --git a/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs b/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs new file mode 100644 index 0000000..17feb6b --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Common.Models; + +public sealed class PaginatedQuery +{ + public int Limit { get; set; } = 20; + + public int Offset { get; set; } +} diff --git a/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs b/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs new file mode 100644 index 0000000..e5516e5 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Api.Common.Models; + +public sealed class PaginatedQueryValidator : AbstractValidator +{ + public PaginatedQueryValidator() + { + RuleFor(query => query.Limit) + .InclusiveBetween(1, 100); + + RuleFor(query => query.Offset) + .GreaterThanOrEqualTo(0); + } +} diff --git a/modular/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs b/modular/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs new file mode 100644 index 0000000..d40ede1 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs @@ -0,0 +1,27 @@ +namespace CleanApiStarter.Api.Common.Models; + +public sealed class PaginatedResult +{ + public required IReadOnlyCollection Items { get; init; } + + public required int Limit { get; init; } + + public required int Offset { get; init; } + + public required int TotalCount { get; init; } + + public bool HasPreviousPage => Offset > 0; + + public bool HasNextPage => Offset + Limit < TotalCount; + + public PaginatedResult Map(Func map) + { + return new PaginatedResult + { + Items = Items.Select(map).ToArray(), + Limit = Limit, + Offset = Offset, + TotalCount = TotalCount + }; + } +} diff --git a/modular/src/CleanApiStarter.Api/DependencyInjection.cs b/modular/src/CleanApiStarter.Api/DependencyInjection.cs new file mode 100644 index 0000000..b745e75 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/DependencyInjection.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Api; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); + services.AddScoped(); + + return services; + } +} diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/Project.cs b/modular/src/CleanApiStarter.Api/Domain/Entities/Project.cs new file mode 100644 index 0000000..e5d562e --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Domain/Entities/Project.cs @@ -0,0 +1,18 @@ +namespace CleanApiStarter.Api.Domain.Entities; + +public sealed class Project +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required string OwnerUserId { get; init; } + + public required DateTime CreatedAt { get; init; } + + public List Members { get; init; } = []; + + public List Tasks { get; init; } = []; +} diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs b/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs new file mode 100644 index 0000000..c3ec29e --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Api.Domain.Entities; + +public sealed class ProjectMember +{ + public required Guid ProjectId { get; init; } + + public required string UserId { get; init; } + + public required DateTime CreatedAt { get; init; } + + public Project Project { get; init; } = null!; +} diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs b/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs new file mode 100644 index 0000000..3fc5ebb --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs @@ -0,0 +1,22 @@ +namespace CleanApiStarter.Api.Domain.Entities; + +public sealed class ProjectTask +{ + public required Guid Id { get; init; } + + public required Guid ProjectId { get; init; } + + public required string Title { get; set; } + + public required string Description { get; set; } + + public ProjectTaskStatus Status { get; set; } + + public DateTime? DueDate { get; set; } + + public required DateTime CreatedAt { get; init; } + + public DateTime? CompletedAt { get; set; } + + public Project Project { get; init; } = null!; +} diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs b/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs new file mode 100644 index 0000000..b59bcef --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Domain.Entities; + +public enum ProjectTaskStatus +{ + Todo = 0, + InProgress = 1, + Done = 2 +} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs new file mode 100644 index 0000000..516c2a1 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs @@ -0,0 +1,100 @@ +namespace CleanApiStarter.Api.Endpoints; + +public static class GoogleLoginPage +{ + public static WebApplication MapGoogleLoginPage(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return app; + } + + app.MapGet("/auth/google-login", (AppSettings appSettings) => + { + string clientId = HtmlEncoder.Default.Encode(appSettings.Authentication.Google.ClientId); + + string html = + $$""" + + + + + + Google Login + + + + +

Google Login

+
+
+ +
+

API JWT

+ + +
+
Sign in with Google to generate a token.
+ + + + + """; + + return Results.Content(html, "text/html"); + }) + .AllowAnonymous() + .WithName("GoogleLoginPage"); + + return app; + } +} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs new file mode 100644 index 0000000..dc064cb --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs @@ -0,0 +1,39 @@ +namespace CleanApiStarter.Api.Endpoints.V1; + +public sealed class Auth : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/auth"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapPost("/google", SignInWithGoogle) + .AllowAnonymous() + .WithName("SignInWithGoogleV1"); + + groupBuilder.MapGet("/me", GetCurrentUser) + .RequireAuthorization() + .WithName("GetCurrentUserV1"); + } + + private static async Task SignInWithGoogle( + GoogleSignInDto signInDto, + IAuthService authService, + CancellationToken cancellationToken) + { + AuthTokenDto token = await authService.SignInWithGoogleAsync(signInDto, cancellationToken); + + return Results.Ok(token); + } + + private static async Task GetCurrentUser( + ClaimsPrincipal principal, + IAuthService authService, + CancellationToken cancellationToken) + { + CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); + + return Results.Ok(currentUser); + } +} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs new file mode 100644 index 0000000..c55c4a9 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs @@ -0,0 +1,176 @@ +namespace CleanApiStarter.Api.Endpoints.V1; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapPost("/", CreateProject) + .WithName("CreateProjectV1"); + + groupBuilder.MapGet("/", GetProjects) + .WithName("GetProjectsV1"); + + groupBuilder.MapGet("/{id:guid}", GetProject) + .WithName("GetProjectV1"); + + groupBuilder.MapDelete("/{id:guid}", DeleteProject) + .WithName("DeleteProjectV1"); + + groupBuilder.MapPost("/{projectId:guid}/tasks", CreateTask) + .WithName("CreateProjectTaskV1"); + + groupBuilder.MapGet("/{projectId:guid}/tasks", GetTasks) + .WithName("GetProjectTasksV1"); + + groupBuilder.MapGet("/{projectId:guid}/tasks/{taskId:guid}", GetTask) + .WithName("GetProjectTaskV1"); + + groupBuilder.MapPut("/{projectId:guid}/tasks/{taskId:guid}", UpdateTask) + .WithName("UpdateProjectTaskV1"); + + groupBuilder.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", CompleteTask) + .WithName("CompleteProjectTaskV1"); + + groupBuilder.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", DeleteTask) + .WithName("DeleteProjectTaskV1"); + } + + private static async Task CreateProject( + CreateProjectDto projectDto, + IProjectService projectService, + CancellationToken cancellationToken) + { + Guid id = await projectService.CreateProjectAsync(projectDto, cancellationToken); + + return Results.Created($"/api/projects/{id}", id); + } + + private static async Task GetProjects( + [AsParameters] PaginatedQuery query, + IProjectService projectService, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); + + return Results.Ok(projects); + } + + private static async Task GetProject( + Guid id, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectDto? project = await projectService.GetProjectAsync(id, cancellationToken); + + return project == null ? Results.NotFound() : Results.Ok(project); + } + + private static async Task DeleteProject( + Guid id, + IProjectService projectService, + CancellationToken cancellationToken) + { + DeleteProjectResult result = await projectService.DeleteProjectAsync(id, cancellationToken); + + return result switch + { + DeleteProjectResult.Deleted => Results.NoContent(), + DeleteProjectResult.Forbidden => Results.Forbid(), + _ => Results.NotFound() + }; + } + + private static async Task CreateTask( + Guid projectId, + CreateProjectTaskDto taskDto, + IProjectService projectService, + CancellationToken cancellationToken) + { + Guid? taskId = await projectService.CreateTaskAsync(projectId, taskDto, cancellationToken); + + return taskId == null + ? Results.NotFound() + : Results.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); + } + + private static async Task GetTasks( + Guid projectId, + [FromQuery] ProjectTaskStatus? status, + [AsParameters] PaginatedQuery query, + IProjectService projectService, + CancellationToken cancellationToken) + { + PaginatedResult? tasks = await projectService.GetTasksAsync( + projectId, + status, + query, + cancellationToken); + + return tasks == null ? Results.NotFound() : Results.Ok(tasks); + } + + private static async Task GetTask( + Guid projectId, + Guid taskId, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectTaskDto? task = await projectService.GetTaskAsync(projectId, taskId, cancellationToken); + + return task == null ? Results.NotFound() : Results.Ok(task); + } + + private static async Task UpdateTask( + Guid projectId, + Guid taskId, + UpdateProjectTaskDto taskDto, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectTaskMutationResult result = await projectService.UpdateTaskAsync( + projectId, + taskId, + taskDto, + cancellationToken); + + return result == ProjectTaskMutationResult.Success + ? Results.NoContent() + : Results.NotFound(); + } + + private static async Task CompleteTask( + Guid projectId, + Guid taskId, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectTaskMutationResult result = await projectService.CompleteTaskAsync(projectId, taskId, cancellationToken); + + return result switch + { + ProjectTaskMutationResult.Success => Results.NoContent(), + ProjectTaskMutationResult.AlreadyCompleted => Results.Conflict(new + { + detail = "Task is already completed." + }), + _ => Results.NotFound() + }; + } + + private static async Task DeleteTask( + Guid projectId, + Guid taskId, + IProjectService projectService, + CancellationToken cancellationToken) + { + bool deleted = await projectService.DeleteTaskAsync(projectId, taskId, cancellationToken); + + return deleted ? Results.NoContent() : Results.NotFound(); + } +} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs new file mode 100644 index 0000000..a0736b0 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Endpoints.V2; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 2; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet("/", GetProjects) + .WithName("GetProjectsV2"); + } + + private static async Task GetProjects( + [AsParameters] PaginatedQuery query, + IProjectService projectService, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); + + return Results.Ok(new + { + ApiVersion = "2.0", + projects.Items, + projects.Limit, + projects.Offset, + projects.TotalCount, + projects.HasPreviousPage, + projects.HasNextPage + }); + } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs b/modular/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs new file mode 100644 index 0000000..182836b --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public sealed class AuthTokenDto +{ + public required string AccessToken { get; init; } + + public required DateTimeOffset ExpiresAt { get; init; } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs b/modular/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs new file mode 100644 index 0000000..b6f0cd9 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public sealed class CurrentUserDto +{ + public required string UserId { get; init; } + + public required string Email { get; init; } + + public required string Name { get; init; } + + public IReadOnlyCollection Roles { get; init; } = []; +} diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs b/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs new file mode 100644 index 0000000..821a8ef --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs @@ -0,0 +1,6 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public sealed class GoogleSignInDto +{ + public required string IdToken { get; init; } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs b/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs new file mode 100644 index 0000000..66bfced --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public sealed class GoogleSignInDtoValidator : AbstractValidator +{ + public GoogleSignInDtoValidator() + { + RuleFor(signIn => signIn.IdToken) + .NotEmpty(); + } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs b/modular/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs new file mode 100644 index 0000000..db9f544 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public interface IAuthService +{ + Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); + + Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs b/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs new file mode 100644 index 0000000..d5dfbb7 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs @@ -0,0 +1,14 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class CreateProjectDtoValidator : AbstractValidator +{ + public CreateProjectDtoValidator() + { + RuleFor(project => project.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(project => project.Description) + .MaximumLength(2_000); + } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs b/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs new file mode 100644 index 0000000..fd275e8 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs @@ -0,0 +1,19 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class CreateProjectTaskDtoValidator : AbstractValidator +{ + public CreateProjectTaskDtoValidator() + { + RuleFor(task => task.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(task => task.Description) + .MaximumLength(4_000); + + RuleFor(task => task.DueDate) + .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) + .When(task => task.DueDate.HasValue) + .WithMessage("Due date must be in the future."); + } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs b/modular/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs new file mode 100644 index 0000000..44478fe --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public interface IProjectRepository +{ + Task AddProjectAsync(Project project, CancellationToken cancellationToken); + + Task GetProjectAsync(Guid id, string userId, CancellationToken cancellationToken); + + Task> GetProjectsAsync(string userId, PaginatedQuery query, CancellationToken cancellationToken); + + Task IsProjectMemberAsync(Guid projectId, string userId, CancellationToken cancellationToken); + + Task IsProjectOwnerAsync(Guid projectId, string userId, CancellationToken cancellationToken); + + Task ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken); + + Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); + + Task AddTaskAsync(ProjectTask task, CancellationToken cancellationToken); + + Task GetTaskAsync(Guid projectId, Guid taskId, string userId, CancellationToken cancellationToken); + + Task GetTaskForUpdateAsync(Guid projectId, Guid taskId, string userId, CancellationToken cancellationToken); + + Task> GetTasksAsync( + Guid projectId, + string userId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken); + + Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs b/modular/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs new file mode 100644 index 0000000..9569d13 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public interface IProjectService +{ + Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken); + + Task GetProjectAsync(Guid id, CancellationToken cancellationToken); + + Task> GetProjectsAsync(PaginatedQuery query, CancellationToken cancellationToken); + + Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); + + Task CreateTaskAsync(Guid projectId, CreateProjectTaskDto taskDto, CancellationToken cancellationToken); + + Task GetTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); + + Task?> GetTasksAsync( + Guid projectId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken); + + Task UpdateTaskAsync( + Guid projectId, + Guid taskId, + UpdateProjectTaskDto taskDto, + CancellationToken cancellationToken); + + Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); + + Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs new file mode 100644 index 0000000..30294a1 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs @@ -0,0 +1,21 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class ProjectDto +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required string OwnerUserId { get; init; } + + public required DateTime CreatedAt { get; init; } +} + +public sealed class CreateProjectDto +{ + public required string Name { get; init; } + + public required string Description { get; init; } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs new file mode 100644 index 0000000..ae436bb --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs @@ -0,0 +1,15 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public enum DeleteProjectResult +{ + Deleted, + NotFound, + Forbidden +} + +public enum ProjectTaskMutationResult +{ + Success, + NotFound, + AlreadyCompleted +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs new file mode 100644 index 0000000..5f71197 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs @@ -0,0 +1,248 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class ProjectService( + IProjectRepository projectRepository, + IUser currentUser, + ILogger logger) : IProjectService +{ + public async Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + DateTime now = DateTime.UtcNow; + Guid projectId = Guid.NewGuid(); + Project project = new() + { + Id = projectId, + Name = projectDto.Name, + Description = projectDto.Description, + OwnerUserId = userId, + CreatedAt = now, + Members = + [ + new ProjectMember + { + ProjectId = projectId, + UserId = userId, + CreatedAt = now + } + ] + }; + + Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); + + logger.LogInformation("Created project {ProjectId} for user {UserId}", createdProjectId, userId); + + return createdProjectId; + } + + public async Task GetProjectAsync(Guid id, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + Project? project = await projectRepository.GetProjectAsync(id, userId, cancellationToken); + + return project == null ? null : MapProject(project); + } + + public async Task> GetProjectsAsync( + PaginatedQuery query, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + PaginatedResult projects = await projectRepository.GetProjectsAsync(userId, query, cancellationToken); + + logger.LogInformation( + "Retrieved {ProjectCount} projects for user {UserId} with limit {Limit} and offset {Offset}", + projects.Items.Count, + userId, + projects.Limit, + projects.Offset); + + return projects.Map(MapProject); + } + + public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken); + + if (!isMember) + { + return DeleteProjectResult.NotFound; + } + + bool isOwner = await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken); + + if (!isOwner) + { + return DeleteProjectResult.Forbidden; + } + + bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); + + return deleted ? DeleteProjectResult.Deleted : DeleteProjectResult.NotFound; + } + + public async Task CreateTaskAsync( + Guid projectId, + CreateProjectTaskDto taskDto, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); + + if (!isMember) + { + return null; + } + + ProjectTask task = new() + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = taskDto.Title, + Description = taskDto.Description, + Status = ProjectTaskStatus.Todo, + DueDate = taskDto.DueDate, + CreatedAt = DateTime.UtcNow + }; + + Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); + + logger.LogInformation("Created task {TaskId} in project {ProjectId}", taskId, projectId); + + return taskId; + } + + public async Task GetTaskAsync( + Guid projectId, + Guid taskId, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + ProjectTask? task = await projectRepository.GetTaskAsync(projectId, taskId, userId, cancellationToken); + + return task == null ? null : MapTask(task); + } + + public async Task?> GetTasksAsync( + Guid projectId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); + + if (!isMember) + { + return null; + } + + PaginatedResult tasks = await projectRepository.GetTasksAsync( + projectId, + userId, + status, + query, + cancellationToken); + + return tasks.Map(MapTask); + } + + public async Task UpdateTaskAsync( + Guid projectId, + Guid taskId, + UpdateProjectTaskDto taskDto, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); + + if (task == null) + { + return ProjectTaskMutationResult.NotFound; + } + + task.Title = taskDto.Title; + task.Description = taskDto.Description; + task.DueDate = taskDto.DueDate; + task.Status = taskDto.Status; + task.CompletedAt = taskDto.Status == ProjectTaskStatus.Done + ? task.CompletedAt ?? DateTime.UtcNow + : null; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return ProjectTaskMutationResult.Success; + } + + public async Task CompleteTaskAsync( + Guid projectId, + Guid taskId, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); + + if (task == null) + { + return ProjectTaskMutationResult.NotFound; + } + + if (task.Status == ProjectTaskStatus.Done) + { + return ProjectTaskMutationResult.AlreadyCompleted; + } + + task.Status = ProjectTaskStatus.Done; + task.CompletedAt = DateTime.UtcNow; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return ProjectTaskMutationResult.Success; + } + + public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); + + if (!isMember) + { + return false; + } + + return await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); + } + + private string GetCurrentUserId() + { + return currentUser.Id ?? throw new UnauthorizedAccessException(); + } + + private static ProjectDto MapProject(Project project) + { + return new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + OwnerUserId = project.OwnerUserId, + CreatedAt = project.CreatedAt + }; + } + + private static ProjectTaskDto MapTask(ProjectTask task) + { + return new ProjectTaskDto + { + Id = task.Id, + ProjectId = task.ProjectId, + Title = task.Title, + Description = task.Description, + Status = task.Status, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt + }; + } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs new file mode 100644 index 0000000..e663046 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class ProjectTaskDto +{ + public required Guid Id { get; init; } + + public required Guid ProjectId { get; init; } + + public required string Title { get; init; } + + public required string Description { get; init; } + + public required ProjectTaskStatus Status { get; init; } + + public DateTime? DueDate { get; init; } + + public required DateTime CreatedAt { get; init; } + + public DateTime? CompletedAt { get; init; } +} + +public sealed class CreateProjectTaskDto +{ + public required string Title { get; init; } + + public required string Description { get; init; } + + public DateTime? DueDate { get; init; } +} + +public sealed class UpdateProjectTaskDto +{ + public required string Title { get; init; } + + public required string Description { get; init; } + + public required ProjectTaskStatus Status { get; init; } + + public DateTime? DueDate { get; init; } +} diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs b/modular/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs new file mode 100644 index 0000000..e6b9068 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class UpdateProjectTaskDtoValidator : AbstractValidator +{ + public UpdateProjectTaskDtoValidator() + { + RuleFor(task => task.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(task => task.Description) + .MaximumLength(4_000); + + RuleFor(task => task.Status) + .IsInEnum(); + + RuleFor(task => task.DueDate) + .Must((task, dueDate) => !dueDate.HasValue + || task.Status == ProjectTaskStatus.Done + || dueDate.Value > DateTime.UtcNow) + .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) + .WithMessage("Due date must be in the future."); + } +} diff --git a/modular/src/CleanApiStarter.Api/GlobalUsings.cs b/modular/src/CleanApiStarter.Api/GlobalUsings.cs new file mode 100644 index 0000000..8538edb --- /dev/null +++ b/modular/src/CleanApiStarter.Api/GlobalUsings.cs @@ -0,0 +1,37 @@ +// Global using directives + +global using System.IdentityModel.Tokens.Jwt; +global using System.Reflection; +global using System.Security.Claims; +global using System.Text; +global using System.Text.Encodings.Web; + +global using CleanApiStarter.Api; +global using CleanApiStarter.Api.Common.Interfaces; +global using CleanApiStarter.Api.Common.Models; +global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Api.Endpoints; +global using CleanApiStarter.Api.Features.Auth; +global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Infrastructure; +global using CleanApiStarter.Api.Infrastructure.Identity; +global using CleanApiStarter.Api.Infrastructure.Persistence; +global using CleanApiStarter.Api.Infrastructure.Repositories; +global using CleanApiStarter.Api.Services; +global using CleanApiStarter.AspNetCore; +global using CleanApiStarter.Configuration; + +global using FluentValidation; + +global using Google.Apis.Auth; + +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Identity; +global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Microsoft.IdentityModel.Tokens; diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs b/modular/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..e853d8a --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs @@ -0,0 +1,22 @@ +namespace CleanApiStarter.Api.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddDbContext((serviceProvider, options) => + { + AppSettings appSettings = serviceProvider.GetRequiredService(); + options.UseNpgsql(appSettings.ConnectionStrings.Postgres); + }); + + services.AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores(); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..ac813bf --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,3 @@ +namespace CleanApiStarter.Api.Infrastructure.Identity; + +public sealed class ApplicationUser : IdentityUser; diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs new file mode 100644 index 0000000..17d3c13 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs @@ -0,0 +1,151 @@ +namespace CleanApiStarter.Api.Infrastructure.Identity; + +public sealed class GoogleAuthService( + AppSettings appSettings, + UserManager userManager, + RoleManager roleManager) : IAuthService +{ + private const string GoogleLoginProvider = "Google"; + private const string DefaultRole = "User"; + + public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken) + { + GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken); + ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken); + IList roles = await userManager.GetRolesAsync(user); + DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes); + + return new AuthTokenDto + { + AccessToken = CreateAccessToken(user, payload.Name, roles, expiresAt), + ExpiresAt = expiresAt + }; + } + + public Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) + { + string userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + string email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty; + string name = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty; + string[] roles = principal.FindAll(ClaimTypes.Role) + .Select(claim => claim.Value) + .ToArray(); + + return Task.FromResult(new CurrentUserDto + { + UserId = userId, + Email = email, + Name = name, + Roles = roles + }); + } + + private async Task ValidateGoogleTokenAsync(string idToken) + { + GoogleJsonWebSignature.ValidationSettings validationSettings = new() + { + Audience = [appSettings.Authentication.Google.ClientId] + }; + + return await GoogleJsonWebSignature.ValidateAsync(idToken, validationSettings); + } + + private async Task FindOrCreateUserAsync( + GoogleJsonWebSignature.Payload payload, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(payload.Email) || !payload.EmailVerified) + { + throw new UnauthorizedAccessException("Google account email must be verified."); + } + + ApplicationUser? user = await userManager.FindByLoginAsync(GoogleLoginProvider, payload.Subject); + + if (user == null) + { + user = await userManager.FindByEmailAsync(payload.Email); + } + + if (user == null) + { + user = new ApplicationUser + { + UserName = payload.Email, + Email = payload.Email, + EmailConfirmed = payload.EmailVerified + }; + + IdentityResult createResult = await userManager.CreateAsync(user); + ThrowIfFailed(createResult); + } + + UserLoginInfo googleLogin = new(GoogleLoginProvider, payload.Subject, GoogleLoginProvider); + IList logins = await userManager.GetLoginsAsync(user); + + if (!logins.Any(login => login.LoginProvider == GoogleLoginProvider && login.ProviderKey == payload.Subject)) + { + IdentityResult loginResult = await userManager.AddLoginAsync(user, googleLogin); + ThrowIfFailed(loginResult); + } + + await EnsureDefaultRoleAsync(user); + + return user; + } + + private async Task EnsureDefaultRoleAsync(ApplicationUser user) + { + if (!await roleManager.RoleExistsAsync(DefaultRole)) + { + IdentityResult roleResult = await roleManager.CreateAsync(new IdentityRole(DefaultRole)); + ThrowIfFailed(roleResult); + } + + if (!await userManager.IsInRoleAsync(user, DefaultRole)) + { + IdentityResult userRoleResult = await userManager.AddToRoleAsync(user, DefaultRole); + ThrowIfFailed(userRoleResult); + } + } + + private string CreateAccessToken( + ApplicationUser user, + string? googleName, + IEnumerable roles, + DateTimeOffset expiresAt) + { + JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; + SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)); + SigningCredentials signingCredentials = new(securityKey, SecurityAlgorithms.HmacSha256); + List claims = + [ + new(JwtRegisteredClaimNames.Sub, user.Id), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email ?? string.Empty), + new(ClaimTypes.Name, googleName ?? user.UserName ?? string.Empty) + ]; + + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + JwtSecurityToken token = new( + issuer: jwtSettings.Issuer, + audience: jwtSettings.Audience, + claims: claims, + expires: expiresAt.UtcDateTime, + signingCredentials: signingCredentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static void ThrowIfFailed(IdentityResult result) + { + if (result.Succeeded) + { + return; + } + + string errors = string.Join("; ", result.Errors.Select(error => error.Description)); + throw new InvalidOperationException(errors); + } +} diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs new file mode 100644 index 0000000..ad3b1ca --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs @@ -0,0 +1,18 @@ +namespace CleanApiStarter.Api.Infrastructure.Persistence; + +public sealed class ApplicationDbContext(DbContextOptions options) + : IdentityDbContext(options) +{ + public DbSet Projects => Set(); + + public DbSet ProjectMembers => Set(); + + public DbSet ProjectTasks => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + } +} diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs new file mode 100644 index 0000000..e913b36 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -0,0 +1,31 @@ +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; + +public sealed class ProjectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("projects"); + + builder.HasKey(project => project.Id); + + builder.Property(project => project.Id) + .HasColumnName("id"); + + builder.Property(project => project.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + builder.Property(project => project.Description) + .HasColumnName("description") + .IsRequired(); + + builder.Property(project => project.OwnerUserId) + .HasColumnName("owner_user_id") + .HasMaxLength(450) + .IsRequired(); + + builder.Property(project => project.CreatedAt) + .HasColumnName("created_at"); + } +} diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs new file mode 100644 index 0000000..aee6cf2 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -0,0 +1,30 @@ +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; + +public sealed class ProjectMemberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("project_members"); + + builder.HasKey(member => new + { + member.ProjectId, + member.UserId + }); + + builder.Property(member => member.ProjectId) + .HasColumnName("project_id"); + + builder.Property(member => member.UserId) + .HasColumnName("user_id") + .HasMaxLength(450); + + builder.Property(member => member.CreatedAt) + .HasColumnName("created_at"); + + builder.HasOne(member => member.Project) + .WithMany(project => project.Members) + .HasForeignKey(member => member.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs new file mode 100644 index 0000000..1571b9c --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs @@ -0,0 +1,46 @@ +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; + +public sealed class ProjectTaskConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("project_tasks"); + + builder.HasKey(task => task.Id); + + builder.Property(task => task.Id) + .HasColumnName("id"); + + builder.Property(task => task.ProjectId) + .HasColumnName("project_id"); + + builder.Property(task => task.Title) + .HasColumnName("title") + .HasMaxLength(150) + .IsRequired(); + + builder.Property(task => task.Description) + .HasColumnName("description") + .IsRequired(); + + builder.Property(task => task.Status) + .HasColumnName("status") + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(task => task.DueDate) + .HasColumnName("due_date"); + + builder.Property(task => task.CreatedAt) + .HasColumnName("created_at"); + + builder.Property(task => task.CompletedAt) + .HasColumnName("completed_at"); + + builder.HasOne(task => task.Project) + .WithMany(project => project.Tasks) + .HasForeignKey(task => task.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs b/modular/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs new file mode 100644 index 0000000..c955914 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs @@ -0,0 +1,158 @@ +namespace CleanApiStarter.Api.Infrastructure.Repositories; + +public sealed class ProjectRepository(ApplicationDbContext dbContext) : IProjectRepository +{ + public async Task AddProjectAsync(Project project, CancellationToken cancellationToken) + { + dbContext.Projects.Add(project); + await dbContext.SaveChangesAsync(cancellationToken); + + return project.Id; + } + + public async Task GetProjectAsync(Guid id, string userId, CancellationToken cancellationToken) + { + return await dbContext.Projects + .AsNoTracking() + .Where(project => project.Id == id) + .Where(project => project.Members.Any(member => member.UserId == userId)) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task> GetProjectsAsync( + string userId, + PaginatedQuery query, + CancellationToken cancellationToken) + { + IQueryable projectsQuery = dbContext.Projects + .AsNoTracking() + .Where(project => project.Members.Any(member => member.UserId == userId)) + .OrderBy(project => project.Name); + + int totalCount = await projectsQuery.CountAsync(cancellationToken); + List projects = await projectsQuery + .Skip(query.Offset) + .Take(query.Limit) + .ToListAsync(cancellationToken); + + return new PaginatedResult + { + Items = projects, + Limit = query.Limit, + Offset = query.Offset, + TotalCount = totalCount + }; + } + + public async Task IsProjectMemberAsync(Guid projectId, string userId, CancellationToken cancellationToken) + { + return await dbContext.ProjectMembers + .AnyAsync(member => member.ProjectId == projectId && member.UserId == userId, cancellationToken); + } + + public async Task IsProjectOwnerAsync(Guid projectId, string userId, CancellationToken cancellationToken) + { + return await dbContext.Projects + .AnyAsync(project => project.Id == projectId && project.OwnerUserId == userId, cancellationToken); + } + + public async Task ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken) + { + return await dbContext.Projects + .AnyAsync(project => project.Id == projectId, cancellationToken); + } + + public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) + { + int rowsAffected = await dbContext.Projects + .Where(project => project.Id == id) + .ExecuteDeleteAsync(cancellationToken); + + return rowsAffected > 0; + } + + public async Task AddTaskAsync(ProjectTask task, CancellationToken cancellationToken) + { + dbContext.ProjectTasks.Add(task); + await dbContext.SaveChangesAsync(cancellationToken); + + return task.Id; + } + + public async Task GetTaskAsync( + Guid projectId, + Guid taskId, + string userId, + CancellationToken cancellationToken) + { + return await ProjectTasksForMember(projectId, userId) + .AsNoTracking() + .SingleOrDefaultAsync(task => task.Id == taskId, cancellationToken); + } + + public async Task GetTaskForUpdateAsync( + Guid projectId, + Guid taskId, + string userId, + CancellationToken cancellationToken) + { + return await ProjectTasksForMember(projectId, userId) + .SingleOrDefaultAsync(task => task.Id == taskId, cancellationToken); + } + + public async Task> GetTasksAsync( + Guid projectId, + string userId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken) + { + IQueryable tasksQuery = ProjectTasksForMember(projectId, userId); + + if (status.HasValue) + { + tasksQuery = tasksQuery.Where(task => task.Status == status.Value); + } + + tasksQuery = tasksQuery + .AsNoTracking() + .OrderBy(task => task.Status) + .ThenBy(task => task.DueDate) + .ThenBy(task => task.Title); + + int totalCount = await tasksQuery.CountAsync(cancellationToken); + List tasks = await tasksQuery + .Skip(query.Offset) + .Take(query.Limit) + .ToListAsync(cancellationToken); + + return new PaginatedResult + { + Items = tasks, + Limit = query.Limit, + Offset = query.Offset, + TotalCount = totalCount + }; + } + + public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) + { + int rowsAffected = await dbContext.ProjectTasks + .Where(task => task.ProjectId == projectId && task.Id == taskId) + .ExecuteDeleteAsync(cancellationToken); + + return rowsAffected > 0; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + return await dbContext.SaveChangesAsync(cancellationToken); + } + + private IQueryable ProjectTasksForMember(Guid projectId, string userId) + { + return dbContext.ProjectTasks + .Where(task => task.ProjectId == projectId) + .Where(task => task.Project.Members.Any(member => member.UserId == userId)); + } +} diff --git a/modular/src/CleanApiStarter.Api/Program.cs b/modular/src/CleanApiStarter.Api/Program.cs new file mode 100644 index 0000000..4637d70 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Program.cs @@ -0,0 +1,20 @@ +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddAspNetCoreDefaults(); + +builder.Services.AddAppSettings(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); +builder.Services.AddScoped(); + +WebApplication app = builder.Build(); + +app.UseAspNetCoreDefaults(); +app.MapOpenApiDocumentation(); +app.MapGoogleLoginPage(); +app.MapEndpoints(Assembly.GetExecutingAssembly()); +app.MapDefaultEndpoints(); + +app.Run(); + +public partial class Program; diff --git a/modular/src/CleanApiStarter.Api/Properties/launchSettings.json b/modular/src/CleanApiStarter.Api/Properties/launchSettings.json new file mode 100644 index 0000000..b182d09 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/modular/src/CleanApiStarter.Api/Services/CurrentUser.cs b/modular/src/CleanApiStarter.Api/Services/CurrentUser.cs new file mode 100644 index 0000000..37c0828 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Services/CurrentUser.cs @@ -0,0 +1,9 @@ +namespace CleanApiStarter.Api.Services; + +public class CurrentUser(IHttpContextAccessor httpContextAccessor) : IUser +{ + 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/modular/src/CleanApiStarter.Api/appsettings.Development.json b/modular/src/CleanApiStarter.Api/appsettings.Development.json new file mode 100644 index 0000000..68309f2 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/appsettings.Development.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + }, + "Authentication": { + "Google": { + "ClientId": "" + }, + "Jwt": { + "Issuer": "CleanApiStarter", + "Audience": "CleanApiStarter.Api", + "SigningKey": "development-only-signing-key-change-me", + "ExpirationMinutes": 60 + } + } +} diff --git a/modular/src/CleanApiStarter.Api/appsettings.json b/modular/src/CleanApiStarter.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/modular/src/CleanApiStarter.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/modular/src/CleanApiStarter.Api/config.nsdepcop b/modular/src/CleanApiStarter.Api/config.nsdepcop new file mode 100644 index 0000000..f157c6e --- /dev/null +++ b/modular/src/CleanApiStarter.Api/config.nsdepcop @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj new file mode 100644 index 0000000..8fd8e8e --- /dev/null +++ b/modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + CleanApiStarter.AppHost + CleanApiStarter.AppHost + clean-api-starter-apphost + + + + + + + + + + + + + + + + diff --git a/modular/src/CleanApiStarter.AppHost/Program.cs b/modular/src/CleanApiStarter.AppHost/Program.cs new file mode 100644 index 0000000..7c5b157 --- /dev/null +++ b/modular/src/CleanApiStarter.AppHost/Program.cs @@ -0,0 +1,19 @@ +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); +string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); + +IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") + .WithImageTag("18") + .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") + .WithInitFiles(databaseMigrationsPath) + .WithPgAdmin(); + +IResourceBuilder database = postgresServer.AddDatabase("postgres") + .WithCreationScript("SELECT 1;"); + +builder.AddProject("api") + .WithReference(database) + .WaitFor(database); + +builder.Build().Run(); diff --git a/modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json b/modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..046e309 --- /dev/null +++ b/modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17134;http://localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21030", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057" + } + } + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs new file mode 100644 index 0000000..2e7e5bc --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Authorization; + +namespace CleanApiStarter.AspNetCore; + +public static partial class Extensions +{ + private static void AddSecurityDefaults(this IHostApplicationBuilder builder) + { + builder.Services.Configure( + options => options.AddServerHeader = false); + + builder.Services.AddHttpContextAccessor(); + } + + private static void AddProblemDetailsDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddExceptionHandler(); + builder.Services.AddProblemDetails(); + } + + private static void AddApiVersioningDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version"); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + }) + .AddOpenApi(); + } + + private static void AddAuthenticationDefaults(this WebApplicationBuilder builder) + { + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + builder.Services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + }); + + builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((options, appSettings) => + { + JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + } + + private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) + { + builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); + + builder.Services.AddHttpLogging(options => + { + options.CombineLogs = true; + options.LoggingFields = + HttpLoggingFields.RequestMethod | + HttpLoggingFields.RequestPath | + HttpLoggingFields.ResponseStatusCode | + HttpLoggingFields.Duration; + }); + + builder.Services.AddHttpLoggingInterceptor(); + } + + private static void AddResponseCompressionDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( + [ + "application/json", + "application/problem+json" + ]); + }); + + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); + + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); + } + + private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj new file mode 100644 index 0000000..a27ae2d --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj @@ -0,0 +1,32 @@ + + + + CleanApiStarter.AspNetCore + CleanApiStarter.AspNetCore + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modular/src/CleanApiStarter.AspNetCore/Extensions.cs b/modular/src/CleanApiStarter.AspNetCore/Extensions.cs new file mode 100644 index 0000000..ca8c5b6 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -0,0 +1,75 @@ +namespace CleanApiStarter.AspNetCore; + +public static partial class Extensions +{ + public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder) + { + builder.Host.UseDefaultServiceProvider((_, options) => + { + options.ValidateOnBuild = true; + options.ValidateScopes = true; + }); + + builder.ConfigureOpenTelemetry(); + builder.AddSecurityDefaults(); + builder.AddProblemDetailsDefaults(); + builder.AddApiVersioningDefaults(); + builder.AddAuthenticationDefaults(); + builder.AddResponseCompressionDefaults(); + builder.AddHttpLoggingDefaults(); + builder.AddServiceDiscoveryDefaults(); + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication UseAspNetCoreDefaults(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseMiddleware(); + app.UseExceptionHandler(); + app.UseResponseCompression(); + app.UseAuthentication(); + app.UseHttpLogging(); + app.UseAuthorization(); + + return app; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapGet("/version", () => + { + Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly; + AssemblyInformationalVersionAttribute? informationalVersion = assembly + .GetCustomAttribute(); + + return Results.Ok(new + { + Application = app.Environment.ApplicationName, + Version = assembly.GetName().Version?.ToString(), + informationalVersion?.InformationalVersion + }); + }) + .WithName("GetVersion") + .WithTags("Version") + .AllowAnonymous(); + + app.MapHealthChecks("/health") + .AllowAnonymous(); + + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live") + }) + .AllowAnonymous(); + + return app; + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs new file mode 100644 index 0000000..1a87110 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -0,0 +1,38 @@ +global using System.Diagnostics; +global using System.IdentityModel.Tokens.Jwt; +global using System.IO.Compression; +global using System.Reflection; +global using System.Security.Claims; +global using System.Text; + +global using Asp.Versioning; +global using Asp.Versioning.ApiExplorer; +global using Asp.Versioning.Builder; + +global using CleanApiStarter.Configuration; + +global using FluentValidation; +global using FluentValidation.Results; + +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Diagnostics; +global using Microsoft.AspNetCore.Diagnostics.HealthChecks; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.HttpLogging; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.ResponseCompression; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.IdentityModel.Tokens; + +global using Npgsql; + +global using OpenTelemetry.Logs; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Trace; + +global using Scalar.AspNetCore; diff --git a/modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs new file mode 100644 index 0000000..66a2135 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.AspNetCore; + +public interface IEndpointGroup +{ + static virtual int MajorVersion => 1; + + static virtual string? RoutePrefix => null; + + static abstract void Map(RouteGroupBuilder groupBuilder); +} diff --git a/modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs new file mode 100644 index 0000000..fba74c4 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.AspNetCore; + +public static class OpenApiDocumentationExtensions +{ + public static WebApplication MapOpenApiDocumentation(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return app; + } + + app.MapOpenApi() + .WithDocumentPerVersion() + .AllowAnonymous(); + + app.MapScalarApiReference(options => + { + IReadOnlyList descriptions = app.DescribeApiVersions(); + + for (int index = 0; index < descriptions.Count; index++) + { + ApiVersionDescription description = descriptions[index]; + bool isDefault = index == descriptions.Count - 1; + + options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); + } + }) + .AllowAnonymous(); + + return app; + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs new file mode 100644 index 0000000..70733a2 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs @@ -0,0 +1,41 @@ +namespace CleanApiStarter.AspNetCore; + +public static partial class Extensions +{ + private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + logging.ParseStateValues = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsqlInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsql(); + }); + + bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + return builder; + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs new file mode 100644 index 0000000..ee1e506 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs @@ -0,0 +1,82 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class ProblemDetailsExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + if (exception is OperationCanceledException && httpContext.RequestAborted.IsCancellationRequested) + { + logger.LogDebug( + "Request {RequestMethod} {RequestPath} was canceled by the client", + httpContext.Request.Method, + httpContext.Request.Path); + + // 499 Client Closed Request (nginx convention); no body since the client is gone. + httpContext.Response.StatusCode = 499; + + return true; + } + + (int statusCode, ProblemDetails problemDetails) = exception switch + { + BadHttpRequestException badRequestException => ( + StatusCodes.Status400BadRequest, + new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Bad Request", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", + Detail = badRequestException.Message + }), + UnauthorizedAccessException => ( + StatusCodes.Status401Unauthorized, + new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2" + }), + _ => ( + StatusCodes.Status500InternalServerError, + new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", + Detail = "An unexpected error occurred." + }) + }; + + if (statusCode >= StatusCodes.Status500InternalServerError) + { + logger.LogError( + exception, + "Unhandled exception while processing {RequestMethod} {RequestPath}", + httpContext.Request.Method, + httpContext.Request.Path); + } + else + { + logger.LogWarning( + exception, + "Handled exception with status {StatusCode} while processing {RequestMethod} {RequestPath}", + statusCode, + httpContext.Request.Method, + httpContext.Request.Path); + } + + httpContext.Response.StatusCode = statusCode; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = problemDetails, + Exception = exception + }); + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs new file mode 100644 index 0000000..003a9ef --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs @@ -0,0 +1,23 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class RequestIdMiddleware(RequestDelegate next) +{ + public const string HeaderName = "X-Request-ID"; + + public async Task InvokeAsync(HttpContext context) + { + context.Response.OnStarting(() => + { + context.Response.Headers[HeaderName] = GetRequestId(context); + + return Task.CompletedTask; + }); + + await next(context); + } + + private static string GetRequestId(HttpContext context) + { + return Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs new file mode 100644 index 0000000..81c7243 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs @@ -0,0 +1,28 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor +{ + private const string UserIdParameterName = "UserId"; + private const string AnonymousUserId = "anonymous"; + + public ValueTask OnRequestAsync(HttpLoggingInterceptorContext logContext) + { + logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext)); + + return ValueTask.CompletedTask; + } + + public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) + { + logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext)); + + return ValueTask.CompletedTask; + } + + private static string GetUserId(HttpContext httpContext) + { + return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? AnonymousUserId; + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs new file mode 100644 index 0000000..63b2cc0 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs @@ -0,0 +1,45 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class ValidationFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + List validationFailures = []; + + foreach (object? argument in context.Arguments) + { + if (argument == null) + { + continue; + } + + Type validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); + object? validator = context.HttpContext.RequestServices.GetService(validatorType); + + if (validator is not IValidator fluentValidator) + { + continue; + } + + ValidationContext validationContext = new(argument); + ValidationResult validationResult = await fluentValidator.ValidateAsync( + validationContext, + context.HttpContext.RequestAborted); + + validationFailures.AddRange(validationResult.Errors); + } + + if (validationFailures.Count == 0) + { + return await next(context); + } + + Dictionary errors = validationFailures + .GroupBy(failure => failure.PropertyName) + .ToDictionary( + group => group.Key, + group => group.Select(failure => failure.ErrorMessage).ToArray()); + + return Results.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); + } +} diff --git a/modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs new file mode 100644 index 0000000..87e5166 --- /dev/null +++ b/modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.AspNetCore; + +public static class WebApplicationExtensions +{ + public static WebApplication MapEndpoints(this WebApplication app, Assembly assembly) + { + IEnumerable endpointGroupTypes = assembly.GetExportedTypes() + .Where(type => type is { IsAbstract: false, IsInterface: false } + && type.IsAssignableTo(typeof(IEndpointGroup))); + + foreach (Type type in endpointGroupTypes) + { + ApiVersion apiVersion = GetApiVersion(type); + string groupName = type.Name; + string routePrefix = type.GetProperty(nameof(IEndpointGroup.RoutePrefix))?.GetValue(null) as string + ?? $"/api/{groupName}"; + ApiVersionSet versionSet = app.NewApiVersionSet() + .HasApiVersion(apiVersion) + .ReportApiVersions() + .Build(); + + RouteGroupBuilder group = app.MapGroup(routePrefix) + .WithTags(groupName) + .WithApiVersionSet(versionSet) + .MapToApiVersion(apiVersion) + .AddEndpointFilter(); + + type.GetMethod(nameof(IEndpointGroup.Map))!.Invoke(null, [group]); + } + + return app; + } + + private static ApiVersion GetApiVersion(Type endpointGroupType) + { + int majorVersion = endpointGroupType.GetProperty(nameof(IEndpointGroup.MajorVersion))?.GetValue(null) as int? ?? 1; + + return new ApiVersion(majorVersion); + } +} diff --git a/modular/src/CleanApiStarter.Configuration/AppSettings.cs b/modular/src/CleanApiStarter.Configuration/AppSettings.cs new file mode 100644 index 0000000..19cdf16 --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/AppSettings.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Configuration; + +public sealed class AppSettings +{ + [Required] + [ValidateObjectMembers] + public ConnectionStringSettings ConnectionStrings { get; set; } = new(); + + [Required] + [ValidateObjectMembers] + public AuthenticationSettings Authentication { get; set; } = new(); +} diff --git a/modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs new file mode 100644 index 0000000..24f76fa --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Configuration; + +public sealed class AuthenticationSettings +{ + [Required] + [ValidateObjectMembers] + public GoogleAuthenticationSettings Google { get; set; } = new(); + + [Required] + [ValidateObjectMembers] + public JwtAuthenticationSettings Jwt { get; set; } = new(); +} diff --git a/modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj new file mode 100644 index 0000000..002e9d2 --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj @@ -0,0 +1,15 @@ + + + + CleanApiStarter.Configuration + CleanApiStarter.Configuration + + + + + + + + + + diff --git a/modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs new file mode 100644 index 0000000..1299016 --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs @@ -0,0 +1,7 @@ +namespace CleanApiStarter.Configuration; + +public sealed class ConnectionStringSettings +{ + [Required] + public string Postgres { get; set; } = string.Empty; +} diff --git a/modular/src/CleanApiStarter.Configuration/GlobalUsings.cs b/modular/src/CleanApiStarter.Configuration/GlobalUsings.cs new file mode 100644 index 0000000..a2250c9 --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.ComponentModel.DataAnnotations; + +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Options; diff --git a/modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs new file mode 100644 index 0000000..b6f8aec --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs @@ -0,0 +1,7 @@ +namespace CleanApiStarter.Configuration; + +public sealed class GoogleAuthenticationSettings +{ + [Required] + public string ClientId { get; set; } = string.Empty; +} diff --git a/modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs new file mode 100644 index 0000000..083c6d8 --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs @@ -0,0 +1,17 @@ +namespace CleanApiStarter.Configuration; + +public sealed class JwtAuthenticationSettings +{ + [Required] + public string Issuer { get; set; } = string.Empty; + + [Required] + public string Audience { get; set; } = string.Empty; + + [Required] + [MinLength(32)] + public string SigningKey { get; set; } = string.Empty; + + [Range(1, 1440)] + public int ExpirationMinutes { get; set; } = 60; +} diff --git a/modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs new file mode 100644 index 0000000..6b767c7 --- /dev/null +++ b/modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs @@ -0,0 +1,19 @@ +namespace CleanApiStarter.Configuration; + +public static class OptionsRegistrationExtensions +{ + public static IServiceCollection AddAppSettings( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return services; + } +} diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj new file mode 100644 index 0000000..275bbf0 --- /dev/null +++ b/modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj @@ -0,0 +1,29 @@ + + + + CleanApiStarter.Api.IntegrationTests + CleanApiStarter.Api.IntegrationTests + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs new file mode 100644 index 0000000..bc17707 --- /dev/null +++ b/modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; + +public sealed class ProjectsTests : IClassFixture> +{ + private readonly HttpClient _client; + + public ProjectsTests(ApiApplicationFactory applicationFactory) + { + _client = applicationFactory.CreateClient(); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + applicationFactory.CreateAccessToken("integration-test-user")); + } + + [Fact] + public async Task Projects_PostAuthenticatedRequest_CreatesProject() + { + // Arrange + object request = new + { + Name = "Integration test project", + Description = "Created through the API integration test host" + }; + + // Act + HttpResponseMessage response = await _client.PostAsJsonAsync("/api/projects", request, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Created); + + Guid projectId = await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); + projectId.ShouldNotBe(Guid.Empty); + + response.Headers.Location.ShouldNotBeNull(); + response.Headers.Location!.ToString().ShouldContain($"/api/projects/{projectId}"); + } +} diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..d1837d5 --- /dev/null +++ b/modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System.Net; +global using System.Net.Http.Headers; +global using System.Net.Http.Json; + +global using CleanApiStarter.Tests.Common; + +global using Shouldly; + +global using Xunit; diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json new file mode 100644 index 0000000..8fdbc0f --- /dev/null +++ b/modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + }, + "Authentication": { + "Google": { + "ClientId": "integration-tests" + }, + "Jwt": { + "Issuer": "CleanApiStarter.Tests", + "Audience": "CleanApiStarter.Api.Tests", + "SigningKey": "integration-tests-signing-key-change-me", + "ExpirationMinutes": 60 + } + } +} diff --git a/modular/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/modular/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj new file mode 100644 index 0000000..7b7ad99 --- /dev/null +++ b/modular/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + CleanApiStarter.Application.UnitTests + CleanApiStarter.Application.UnitTests + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modular/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/modular/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs new file mode 100644 index 0000000..b39323a --- /dev/null +++ b/modular/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.Api.UnitTests.Features.Projects; + +public sealed class ProjectServiceTests +{ + [Theory] + [AutoNSubstituteData] + public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyCompleted( + Guid projectId, + Guid taskId, + string userId, + [Frozen] IUser currentUser, + [Frozen] IProjectRepository projectRepository, + ProjectService sut) + { + // Arrange + CancellationToken cancellationToken = CancellationToken.None; + ProjectTask task = new() + { + Id = taskId, + ProjectId = projectId, + Title = "Write first unit test", + Description = "Cover already completed tasks", + Status = ProjectTaskStatus.Done, + CreatedAt = DateTime.UtcNow, + CompletedAt = DateTime.UtcNow + }; + + currentUser.Id.Returns(userId); + projectRepository + .GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken) + .Returns(Task.FromResult(task)); + + // Act + ProjectTaskMutationResult result = await sut.CompleteTaskAsync(projectId, taskId, cancellationToken); + + // Assert + result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); + _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); + } +} diff --git a/modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..32fce59 --- /dev/null +++ b/modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using AutoFixture.Xunit3; + +global using CleanApiStarter.Api.Common.Interfaces; +global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Tests.Common; + +global using Microsoft.Extensions.Logging; + +global using NSubstitute; + +global using Shouldly; + +global using Xunit; diff --git a/modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj new file mode 100644 index 0000000..3167dbc --- /dev/null +++ b/modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj @@ -0,0 +1,22 @@ + + + + CleanApiStarter.Tests + CleanApiStarter.Tests + + + + + + + + + + + + + + + + + diff --git a/modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs new file mode 100644 index 0000000..01f01a0 --- /dev/null +++ b/modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs @@ -0,0 +1,100 @@ +namespace CleanApiStarter.Tests.Common; + +public sealed class ApiApplicationFactory : WebApplicationFactory, IAsyncLifetime + where TProgram : class +{ + private const string TestIssuer = "CleanApiStarter.Tests"; + private const string TestAudience = "CleanApiStarter.Api.Tests"; + private const string TestSigningKey = "integration-tests-signing-key-change-me"; + + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:18") + .WithDatabase("postgres") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + public async ValueTask InitializeAsync() + { + await postgres.StartAsync(); + await RunDatabaseScriptsAsync(); + } + + public string CreateAccessToken(string userId) + { + SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(TestSigningKey)); + SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256); + List claims = + [ + new(JwtRegisteredClaimNames.Sub, userId), + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Email, $"{userId}@example.test"), + new(ClaimTypes.Name, userId), + new(ClaimTypes.Role, "User") + ]; + + JwtSecurityToken token = new( + issuer: TestIssuer, + audience: TestAudience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(60), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration(configurationBuilder => + { + string settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.Testing.json"); + Dictionary connectionStringOverride = new() + { + ["ConnectionStrings:Postgres"] = postgres.GetConnectionString() + }; + + configurationBuilder.AddJsonFile(settingsPath, optional: false); + configurationBuilder.AddInMemoryCollection(connectionStringOverride); + }); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + private async Task RunDatabaseScriptsAsync() + { + await using NpgsqlConnection connection = new(postgres.GetConnectionString()); + await connection.OpenAsync(); + + foreach (string scriptPath in GetDatabaseScriptPaths()) + { + string sql = await File.ReadAllTextAsync(scriptPath); + + await using NpgsqlCommand command = new(sql, connection); + await command.ExecuteNonQueryAsync(); + } + } + + private static IEnumerable GetDatabaseScriptPaths() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + + while (directory != null) + { + string migrationsPath = Path.Combine(directory.FullName, "database", "migrations"); + + if (Directory.Exists(migrationsPath)) + { + return Directory.GetFiles(migrationsPath, "*.sql").Order(StringComparer.Ordinal); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find database/migrations from the test output directory."); + } +} diff --git a/modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs new file mode 100644 index 0000000..1ff103c --- /dev/null +++ b/modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Tests.Common; + +public sealed class AutoNSubstituteDataAttribute() : AutoDataAttribute(CreateFixture) +{ + private static IFixture CreateFixture() + { + Fixture fixture = new(); + + fixture.Customize(new AutoNSubstituteCustomization()); + + return fixture; + } +} diff --git a/modular/tests/CleanApiStarter.Tests/GlobalUsings.cs b/modular/tests/CleanApiStarter.Tests/GlobalUsings.cs new file mode 100644 index 0000000..5d1b365 --- /dev/null +++ b/modular/tests/CleanApiStarter.Tests/GlobalUsings.cs @@ -0,0 +1,18 @@ +global using System.IdentityModel.Tokens.Jwt; +global using System.Security.Claims; +global using System.Text; + +global using AutoFixture; +global using AutoFixture.AutoNSubstitute; +global using AutoFixture.Xunit3; + +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Mvc.Testing; +global using Microsoft.Extensions.Configuration; +global using Microsoft.IdentityModel.Tokens; + +global using Npgsql; + +global using Testcontainers.PostgreSql; + +global using Xunit; From 852d7c5f1e1640e416d3bde0d65c3423280daf8b Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Wed, 17 Jun 2026 20:50:33 +0800 Subject: [PATCH 03/18] chore: shared database, two-variant CI, and docs refresh - Move database/ to the repo root as the single source of truth; each variant gets a git-ignored copy via scripts/sync-database.sh (CI and install scripts run it before building/packing). Removes the duplicated, drift-prone copies. - Rework CI as a matrix over [layered, modular]: build.yml, template.yml (packs + instantiates each variant by shortName/package), codeql.yml, release.yml (publishes both NuGet packages). dependabot.yml now watches both variant dirs. - Ship a per-variant generated-project build.yml (restores CI that generated projects lost when the repo .github moved to the root). - Fix per-variant install-template.sh for the new package ids + sync step. - Refresh CONTRIBUTING.md for the two-variant workflow; give modular its own README; update the root README (modular is available, database sync documented). Verified: both variants build, and both pack -> install -> instantiate -> the generated project builds, with migrations and a build workflow shipped. --- .github/dependabot.yml | 4 +- .github/workflows/build.yml | 17 +- .github/workflows/codeql.yml | 11 +- .github/workflows/release.yml | 18 +- .github/workflows/template.yml | 25 +- CONTRIBUTING.md | 121 +++- README.md | 20 +- ...V001__create_projects_and_tasks_tables.sql | 0 .../V002__create_identity_tables.sql | 0 layered/.github/workflows/build.yml | 30 + layered/.gitignore | 3 + layered/scripts/install-template.sh | 9 +- modular/.github/workflows/build.yml | 30 + modular/.gitignore | 3 + modular/README.md | 599 ++---------------- ...V001__create_projects_and_tasks_tables.sql | 34 - .../V002__create_identity_tables.sql | 91 --- modular/scripts/install-template.sh | 9 +- scripts/sync-database.sh | 24 + 19 files changed, 309 insertions(+), 739 deletions(-) rename {layered/database => database}/migrations/V001__create_projects_and_tasks_tables.sql (100%) rename {layered/database => database}/migrations/V002__create_identity_tables.sql (100%) create mode 100644 layered/.github/workflows/build.yml create mode 100644 modular/.github/workflows/build.yml delete mode 100644 modular/database/migrations/V001__create_projects_and_tasks_tables.sql delete mode 100644 modular/database/migrations/V002__create_identity_tables.sql create mode 100755 scripts/sync-database.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c390d1d..dfaa31f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,9 @@ version: 2 updates: - package-ecosystem: nuget - directory: "/" + directories: + - "/layered" + - "/modular" schedule: interval: weekly groups: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 111c83c..a5ce968 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,9 +8,18 @@ on: jobs: build: - name: Build and test + name: Build and test (${{ matrix.variant }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + variant: [layered, modular] + + defaults: + run: + working-directory: ${{ matrix.variant }} + steps: - name: Checkout uses: actions/checkout@v6 @@ -18,7 +27,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: global.json + global-json-file: ${{ matrix.variant }}/global.json + + - name: Sync database + working-directory: ${{ github.workspace }} + run: ./scripts/sync-database.sh - name: Restore run: dotnet restore CleanApiStarter.slnx diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5dfba3b..1a02522 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,9 +15,14 @@ permissions: jobs: analyze: - name: Analyze + name: Analyze (${{ matrix.variant }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + variant: [layered, modular] + steps: - name: Checkout uses: actions/checkout@v6 @@ -25,7 +30,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: global.json + global-json-file: ${{ matrix.variant }}/global.json - name: Initialize CodeQL uses: github/codeql-action/init@v4 @@ -35,9 +40,11 @@ jobs: queries: security-extended,security-and-quality - name: Restore + working-directory: ${{ matrix.variant }} run: dotnet restore CleanApiStarter.slnx - name: Build + working-directory: ${{ matrix.variant }} run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - name: Analyze diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0715734..2cea56a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,14 @@ permissions: jobs: publish: - name: Publish to NuGet.org + name: Publish ${{ matrix.variant }} to NuGet.org runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + variant: [layered, modular] + steps: - name: Checkout uses: actions/checkout@v6 @@ -19,27 +24,32 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: global.json + global-json-file: ${{ matrix.variant }}/global.json - name: Extract version id: version run: echo "value=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + - name: Sync database + run: ./scripts/sync-database.sh + - name: Update template version + working-directory: ${{ matrix.variant }} run: | jq --arg v "${{ steps.version.outputs.value }}" \ '.symbols.caPackageVersion.defaultValue = $v' \ .template.config/template.json > tmp.json && mv tmp.json .template.config/template.json - name: Pack template + working-directory: ${{ matrix.variant }} env: RELEASE_NOTES: ${{ github.event.release.body }} - run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts -p:PackageVersion=${{ steps.version.outputs.value }} -p:PackageReleaseNotes="$RELEASE_NOTES" + run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output ../artifacts -p:PackageVersion=${{ steps.version.outputs.value }} -p:PackageReleaseNotes="$RELEASE_NOTES" - name: Upload package artifact uses: actions/upload-artifact@v7 with: - name: nuget-package + name: nuget-package-${{ matrix.variant }} path: artifacts/*.nupkg - name: Publish to NuGet diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index 981cc62..de46752 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -8,9 +8,20 @@ on: jobs: template: - name: Verify template output + name: Verify template output (${{ matrix.variant }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: layered + shortName: clean-api-layered + package: CleanApiStarter.Template.Layered + - variant: modular + shortName: clean-api-modular + package: CleanApiStarter.Template.Modular + steps: - name: Checkout uses: actions/checkout@v6 @@ -18,16 +29,20 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: global.json + global-json-file: ${{ matrix.variant }}/global.json + + - name: Sync database + run: ./scripts/sync-database.sh - name: Pack template - run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts + working-directory: ${{ matrix.variant }} + run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output ../artifacts - name: Install template - run: dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force + run: dotnet new install artifacts/${{ matrix.package }}.0.0.0.nupkg --force - name: Create sample - run: dotnet new clean-api-starter -n DemoProduct -o artifacts/DemoProduct + run: dotnet new ${{ matrix.shortName }} -n DemoProduct -o artifacts/DemoProduct - name: Restore sample working-directory: artifacts/DemoProduct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6681dac..d484683 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,84 +1,137 @@ # Contributing to CleanApiStarter -Thanks for your interest in contributing. This repository is both a working reference application and a packaged `dotnet new` template, so changes are verified in both forms. +Thanks for your interest in contributing. This repository ships **two `dotnet new` +template variants** that share documentation and a single source of truth for the +database schema: + +- [`layered/`](layered/) — multi-project Clean Architecture (`clean-api-layered`). +- [`modular/`](modular/) — single business project with NsDepCop-enforced + boundaries (`clean-api-modular`). + +Each variant is a self-contained solution. A change usually touches one variant; +some (docs, the database schema, CI) span both. See +[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) +for the reasoning behind the split. ## Prerequisites -- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: latestFeature` applies). -- **Docker** — required to run the API integration tests (Testcontainers) and local PostgreSQL. -- No IDE requirement; the solution file is [CleanApiStarter.slnx](CleanApiStarter.slnx). +- **.NET SDK** matching each variant's `global.json` (10.0.2xx; `rollForward: + latestFeature` applies). +- **Docker** — required for the integration tests (Testcontainers) and local + PostgreSQL. ## Getting started ```bash git clone https://github.com/cbjpdev/clean-api-starter.git cd clean-api-starter + +# Populate each variant's database/ from the shared root copy (see below). +./scripts/sync-database.sh + +# Work inside whichever variant you're changing: +cd layered # or: cd modular dotnet build CleanApiStarter.slnx dotnet test CleanApiStarter.slnx ``` -To run the API locally, use the Aspire AppHost (starts PostgreSQL with the schema scripts applied, plus pgAdmin): +To run a variant's API locally via Aspire (starts PostgreSQL with the schema +applied, plus pgAdmin): ```bash +cd layered # or: cd modular dotnet run --project src/CleanApiStarter.AppHost ``` -Alternatively, start PostgreSQL with `docker compose up -d` and run `src/CleanApiStarter.Api` directly. - -See the [README](README.md) for the solution layout and layer dependency rules. The short version: dependencies point inward — `Domain` references nothing, `Application` references only `Domain`, and web/persistence concerns stay in `Api`, `AspNetCore`, and `Infrastructure`. - ## Making changes -1. Branch from `main` (the convention here is `feature/-short-description`). +1. Branch from `main` (convention: `feature/-short-description`). 2. Keep changes focused; unrelated refactoring belongs in its own PR. -3. Open a pull request against `main`. CI must pass before review. +3. If a change is conceptually shared (the domain model, the database schema, a + platform default), apply it to **both** variants so they don't drift. +4. Open a pull request against `main`. CI must pass before review. ### What CI enforces -The same checks run on every PR ([build.yml](.github/workflows/build.yml), [template.yml](.github/workflows/template.yml), [codeql.yml](.github/workflows/codeql.yml)): +Every workflow runs as a matrix over both variants +([build.yml](.github/workflows/build.yml), +[template.yml](.github/workflows/template.yml), +[codeql.yml](.github/workflows/codeql.yml)): -- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run `dotnet format CleanApiStarter.slnx` before pushing; style rules live in [.editorconfig](.editorconfig). -- **Build and tests** — the full suite, including the Testcontainers-based integration tests. -- **Template verification** — the template is packed, installed, and instantiated to confirm generated projects still build. -- **CodeQL** security scanning. +- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run + `dotnet format CleanApiStarter.slnx` in the variant before pushing; style rules + live in each variant's `.editorconfig`. +- **Build and tests** — the full suite per variant, including the + Testcontainers-based integration tests. +- **Template verification** — each variant is packed, installed, and instantiated + to confirm generated projects build. +- **CodeQL** security scanning per variant. ## Tests -- Unit tests: `tests/CleanApiStarter.Application.UnitTests` (xUnit v3, AutoFixture, NSubstitute, Shouldly). -- Integration tests: `tests/CleanApiStarter.Api.IntegrationTests` (xUnit v3 against a real PostgreSQL container; Docker must be running). -- Shared test infrastructure (the `ApiApplicationFactory` class fixture, AutoFixture attributes) lives in `tests/CleanApiStarter.Tests`. +- Unit tests: `/tests/CleanApiStarter.Application.UnitTests` (xUnit v3, + AutoFixture, NSubstitute, Shouldly). +- Integration tests: `/tests/CleanApiStarter.Api.IntegrationTests` + (xUnit v3 against a real PostgreSQL container; Docker must be running). +- Shared test infrastructure (the `ApiApplicationFactory` class fixture) + lives in `/tests/CleanApiStarter.Tests`. -New behavior needs tests. Integration tests share one database per test class, so isolate through unique user ids or resource names rather than assuming a fresh schema. +New behavior needs tests. The integration test factory is an `IClassFixture`, so +all tests in a class share one database — isolate through unique user ids or +resource names rather than assuming a fresh schema. -For a local coverage report: - -```bash -./scripts/test-coverage.sh -``` +For a local coverage report: `cd && ./scripts/test-coverage.sh`. ## Database changes -The SQL scripts in `database/migrations` are the schema's source of truth; the EF Core entity configurations in `src/CleanApiStarter.Infrastructure/Persistence/Configuration` must be kept in sync with them manually. When changing either, update both. +`database/migrations` **at the repository root is the single source of truth.** +Each variant gets its own git-ignored copy via +[`scripts/sync-database.sh`](scripts/sync-database.sh); never edit the variant +copies directly. After changing the root scripts, re-run the sync (CI does this +automatically before building or packing). + +The EF Core entity configurations in each variant +(`Infrastructure/Persistence/Configuration`) must be kept in sync with the SQL by +hand. When you change one, change the other — and apply it to both variants. Conventions: -- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC (`DateTime.UtcNow` in code). -- Migration files are named `V__description.sql` and are applied in file-name order by both the AppHost and the integration test factory. +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC + (`DateTime.UtcNow` in code). +- Migration files are named `V__description.sql` and applied in file-name + order by both the AppHost and the integration test factory. - Migrations must be idempotent (`CREATE TABLE IF NOT EXISTS`, etc.). +### Boundary enforcement + +- **Layered** enforces dependency direction through project references — a + violation simply doesn't compile. +- **Modular** enforces it with NsDepCop: `config.nsdepcop` in the `Api` project + lists illegal namespace directions and `WarningsAsErrors=NSDEPCOP01` makes a + violation a build error. Update `config.nsdepcop` when you add a layer or rule. + ## Template changes -If your change affects the project structure, file names, or anything under [.template.config/template.json](.template.config/template.json), verify the template output locally: +If a change affects project structure, file names, or a variant's +`.template.config/template.json`, verify that variant's output locally: ```bash -dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts -dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force -dotnet new clean-api-starter -n DemoProduct -o /tmp/DemoProduct +./scripts/sync-database.sh +cd layered # or: cd modular +dotnet pack CleanApiStarter.Template.csproj --configuration Release --output ../artifacts +# layered -> CleanApiStarter.Template.Layered / clean-api-layered +# modular -> CleanApiStarter.Template.Modular / clean-api-modular +dotnet new install ../artifacts/CleanApiStarter.Template.Layered.0.0.0.nupkg --force +dotnet new clean-api-layered -n DemoProduct -o /tmp/DemoProduct dotnet build /tmp/DemoProduct ``` -Repository-only files (CI release workflows, this guide, install scripts) must be listed in the `exclude` section of `template.json` so they don't ship inside generated projects. +Repository-only files (this guide, `docs/`, root CI/release workflows, install +scripts) must stay outside the variant folders or be listed in the variant's +`template.json` `exclude` section so they don't ship inside generated projects. ## Reporting issues -Open a GitHub issue with reproduction steps, expected vs. actual behavior, and your environment (OS, .NET SDK version). For security vulnerabilities, please do not open a public issue — contact the maintainer directly instead. +Open a GitHub issue with reproduction steps, expected vs. actual behavior, and +your environment (OS, .NET SDK version). For security vulnerabilities, please do +not open a public issue — contact the maintainer directly instead. diff --git a/README.md b/README.md index 67a652d..9de93f4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ giving up enforced dependency boundaries in either. | Variant | Folder | `dotnet new` | Shape | Boundaries enforced by | | --- | --- | --- | --- | --- | | **Layered** | [`layered/`](layered/) | `clean-api-layered` | Multi-project (Api, Application, Domain, Infrastructure, …) | Project references (compiler) | -| **Modular** | `modular/` *(in progress)* | `clean-api-modular` | One business project + reused platform projects | NsDepCop analyzer (build-breaking) | +| **Modular** | [`modular/`](modular/) | `clean-api-modular` | One business project + reused platform projects | NsDepCop analyzer (build-breaking) | Both are genuine Clean Architectures — they differ in feature organization and in *how* boundaries are enforced, not in whether dependencies point inward. See @@ -24,11 +24,11 @@ Slice Architecture and the modular monolith. ## Getting started -Each variant is a self-contained solution. Pick one and read its README: +Each variant is a self-contained solution. Pick one and read its README +([layered](layered/README.md), [modular](modular/README.md)): ```bash -# Layered (available now) -cd layered +cd layered # or: cd modular dotnet build CleanApiStarter.slnx ``` @@ -36,16 +36,24 @@ To generate a new project from a variant once the templates are published: ```bash dotnet new clean-api-layered -n MyApi -# dotnet new clean-api-modular -n MyApi (coming soon) +dotnet new clean-api-modular -n MyApi ``` +### Working in the repo + +The database schema lives once at the repo root (`database/`) and is synced into +each variant by `./scripts/sync-database.sh` (run it after cloning and after any +schema change). See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. + ## Repository layout ``` clean-api-starter/ +├── database/migrations/ ← single source of truth, synced into each variant ├── docs/architecture/ ← reasoning: Clean Architecture, vertical slices, duplication +├── scripts/ ← repo-level tooling (database sync) ├── layered/ ← variant 1: multi-project template (self-contained) -├── modular/ ← variant 2: single-project + NsDepCop template (in progress) +├── modular/ ← variant 2: single-project + NsDepCop template ├── CONTRIBUTING.md └── LICENSE ``` diff --git a/layered/database/migrations/V001__create_projects_and_tasks_tables.sql b/database/migrations/V001__create_projects_and_tasks_tables.sql similarity index 100% rename from layered/database/migrations/V001__create_projects_and_tasks_tables.sql rename to database/migrations/V001__create_projects_and_tasks_tables.sql diff --git a/layered/database/migrations/V002__create_identity_tables.sql b/database/migrations/V002__create_identity_tables.sql similarity index 100% rename from layered/database/migrations/V002__create_identity_tables.sql rename to database/migrations/V002__create_identity_tables.sql diff --git a/layered/.github/workflows/build.yml b/layered/.github/workflows/build.yml new file mode 100644 index 0000000..5d27005 --- /dev/null +++ b/layered/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore CleanApiStarter.slnx + + - name: Build + run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal + + - name: Test + run: dotnet test CleanApiStarter.slnx --no-build --configuration Release /nr:false -v:minimal diff --git a/layered/.gitignore b/layered/.gitignore index 0e2fcbc..2c00c02 100644 --- a/layered/.gitignore +++ b/layered/.gitignore @@ -403,3 +403,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .DS_Store + +# Synced from the repo-root database/ (single source of truth) by scripts/sync-database.sh +/database/ diff --git a/layered/scripts/install-template.sh b/layered/scripts/install-template.sh index e41dcbe..6b93962 100755 --- a/layered/scripts/install-template.sh +++ b/layered/scripts/install-template.sh @@ -2,12 +2,15 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -name="CleanApiStarter.Template.0.0.0.nupkg" -pkg="$root/artifacts/$name" +package="CleanApiStarter.Template.Layered" +pkg="$root/artifacts/$package.0.0.0.nupkg" cd "$root" -dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true +# Ensure the variant's database/ is populated from the shared root source. +../scripts/sync-database.sh + +dotnet new uninstall "$package" 2>/dev/null || true dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" dotnet new install "$pkg" --force rm -f "$pkg" diff --git a/modular/.github/workflows/build.yml b/modular/.github/workflows/build.yml new file mode 100644 index 0000000..5d27005 --- /dev/null +++ b/modular/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore CleanApiStarter.slnx + + - name: Build + run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal + + - name: Test + run: dotnet test CleanApiStarter.slnx --no-build --configuration Release /nr:false -v:minimal diff --git a/modular/.gitignore b/modular/.gitignore index 0e2fcbc..2c00c02 100644 --- a/modular/.gitignore +++ b/modular/.gitignore @@ -403,3 +403,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .DS_Store + +# Synced from the repo-root database/ (single source of truth) by scripts/sync-database.sh +/database/ diff --git a/modular/README.md b/modular/README.md index a8c68c4..ccf0d39 100644 --- a/modular/README.md +++ b/modular/README.md @@ -1,12 +1,25 @@ -# CleanApiStarter +# CleanApiStarter (Modular) -`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. +`CleanApiStarter` is a Clean Architecture API starter for .NET 10, ASP.NET Core +Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API +versioning, FluentValidation, Scalar, EF Core, and automated tests. -The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. +This is the **modular** variant: the whole application lives in a single +`CleanApiStarter.Api` project, organized by feature and layer folders, with the +Clean Architecture dependency rule enforced at compile time by +[NsDepCop](https://github.com/realvizu/NsDepCop) instead of separate projects. +The sample domain is project management: authenticated users create projects, +manage tasks, filter tasks by status, and complete them. + +> Prefer hard, project-reference boundaries? Use the **layered** variant +> (`clean-api-layered`) instead. ## Features -- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. +- Single application project with feature-centric organization and reusable + platform projects (`AspNetCore`, `Configuration`) plus an Aspire `AppHost`. +- Compile-time boundary enforcement via NsDepCop (`config.nsdepcop`, + `WarningsAsErrors=NSDEPCOP01`): a forbidden dependency fails the build. - Minimal APIs organized by endpoint group and API version folders. - Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. - Versioned OpenAPI documents shown with Scalar. @@ -23,10 +36,7 @@ The project is intentionally both a template and a working reference application - Response compression with Brotli and gzip. - FluentValidation endpoint validation returning `422 Unprocessable Entity`. - Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. -- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. -- xUnit v3 API integration tests with Testcontainers for PostgreSQL. -- Local coverage script that generates an HTML report with ReportGenerator. -- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. +- xUnit v3 unit and Testcontainers-backed integration tests. ## Solution Layout @@ -36,565 +46,46 @@ CleanApiStarter │ └── migrations ├── scripts ├── src -│ ├── CleanApiStarter.Api -│ ├── CleanApiStarter.Application -│ ├── CleanApiStarter.Domain -│ ├── CleanApiStarter.Infrastructure +│ ├── CleanApiStarter.Api ← the whole application +│ │ ├── config.nsdepcop ← enforced dependency rules +│ │ ├── Common ← shared kernel (paged results, IUser) +│ │ ├── Domain ← entities (no outward dependencies) +│ │ ├── Features ← Auth, Projects (endpoints + handlers + validators) +│ │ ├── Infrastructure ← DbContext, EF config, repositories, identity +│ │ ├── Endpoints, Services ← composition / web wiring +│ │ └── Program.cs │ └── Common -│ ├── CleanApiStarter.AppHost -│ ├── CleanApiStarter.AspNetCore -│ └── CleanApiStarter.Configuration +│ ├── CleanApiStarter.AppHost ← Aspire orchestration +│ ├── CleanApiStarter.AspNetCore ← reusable web/runtime defaults +│ └── CleanApiStarter.Configuration ← settings classes + options registration └── tests ├── CleanApiStarter.Api.IntegrationTests ├── CleanApiStarter.Application.UnitTests └── CleanApiStarter.Tests ``` -Layer dependencies point inward: - -- `Domain` references nothing. -- `Application` references `Domain`. -- `Infrastructure` references `Application` and `Configuration`. -- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- `Configuration` contains plain settings classes and options registration. -- `AspNetCore` contains reusable web/runtime defaults. -- `AppHost` contains Aspire orchestration. - -## Requirements - -- .NET SDK `10.0.203` or compatible latest feature SDK -- Docker Desktop -- Google Chrome, only for the local coverage script opening the HTML report - -The SDK is pinned in `global.json`: - -```json -{ - "sdk": { - "version": "10.0.203", - "rollForward": "latestFeature" - } -} -``` - -## Use As A Template - -Install the template package from NuGet: - -```bash -dotnet new install CleanApiStarter.Template -``` - -Create a new solution: - -```bash -dotnet new clean-api-starter -n MyProduct -``` - -The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. - -While developing the template locally, run: - -```bash -scripts/install-template.sh -``` - -That script: - -- uninstalls the previous local template package -- packs the current repo with `CleanApiStarter.Template.csproj` -- installs the generated local `.nupkg` -- deletes the temporary package from `artifacts` - -Then create a local test solution: - -```bash -dotnet new clean-api-starter -n DemoProduct -``` - -## Run With Aspire - -```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj -``` - -Aspire starts: - -- the API -- a PostgreSQL container using `postgres:latest` -- pgAdmin -- the Aspire dashboard - -Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. - -The AppHost configures PostgreSQL like this: - -- server resource: `postgres-server` -- database resource: `postgres` -- data volume: `clean-api-starter-postgres-data` -- volume mount: `/var/lib/postgresql` -- init scripts: `database/migrations` - -PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. - -## Run With Docker Compose - -If you want only PostgreSQL without Aspire: - -```bash -docker compose up -d -dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. - -## Database - -Database scripts live here: - -```text -database/migrations -├── V001__create_projects_and_tasks_tables.sql -└── V002__create_identity_tables.sql -``` - -The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. - -`ApplicationDbContext` lives in: - -```text -src/CleanApiStarter.Infrastructure/Persistence -``` - -EF Core mappings live in: - -```text -src/CleanApiStarter.Infrastructure/Persistence/Configuration -``` - -## API Style - -The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. - -Endpoint groups live under version folders: - -```text -src/CleanApiStarter.Api/Endpoints/V1 -src/CleanApiStarter.Api/Endpoints/V2 -``` - -Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: - -```csharp -app.MapEndpoints(Assembly.GetExecutingAssembly()); -``` - -Endpoint names are globally unique across versions, for example: - -```text -GetProjectsV1 -GetProjectsV2 -``` - -## API Versioning - -API versioning uses header-based version selection: - -```http -X-Api-Version: 1.0 -``` - -The header is optional. Requests without `X-Api-Version` use v1. - -The project uses the .NET 10 API versioning/OpenAPI package setup: - -- `Asp.Versioning.Http` -- `Asp.Versioning.Mvc.ApiExplorer` -- `Asp.Versioning.OpenApi` -- `Microsoft.AspNetCore.OpenApi` - -OpenAPI documents are generated per version and displayed in Scalar. - -## Scalar - -This project uses Scalar instead of Swagger/Swashbuckle. - -When running in Development, OpenAPI and Scalar are mapped by the API: - -- versioned OpenAPI documents -- Scalar API reference with version selection - -## Authentication - -Authentication is API-first. - -The intended production flow is: - -1. A client obtains a Google ID token. -2. The client sends it to `POST /api/auth/google`. -3. The API validates the Google ID token. -4. The API creates or updates the local ASP.NET Core Identity user. -5. The API issues its own JWT. -6. Protected API calls use: - -```http -Authorization: Bearer -``` - -The API does not use cookies as the default authentication mechanism. - -### Google Client ID - -Create an OAuth client in Google Cloud Console and set the client id with user secrets: - -```bash -dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -For local browser testing, open the development helper page: - -```text -https://localhost:7285/auth/google-login -``` - -The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. - -## Authorization - -Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. - -Project authorization rules are implemented in the application service and repository queries: - -- users can only see projects they belong to -- users can only see and manage tasks inside projects they belong to -- project deletion is owner-only - -## Application Features - -The reference feature is project and task management. - -Projects: - -- create project -- list current user's projects -- get project by id -- delete project as owner - -Tasks: - -- create task under a project -- list tasks with `limit` and `offset` -- filter tasks by status -- get task by id -- update task -- complete task -- delete task - -The application project is organized by feature: - -```text -src/CleanApiStarter.Application/Features/Auth -src/CleanApiStarter.Application/Features/Projects -``` - -Cross-cutting abstractions live under: - -```text -src/CleanApiStarter.Application/Common -``` - -## Response Shapes - -Single-resource endpoints return the resource DTO directly. - -Paginated endpoints return `PaginatedResult`: - -```json -{ - "items": [], - "limit": 20, - "offset": 0, - "totalCount": 0, - "hasPreviousPage": false, - "hasNextPage": false -} -``` - -Non-paginated collection endpoints should return `ArrayResult`: - -```json -{ - "items": [], - "count": 0 -} -``` - -## Validation - -Request validation uses FluentValidation, not DataAnnotations. - -Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. - -Validation failures return: - -```http -422 Unprocessable Entity -``` - -## Configuration - -Configuration classes live in `CleanApiStarter.Configuration`. - -The root configuration object is: - -```csharp -AppSettings -``` - -It includes: - -- `ConnectionStrings.Postgres` -- `Authentication.Google.ClientId` -- `Authentication.Jwt.Issuer` -- `Authentication.Jwt.Audience` -- `Authentication.Jwt.SigningKey` -- `Authentication.Jwt.ExpirationMinutes` - -The API registers settings once: - -```csharp -builder.Services.AddAppSettings(builder.Configuration); -``` - -Services can inject `AppSettings` directly. - -## Observability - -`CleanApiStarter.AspNetCore` centralizes runtime defaults: - -- OpenTelemetry traces -- OpenTelemetry metrics -- OpenTelemetry logs -- OTLP export for Aspire -- structured log formatting -- HTTP request logging -- health checks -- service discovery -- HTTP client resilience -- problem details -- response compression -- request id header -- Kestrel `Server` header removal - -When the API runs under Aspire, inspect the dashboard for: - -- API request traces -- Npgsql database traces -- structured logs -- request logs with `UserId` -- runtime metrics -- resource health - -Every response includes: - -```http -X-Request-ID: -``` - -Use this value to correlate client responses with logs and traces. - -## Health And Version Endpoints - -The shared ASP.NET Core defaults map: - -```text -/health -/alive -/version -``` - -`/version` exposes the running application version. - -## Testing - -### Unit Tests - -Application unit tests use: - -- xUnit v3 -- AutoFixture.xUnit3 -- AutoFixture.AutoNSubstitute -- NSubstitute -- Shouldly - -Common test helpers live in: - -```text -tests/CleanApiStarter.Tests -``` - -Current reusable helpers: - -- `AutoNSubstituteDataAttribute` -- `ApiApplicationFactory` - -Test names follow: - -```text -UnitOfWork_StateUnderTest_ExpectedBehavior -``` - -Tests use AAA sections: - -```csharp -// Arrange -// Act -// Assert -``` - -Run unit tests: - -```bash -dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj -``` - -### API Integration Tests +Dependency direction (enforced by `config.nsdepcop`): -API integration tests use: +- `Domain` depends on nothing else in the app — not `Features`, not `Infrastructure`. +- `Features` (application logic) may not depend on `Infrastructure`; it talks to + abstractions that `Infrastructure` implements and DI wires up. +- `Infrastructure`, `Endpoints`, and `Program.cs` form the composition root. +- `AspNetCore`, `Configuration`, and `AppHost` are reusable platform projects. -- xUnit v3 -- Microsoft.AspNetCore.Mvc.Testing -- Testcontainers for PostgreSQL -- Shouldly +A violation — for example `using` an `Infrastructure` type from `Domain` — fails +the build with `error NSDEPCOP01`. -The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. - -The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. - -Run integration tests: - -```bash -dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj -``` - -Run all tests: - -```bash -dotnet test CleanApiStarter.slnx -``` - -## Coverage - -Generate coverage and open the HTML report in Chrome: - -```bash -scripts/test-coverage.sh -``` - -The script: - -- deletes the previous `artifacts/coverage` folder -- restores local .NET tools -- runs tests with `XPlat Code Coverage` -- generates an HTML report with ReportGenerator -- opens the report in Google Chrome - -Coverage output: - -```text -artifacts/coverage/report/index.html -``` - -## Build - -Restore and build: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -## CI - -GitHub Actions workflows live in `.github/workflows`: - -- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. -- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. -- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. -- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. - -## Package Management - -Package versions are managed centrally in: - -```text -Directory.Packages.props -``` - -Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. - -## Coding Conventions - -- Use explicit local types. -- Use project-level `GlobalUsings.cs`. -- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. -- Use `required` and `init` for required non-null DTO and domain properties. -- Use nullable types only for genuinely optional values. -- Use structured logging message templates instead of interpolated log strings. -- Keep repository interfaces in `Application`. -- Keep repository implementations and EF Core details in `Infrastructure`. -- Keep domain models persistence-agnostic. -- Do not reintroduce Dapper for the default data access path. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. -- Do not add Swashbuckle; use Scalar. - -## Troubleshooting - -### Aspire certificate errors - -If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: - -```bash -dotnet dev-certs https --check --trust -``` - -If needed, reset and trust again: - -```bash -dotnet dev-certs https --clean -dotnet dev-certs https --trust -dotnet dev-certs https --check --trust -``` - -On macOS, accept the Keychain prompt and enter your password if requested. - -### PostgreSQL init scripts did not run +## Requirements -Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. +- .NET SDK `10.0.203` or a compatible latest-feature SDK (pinned in `global.json`). +- Docker Desktop (for integration tests and local PostgreSQL). -For Docker Compose: +## Getting started ```bash -docker compose down -v -docker compose up -d +dotnet run --project src/CleanApiStarter.AppHost # API + PostgreSQL via Aspire +# or +docker compose up -d && dotnet run --project src/CleanApiStarter.Api ``` -For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. - -### Postgres latest and data volumes - -This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: - -```text -/var/lib/postgresql -``` - -Do not mount the volume at: - -```text -/var/lib/postgresql/data -``` - -## License - -This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. +Run the tests with `dotnet test CleanApiStarter.slnx`. diff --git a/modular/database/migrations/V001__create_projects_and_tasks_tables.sql b/modular/database/migrations/V001__create_projects_and_tasks_tables.sql deleted file mode 100644 index 5c7b4f9..0000000 --- a/modular/database/migrations/V001__create_projects_and_tasks_tables.sql +++ /dev/null @@ -1,34 +0,0 @@ -CREATE TABLE IF NOT EXISTS projects ( - id UUID PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description TEXT NOT NULL, - owner_user_id VARCHAR(450) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL -); - -CREATE TABLE IF NOT EXISTS project_members ( - project_id UUID NOT NULL, - user_id VARCHAR(450) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT pk_project_members PRIMARY KEY (project_id, user_id), - CONSTRAINT fk_project_members_projects_project_id - FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS project_tasks ( - id UUID PRIMARY KEY, - project_id UUID NOT NULL, - title VARCHAR(150) NOT NULL, - description TEXT NOT NULL, - status VARCHAR(20) NOT NULL, - due_date TIMESTAMP WITH TIME ZONE NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - completed_at TIMESTAMP WITH TIME ZONE NULL, - CONSTRAINT fk_project_tasks_projects_project_id - FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_projects_owner_user_id ON projects(owner_user_id); -CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members(user_id); -CREATE INDEX IF NOT EXISTS idx_project_tasks_project_id ON project_tasks(project_id); -CREATE INDEX IF NOT EXISTS idx_project_tasks_project_id_status ON project_tasks(project_id, status); diff --git a/modular/database/migrations/V002__create_identity_tables.sql b/modular/database/migrations/V002__create_identity_tables.sql deleted file mode 100644 index 43c1f17..0000000 --- a/modular/database/migrations/V002__create_identity_tables.sql +++ /dev/null @@ -1,91 +0,0 @@ -CREATE TABLE IF NOT EXISTS "AspNetRoles" ( - "Id" TEXT PRIMARY KEY, - "Name" VARCHAR(256) NULL, - "NormalizedName" VARCHAR(256) NULL, - "ConcurrencyStamp" TEXT NULL -); - -CREATE TABLE IF NOT EXISTS "AspNetUsers" ( - "Id" TEXT PRIMARY KEY, - "UserName" VARCHAR(256) NULL, - "NormalizedUserName" VARCHAR(256) NULL, - "Email" VARCHAR(256) NULL, - "NormalizedEmail" VARCHAR(256) NULL, - "EmailConfirmed" BOOLEAN NOT NULL, - "PasswordHash" TEXT NULL, - "SecurityStamp" TEXT NULL, - "ConcurrencyStamp" TEXT NULL, - "PhoneNumber" TEXT NULL, - "PhoneNumberConfirmed" BOOLEAN NOT NULL, - "TwoFactorEnabled" BOOLEAN NOT NULL, - "LockoutEnd" TIMESTAMP WITH TIME ZONE NULL, - "LockoutEnabled" BOOLEAN NOT NULL, - "AccessFailedCount" INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( - "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "RoleId" TEXT NOT NULL, - "ClaimType" TEXT NULL, - "ClaimValue" TEXT NULL, - CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" - FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( - "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - "UserId" TEXT NOT NULL, - "ClaimType" TEXT NULL, - "ClaimValue" TEXT NULL, - CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" - FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( - "LoginProvider" TEXT NOT NULL, - "ProviderKey" TEXT NOT NULL, - "ProviderDisplayName" TEXT NULL, - "UserId" TEXT NOT NULL, - CONSTRAINT "PK_AspNetUserLogins" - PRIMARY KEY ("LoginProvider", "ProviderKey"), - CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" - FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( - "UserId" TEXT NOT NULL, - "RoleId" TEXT NOT NULL, - CONSTRAINT "PK_AspNetUserRoles" - PRIMARY KEY ("UserId", "RoleId"), - CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" - FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, - CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" - FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( - "UserId" TEXT NOT NULL, - "LoginProvider" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "Value" TEXT NULL, - CONSTRAINT "PK_AspNetUserTokens" - PRIMARY KEY ("UserId", "LoginProvider", "Name"), - CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" - FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail"); -CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName"); -CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName"); -CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId"); -CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId"); -CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId"); -CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId"); - -INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") -VALUES ('user', 'User', 'USER', gen_random_uuid()::text) -ON CONFLICT ("Id") DO NOTHING; - -INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") -VALUES ('admin', 'Admin', 'ADMIN', gen_random_uuid()::text) -ON CONFLICT ("Id") DO NOTHING; diff --git a/modular/scripts/install-template.sh b/modular/scripts/install-template.sh index e41dcbe..39afb8c 100755 --- a/modular/scripts/install-template.sh +++ b/modular/scripts/install-template.sh @@ -2,12 +2,15 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -name="CleanApiStarter.Template.0.0.0.nupkg" -pkg="$root/artifacts/$name" +package="CleanApiStarter.Template.Modular" +pkg="$root/artifacts/$package.0.0.0.nupkg" cd "$root" -dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true +# Ensure the variant's database/ is populated from the shared root source. +../scripts/sync-database.sh + +dotnet new uninstall "$package" 2>/dev/null || true dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" dotnet new install "$pkg" --force rm -f "$pkg" diff --git a/scripts/sync-database.sh b/scripts/sync-database.sh new file mode 100755 index 0000000..9ab403c --- /dev/null +++ b/scripts/sync-database.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Copies the single source-of-truth database/ (repo root) into each variant. +# The variant copies are git-ignored; this keeps one canonical set of migrations +# while letting each variant stay self-contained for `dotnet new` packaging. +# +# Run this before packing a template locally, or before running a variant's +# Aspire AppHost. Integration tests find the migrations via a directory walk-up, +# so they work without syncing. CI runs this automatically. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="$root/database" + +if [ ! -d "$source_dir" ]; then + echo "Source database directory not found: $source_dir" >&2 + exit 1 +fi + +for variant in layered modular; do + dest="$root/$variant/database" + rm -rf "$dest" + cp -R "$source_dir" "$dest" + echo "Synced database -> $variant/database" +done From a2c5c84617081b19889f8172a9feeeab838ff549 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sat, 20 Jun 2026 17:56:22 +0800 Subject: [PATCH 04/18] docs: add architecture decision records (ADR-001..003) Capture the architecture's evolution: seven-project split -> dual layered+modular variants -> a single modular solution of three projects with analyzer-enforced boundaries. Insights synthesized as general industry knowledge. --- adr/ADR-001.md | 70 +++++++++++++++++++++++++++++++++++++++ adr/ADR-002.md | 82 +++++++++++++++++++++++++++++++++++++++++++++ adr/ADR-003.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ adr/README.md | 20 +++++++++++ 4 files changed, 262 insertions(+) create mode 100644 adr/ADR-001.md create mode 100644 adr/ADR-002.md create mode 100644 adr/ADR-003.md create mode 100644 adr/README.md diff --git a/adr/ADR-001.md b/adr/ADR-001.md new file mode 100644 index 0000000..ed7df04 --- /dev/null +++ b/adr/ADR-001.md @@ -0,0 +1,70 @@ +# ADR-001: Fine-Grained Seven-Project Clean Architecture Structure + +- **Date:** 2026-04-26 +- **Status:** Superseded by [ADR-002](ADR-002.md) + +## Context + +The solution began as a Clean Architecture REST API starter for .NET. The +prevailing convention in the .NET ecosystem for expressing Clean Architecture is +to make each architectural layer its own compiled project. The appeal is +enforcement: the dependency rule (dependencies point inward, business logic stays +free of infrastructure) is guaranteed by the compiler, because a project cannot +reference something it has no project reference to. A layer with no reference to +the persistence layer physically cannot compile a call into it. + +Wanting the strongest possible enforcement and the cleanest separation of +concerns, the initial design maximized this split into seven projects: + +1. **Domain** — entities and core types; depends on nothing. +2. **Application** — use cases, service abstractions (e.g. repository + interfaces), DTOs, and validators; the seam between domain and infrastructure. +3. **Infrastructure** — EF Core, identity, and the implementations of the + application's abstractions. +4. **Api** — the HTTP host, endpoints, and composition root. +5. **Configuration** — settings/options classes and their registration; a neutral + leaf shared by both web and infrastructure concerns without coupling them. +6. **AspNetCore** — reusable web/runtime defaults (telemetry, problem details, + API versioning, authentication, validation, health checks). +7. **AppHost** — local-development orchestration (database, admin tooling). + +It is worth noting up front that modern orchestration tooling is itself +responsible for some of this count — a portion of the projects exist to satisfy +the local-development host rather than the application's own architecture. + +## Decision + +Adopt the seven-project structure and rely on project references for +compile-time enforcement of the dependency rule. Each layer and each +cross-cutting concern becomes a distinct project with an explicitly declared set +of references. + +## Consequences + +**Positive** + +- The dependency rule is enforced by the compiler — an illegal inward/outward + reference simply does not build. This is the strongest form of enforcement + available. +- Each project carries a single, clearly named responsibility. +- The platform and configuration projects are, in principle, reusable if the + system later grows into multiple services. + +**Negative** + +- High ceremony. Implementing a single feature touches files spread across + several projects, and day-to-day work involves constant hopping between them. +- The project count is disproportionate to a single small service. Much of the + separation — particularly extracting reusable platform code — is speculative: + it pays off only when reuse actually materializes, which for one service it + never does. +- Onboarding friction is real, and the layout invites a common misconception: + that Clean Architecture *is* this particular set of projects. It is not. The + architecture is fundamentally about the **direction of dependencies** and the + protection of business logic from external concerns — not about how many + projects exist. A large multi-project solution is not a prerequisite for good + architecture; it is one implementation of the dependency rule, and a heavy one. + +This tension — strong enforcement bought at the cost of significant ceremony, much +of it speculative — is the motivation for revisiting the structure in +[ADR-002](ADR-002.md). diff --git a/adr/ADR-002.md b/adr/ADR-002.md new file mode 100644 index 0000000..3405394 --- /dev/null +++ b/adr/ADR-002.md @@ -0,0 +1,82 @@ +# ADR-002: Maintain Two Parallel Variants — Layered and Modular + +- **Date:** 2026-06-17 +- **Status:** Superseded by [ADR-003](ADR-003.md) +- **Supersedes:** [ADR-001](ADR-001.md) + +## Context + +The seven-project structure ([ADR-001](ADR-001.md)) delivered strong, compiler- +enforced boundaries but at a cost in ceremony that felt disproportionate for a +single service. Reassessing the design surfaced three insights that reframed the +problem. + +First, what is usually discussed as one decision is really **three independent +decisions**, and conflating them causes most architecture arguments: + +- **Feature organization** — *where do I find the code?* +- **Code reuse** — *is logic consistent across features?* +- **Dependency management** — *what is allowed to depend on what?* + +Clean Architecture is an answer to dependency management. Organizing by feature +(vertical slices) is an answer to feature organization. These are orthogonal and +not opposites; a system can — and generally should — do both. + +Second, compile-time enforcement of the dependency rule **does not require +separate projects**. A namespace-dependency analyzer can inspect dependencies at +build time and fail the build when code crosses a forbidden boundary, achieving +inside a single project what project references achieve across many. + +Third, there was no single right answer for the audience. Some teams value hard, +physical boundaries and accept the ceremony; others want feature-centric +organization with minimal ceremony. Both are legitimate, and forcing one on +everyone would be wrong. + +A note on duplication informed the modular design: organizing by feature does +**not** mean duplicating everything into every feature. Shared domain concepts and +shared infrastructure (for example, a single database context and its +configuration) should be shared, not copied. Only slice-level glue — request and +response shapes, validators, and the orchestration of a single operation — is +reasonably duplicated, because coupling unrelated features through a shared +abstraction is often worse than a little repetition. + +## Decision + +Keep both structures, expressed as **two self-contained templates in one +repository**: + +- **Layered** — the multi-project structure, with boundaries enforced by project + references. For teams that want hard, physical separation. +- **Modular** — a single application project organized by feature folders, with + boundaries enforced by a namespace-dependency analyzer whose violations are + elevated to build errors. Reuses the platform and configuration projects. + +Concerns genuinely shared by both — the database schema (kept as a single source +of truth and synced into each variant), documentation, and continuous +integration — are hoisted to the repository root so the two variants cannot drift +on the parts that must agree. + +## Consequences + +**Positive** + +- Users pick the structure that fits their context without giving up enforced + dependency boundaries in either case. +- The pairing demonstrates concretely that Clean Architecture and vertical slices + coexist; they sit on different axes (dependency management vs. feature + organization). +- The duplication guidance is made explicit and consistent across both variants. + +**Negative** + +- Two trees must be maintained, with a real risk of drift. This is mitigated, but + not eliminated, by sharing the schema, docs, and CI at the root. +- Because each variant must be self-contained to function as a standalone + template, some configuration files and platform projects are necessarily + duplicated between them. +- Carrying two architectural philosophies in one repository increases the + cognitive load on maintainers and can dilute the project's message — a reader + must now understand both, and decide between them, before getting started. + +The maintenance overhead and divided focus of running two variants is what drives +the consolidation in [ADR-003](ADR-003.md). diff --git a/adr/ADR-003.md b/adr/ADR-003.md new file mode 100644 index 0000000..02b784d --- /dev/null +++ b/adr/ADR-003.md @@ -0,0 +1,90 @@ +# ADR-003: Consolidate to a Single Modular Solution at the Root (Three Projects) + +- **Date:** 2026-06-20 +- **Status:** Accepted +- **Supersedes:** [ADR-002](ADR-002.md) + +## Context + +Maintaining two parallel variants ([ADR-002](ADR-002.md)) doubled the maintenance +surface and split the project's focus. A reader had to understand both structures +before starting, and every shared change had to be applied carefully in two +places to avoid drift. The cost of running both was no longer justified by the +benefit. + +Re-examining the layered variant project by project showed that only a subset of +its projects were actually load-bearing: + +- The **application ↔ infrastructure** boundary is the one that matters — it is + what delivers dependency inversion, lets business logic be tested without a + database, and keeps persistence swappable. +- The **domain ↔ application** boundary is a *soft* rule. A leak in that + direction is far less harmful than anything reaching into infrastructure, and + many established Clean Architecture layouts keep domain and application together + in a single inner project. +- The **platform, configuration, and orchestration** projects were either + speculative reuse (extracted for a second service that does not exist) or the + unavoidable tax of local-development tooling. + +Crucially, the dependency rule can be enforced **completely within one project** +by a namespace-dependency analyzer that elevates illegal dependencies to build +errors. Separate projects are therefore not necessary to *enforce* the +architecture — they are only one way to do it, and the heaviest one. + +Two further considerations pointed the same way. Number of projects is not an +architectural principle; the architecture is the dependency direction, and that +can be preserved regardless of project count. And **cohesion and locality** — +keeping the code for one capability together — measurably reduce friction, both +for people navigating the codebase and for automated and agent-driven tooling +that benefits from consistency and co-located context. The modular variant had +already proven this end to end: feature-centric organization, boundaries enforced +at build time, and a working template. + +## Decision + +Retire the layered variant. Keep only the modular / vertical-slice solution and +promote it to the repository root. Reduce the structure to **three projects**: + +1. **Api** — the entire application: domain, features (vertical slices), + infrastructure, configuration/options, and composition. Internal boundaries + (domain ✗→ infrastructure, features ✗→ infrastructure, domain ✗→ features) are + enforced by a namespace-dependency analyzer configured to fail the build. +2. **AspNetCoreDefaults** — reusable, application-agnostic web and runtime + defaults. It deliberately holds no knowledge of the application's settings; the + composition root (Api) binds configuration-dependent options such as JWT token + validation. +3. **AppHost** — local-development orchestration. + +The dependency rule is enforced by the analyzer and configuration, not by the +number of projects. + +## Consequences + +**Positive** + +- Dramatically less ceremony. A feature lives in one place; there is no + project-hopping to implement or trace a single operation, which lowers + onboarding cost. +- A single source of truth — no second variant to keep in sync, no drift. +- The dependency guarantees that actually matter are preserved and still enforced + at build time; the architecture is unchanged, only its packaging is simpler. +- Better cohesion and locality, which helps human comprehension and consistent, + tool-assisted change. + +**Negative** + +- The "physically cannot even reference it" guarantee of separate projects is + traded for analyzer-based enforcement. This depends on the analyzer being + present and correctly configured; the risk is mitigated by committing the + configuration and treating its violations as build errors. +- Teams that specifically prefer hard, project-level boundaries no longer have a + ready-made option. This is an accepted trade-off: the analyzer provides + equivalent enforcement of the same rule. +- Folding configuration into the Api project slightly broadens its surface, but + keeps the defaults project genuinely reusable and free of app-specific types. +- References to the retired layered variant in documentation and tooling must be + updated. + +Simplicity and cohesion won because the separation that was removed was either +speculative or ceremonial, while everything that genuinely protects the +architecture — the inward dependency direction — is retained and still enforced. diff --git a/adr/README.md b/adr/README.md new file mode 100644 index 0000000..333a0c6 --- /dev/null +++ b/adr/README.md @@ -0,0 +1,20 @@ +# Architecture Decision Records + +This folder records the significant architectural decisions for this solution and +how they evolved. Each record follows a standard format (Title, Date, Status, +Context, Decision, Consequences) and is immutable once accepted — superseding +decisions are captured in new records rather than by editing old ones. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](ADR-001.md) | Fine-grained seven-project Clean Architecture structure | Superseded by ADR-002 | +| [ADR-002](ADR-002.md) | Maintain two parallel variants — layered and modular | Superseded by ADR-003 | +| [ADR-003](ADR-003.md) | Consolidate to a single modular solution at the root (three projects) | Accepted | + +The progression in one line: a fine-grained seven-project split → two parallel +variants (layered + modular) → a single modular solution of three projects, where +boundaries are enforced by a namespace-dependency analyzer rather than by project +count. + +Background reading on the principles behind these decisions lives in +[`../docs/architecture`](../docs/architecture/clean-architecture-and-vertical-slices.md). From 46f188561772ad73dc79d886f275f8e6a19650b2 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sat, 20 Jun 2026 17:56:22 +0800 Subject: [PATCH 05/18] refactor(modular): rename AspNetCore->AspNetCoreDefaults, fold Configuration into Api Per ADR-003, reduce the modular variant toward three projects: - Rename CleanApiStarter.AspNetCore -> CleanApiStarter.AspNetCoreDefaults (folder, csproj, assembly, namespace). - Fold CleanApiStarter.Configuration into the Api project (CleanApiStarter.Api. Configuration), removing the separate project. - Break the resulting reference cycle by keeping AspNetCoreDefaults application-agnostic: the JWT bearer token-validation options are now bound from AppSettings by the composition root (Api) via AddJwtBearerOptions, instead of inside the defaults project. Modular now has three source projects (Api, AspNetCoreDefaults, AppHost). Build, unit tests, and the Testcontainers integration test (which exercises JWT auth) all pass. --- modular/CleanApiStarter.slnx | 3 +-- .../CleanApiStarter.Api.csproj | 4 +-- .../Configuration}/AppSettings.cs | 2 +- .../AuthenticationConfiguration.cs | 27 +++++++++++++++++++ .../Configuration}/AuthenticationSettings.cs | 2 +- .../ConnectionStringSettings.cs | 2 +- .../GoogleAuthenticationSettings.cs | 2 +- .../JwtAuthenticationSettings.cs | 2 +- .../OptionsRegistrationExtensions.cs | 2 +- .../src/CleanApiStarter.Api/GlobalUsings.cs | 9 +++++-- modular/src/CleanApiStarter.Api/Program.cs | 1 + .../AspNetCoreDefaultServices.cs | 22 +++------------ ...CleanApiStarter.AspNetCoreDefaults.csproj} | 8 ++---- .../Extensions.cs | 2 +- .../GlobalUsings.cs | 2 -- .../IEndpointGroup.cs | 2 +- .../OpenApiDocumentationExtensions.cs | 2 +- .../OpenTelemetryDefaults.cs | 2 +- .../ProblemDetailsExceptionHandler.cs | 2 +- .../RequestIdMiddleware.cs | 2 +- .../UserIdHttpLoggingInterceptor.cs | 2 +- .../ValidationFilter.cs | 2 +- .../WebApplicationExtensions.cs | 2 +- .../CleanApiStarter.Configuration.csproj | 15 ----------- .../GlobalUsings.cs | 5 ---- 25 files changed, 59 insertions(+), 67 deletions(-) rename modular/src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/AppSettings.cs (85%) create mode 100644 modular/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs rename modular/src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/AuthenticationSettings.cs (85%) rename modular/src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/ConnectionStringSettings.cs (73%) rename modular/src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/GoogleAuthenticationSettings.cs (73%) rename modular/src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/JwtAuthenticationSettings.cs (88%) rename modular/src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/OptionsRegistrationExtensions.cs (91%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/AspNetCoreDefaultServices.cs (78%) rename modular/src/{CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj => CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj} (82%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/Extensions.cs (98%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/GlobalUsings.cs (96%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/IEndpointGroup.cs (80%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/OpenApiDocumentationExtensions.cs (95%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/OpenTelemetryDefaults.cs (96%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/ProblemDetailsExceptionHandler.cs (98%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/RequestIdMiddleware.cs (92%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/UserIdHttpLoggingInterceptor.cs (95%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/ValidationFilter.cs (97%) rename modular/src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/WebApplicationExtensions.cs (96%) delete mode 100644 modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj delete mode 100644 modular/src/CleanApiStarter.Configuration/GlobalUsings.cs diff --git a/modular/CleanApiStarter.slnx b/modular/CleanApiStarter.slnx index 9ba3db1..fba1bd8 100644 --- a/modular/CleanApiStarter.slnx +++ b/modular/CleanApiStarter.slnx @@ -26,8 +26,7 @@ - - + diff --git a/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj index 21e2e3a..269ddb0 100644 --- a/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ b/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -11,6 +11,7 @@ + @@ -21,8 +22,7 @@ - - + diff --git a/modular/src/CleanApiStarter.Configuration/AppSettings.cs b/modular/src/CleanApiStarter.Api/Configuration/AppSettings.cs similarity index 85% rename from modular/src/CleanApiStarter.Configuration/AppSettings.cs rename to modular/src/CleanApiStarter.Api/Configuration/AppSettings.cs index 19cdf16..cac0758 100644 --- a/modular/src/CleanApiStarter.Configuration/AppSettings.cs +++ b/modular/src/CleanApiStarter.Api/Configuration/AppSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class AppSettings { diff --git a/modular/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs b/modular/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs new file mode 100644 index 0000000..86dad5c --- /dev/null +++ b/modular/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs @@ -0,0 +1,27 @@ +namespace CleanApiStarter.Api.Configuration; + +public static class AuthenticationConfiguration +{ + public static IServiceCollection AddJwtBearerOptions(this IServiceCollection services) + { + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((options, appSettings) => + { + JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + + return services; + } +} diff --git a/modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/modular/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs similarity index 85% rename from modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs rename to modular/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs index 24f76fa..19e0227 100644 --- a/modular/src/CleanApiStarter.Configuration/AuthenticationSettings.cs +++ b/modular/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class AuthenticationSettings { diff --git a/modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/modular/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs similarity index 73% rename from modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs rename to modular/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs index 1299016..4f9b846 100644 --- a/modular/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs +++ b/modular/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class ConnectionStringSettings { diff --git a/modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/modular/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs similarity index 73% rename from modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs rename to modular/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs index b6f8aec..a7cbf13 100644 --- a/modular/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs +++ b/modular/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class GoogleAuthenticationSettings { diff --git a/modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/modular/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs similarity index 88% rename from modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs rename to modular/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs index 083c6d8..abc0bc1 100644 --- a/modular/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs +++ b/modular/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class JwtAuthenticationSettings { diff --git a/modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/modular/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs similarity index 91% rename from modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs rename to modular/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs index 6b767c7..77b0e32 100644 --- a/modular/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs +++ b/modular/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public static class OptionsRegistrationExtensions { diff --git a/modular/src/CleanApiStarter.Api/GlobalUsings.cs b/modular/src/CleanApiStarter.Api/GlobalUsings.cs index 8538edb..23c9c2a 100644 --- a/modular/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/modular/src/CleanApiStarter.Api/GlobalUsings.cs @@ -17,14 +17,17 @@ global using CleanApiStarter.Api.Infrastructure.Identity; global using CleanApiStarter.Api.Infrastructure.Persistence; global using CleanApiStarter.Api.Infrastructure.Repositories; +global using CleanApiStarter.Api.Configuration; global using CleanApiStarter.Api.Services; -global using CleanApiStarter.AspNetCore; -global using CleanApiStarter.Configuration; +global using CleanApiStarter.AspNetCoreDefaults; global using FluentValidation; global using Google.Apis.Auth; +global using System.ComponentModel.DataAnnotations; + +global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Identity; global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -32,6 +35,8 @@ global using Microsoft.AspNetCore.Routing; global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; global using Microsoft.IdentityModel.Tokens; diff --git a/modular/src/CleanApiStarter.Api/Program.cs b/modular/src/CleanApiStarter.Api/Program.cs index 4637d70..3dc49e0 100644 --- a/modular/src/CleanApiStarter.Api/Program.cs +++ b/modular/src/CleanApiStarter.Api/Program.cs @@ -3,6 +3,7 @@ builder.AddAspNetCoreDefaults(); builder.Services.AddAppSettings(builder.Configuration); +builder.Services.AddJwtBearerOptions(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(); builder.Services.AddScoped(); diff --git a/modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs similarity index 78% rename from modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs index 2e7e5bc..09b47fd 100644 --- a/modular/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static partial class Extensions { @@ -46,23 +46,9 @@ private static void AddAuthenticationDefaults(this WebApplicationBuilder builder .Build(); }); - builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) - .Configure((options, appSettings) => - { - JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; - - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtSettings.Issuer, - ValidateAudience = true, - ValidAudience = jwtSettings.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - }); + // The JWT bearer token validation parameters are bound from application + // settings by the composition root (the Api project), so these defaults + // stay free of any app-specific configuration types. } private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) diff --git a/modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/modular/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj similarity index 82% rename from modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj rename to modular/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj index a27ae2d..5006b81 100644 --- a/modular/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj @@ -1,14 +1,10 @@ - CleanApiStarter.AspNetCore - CleanApiStarter.AspNetCore + CleanApiStarter.AspNetCoreDefaults + CleanApiStarter.AspNetCoreDefaults - - - - diff --git a/modular/src/CleanApiStarter.AspNetCore/Extensions.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs similarity index 98% rename from modular/src/CleanApiStarter.AspNetCore/Extensions.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs index ca8c5b6..e00e1a0 100644 --- a/modular/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static partial class Extensions { diff --git a/modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs similarity index 96% rename from modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs index 1a87110..c9242a1 100644 --- a/modular/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs @@ -9,8 +9,6 @@ global using Asp.Versioning.ApiExplorer; global using Asp.Versioning.Builder; -global using CleanApiStarter.Configuration; - global using FluentValidation; global using FluentValidation.Results; diff --git a/modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs similarity index 80% rename from modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs index 66a2135..47296b2 100644 --- a/modular/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public interface IEndpointGroup { diff --git a/modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs similarity index 95% rename from modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs index fba74c4..0314cb5 100644 --- a/modular/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static class OpenApiDocumentationExtensions { diff --git a/modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs similarity index 96% rename from modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs index 70733a2..f9a770c 100644 --- a/modular/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static partial class Extensions { diff --git a/modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs similarity index 98% rename from modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs index ee1e506..7cf981f 100644 --- a/modular/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class ProblemDetailsExceptionHandler( IProblemDetailsService problemDetailsService, diff --git a/modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs similarity index 92% rename from modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs index 003a9ef..7c5099d 100644 --- a/modular/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class RequestIdMiddleware(RequestDelegate next) { diff --git a/modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs similarity index 95% rename from modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs index 81c7243..0c613a3 100644 --- a/modular/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor { diff --git a/modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs similarity index 97% rename from modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs index 63b2cc0..4a9d885 100644 --- a/modular/src/CleanApiStarter.AspNetCore/ValidationFilter.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class ValidationFilter : IEndpointFilter { diff --git a/modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/modular/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs similarity index 96% rename from modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs rename to modular/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs index 87e5166..bf66b9f 100644 --- a/modular/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ b/modular/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static class WebApplicationExtensions { diff --git a/modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj deleted file mode 100644 index 002e9d2..0000000 --- a/modular/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - CleanApiStarter.Configuration - CleanApiStarter.Configuration - - - - - - - - - - diff --git a/modular/src/CleanApiStarter.Configuration/GlobalUsings.cs b/modular/src/CleanApiStarter.Configuration/GlobalUsings.cs deleted file mode 100644 index a2250c9..0000000 --- a/modular/src/CleanApiStarter.Configuration/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -global using System.ComponentModel.DataAnnotations; - -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Options; From dda422137a686a21caeab58883ffaea19c0da680 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sat, 20 Jun 2026 18:12:02 +0800 Subject: [PATCH 06/18] refactor: retire layered variant, promote modular to repo root (ADR-003) Consolidate to a single modular solution at the repository root, replacing the two-variant layout: - Delete the layered/ variant; move the modular solution (Api, AspNetCoreDefaults, AppHost + tests) to the root. - database/ at the root is referenced directly; remove the per-variant sync mechanism (scripts/sync-database.sh) and its gitignore entry. - Revert CI from a two-variant matrix to single-solution workflows; dependabot watches one nuget directory again. - Single template again: identity/shortName CleanApiStarter.Template / clean-api-starter, PackageId CleanApiStarter.Template. Exclude adr/, docs/, CONTRIBUTING from packed output. - Refresh README and CONTRIBUTING for the single-solution structure. Verified: build, format check, unit + integration tests pass; template packs, instantiates, and the generated three-project solution builds with migrations and CI workflows shipped. --- .../.config => .config}/dotnet-tools.json | 0 modular/.editorconfig => .editorconfig | 0 .github/dependabot.yml | 4 +- .github/workflows/build.yml | 17 +- .github/workflows/codeql.yml | 11 +- .github/workflows/release.yml | 18 +- .github/workflows/template.yml | 25 +- .../template.json | 7 +- CONTRIBUTING.md | 118 ++- ....csproj => CleanApiStarter.Template.csproj | 6 +- ...eanApiStarter.slnx => CleanApiStarter.slnx | 0 ...ctory.Build.props => Directory.Build.props | 0 ...Packages.props => Directory.Packages.props | 0 README.md | 132 ++-- .../docker-compose.yml => docker-compose.yml | 0 layered/global.json => global.json | 0 layered/.editorconfig | 367 ---------- layered/.github/workflows/build.yml | 30 - layered/.gitignore | 408 ----------- layered/.template.config/template.json | 53 -- layered/AGENTS.md | 181 ----- layered/CleanApiStarter.slnx | 44 -- layered/Directory.Build.props | 10 - layered/Directory.Packages.props | 60 -- layered/LICENSE | 674 ------------------ layered/README.md | 600 ---------------- .../CleanApiStarter.Api.csproj | 15 - .../src/CleanApiStarter.Api/GlobalUsings.cs | 21 - layered/src/CleanApiStarter.Api/Program.cs | 20 - .../Properties/launchSettings.json | 15 - .../src/CleanApiStarter.Api/appsettings.json | 9 - .../CleanApiStarter.Application.csproj | 18 - .../Common/Interfaces/IUser.cs | 7 - .../Common/Models/ArrayResult.cs | 8 - .../Common/Models/PaginatedQuery.cs | 8 - .../Common/Models/PaginatedQueryValidator.cs | 13 - .../Common/Models/PaginatedResult.cs | 27 - .../DependencyInjection.cs | 12 - .../Features/Auth/AuthTokenDto.cs | 8 - .../Features/Auth/CurrentUserDto.cs | 12 - .../Features/Auth/GoogleSignInDto.cs | 6 - .../Features/Auth/GoogleSignInDtoValidator.cs | 10 - .../Features/Auth/IAuthService.cs | 8 - .../Projects/CreateProjectDtoValidator.cs | 14 - .../Projects/CreateProjectTaskDtoValidator.cs | 19 - .../Features/Projects/IProjectRepository.cs | 35 - .../Features/Projects/IProjectService.cs | 32 - .../Features/Projects/ProjectDto.cs | 21 - .../Projects/ProjectOperationResults.cs | 15 - .../Features/Projects/ProjectService.cs | 248 ------- .../Features/Projects/ProjectTaskDto.cs | 40 -- .../Projects/UpdateProjectTaskDtoValidator.cs | 24 - .../GlobalUsings.cs | 11 - .../AspNetCoreDefaultServices.cs | 116 --- .../CleanApiStarter.AspNetCore.csproj | 32 - .../CleanApiStarter.AspNetCore/Extensions.cs | 75 -- .../GlobalUsings.cs | 38 - .../IEndpointGroup.cs | 10 - .../OpenApiDocumentationExtensions.cs | 32 - .../OpenTelemetryDefaults.cs | 41 -- .../ProblemDetailsExceptionHandler.cs | 82 --- .../RequestIdMiddleware.cs | 23 - .../UserIdHttpLoggingInterceptor.cs | 28 - .../ValidationFilter.cs | 45 -- .../WebApplicationExtensions.cs | 40 -- .../AppSettings.cs | 12 - .../AuthenticationSettings.cs | 12 - .../CleanApiStarter.Configuration.csproj | 15 - .../ConnectionStringSettings.cs | 7 - .../GlobalUsings.cs | 5 - .../GoogleAuthenticationSettings.cs | 7 - .../JwtAuthenticationSettings.cs | 17 - .../OptionsRegistrationExtensions.cs | 19 - .../CleanApiStarter.Domain.csproj | 6 - .../Entities/Project.cs | 18 - .../Entities/ProjectMember.cs | 12 - .../Entities/ProjectTask.cs | 22 - .../Entities/ProjectTaskStatus.cs | 8 - .../CleanApiStarter.Infrastructure.csproj | 20 - .../DependencyInjection.cs | 22 - .../GlobalUsings.cs | 21 - .../Identity/ApplicationUser.cs | 3 - .../Identity/GoogleAuthService.cs | 151 ---- .../Persistence/ApplicationDbContext.cs | 18 - .../Configuration/ProjectConfiguration.cs | 31 - .../ProjectMemberConfiguration.cs | 30 - .../Configuration/ProjectTaskConfiguration.cs | 46 -- .../Repositories/ProjectRepository.cs | 158 ---- ...eanApiStarter.Application.UnitTests.csproj | 28 - .../Features/Projects/ProjectServiceTests.cs | 40 -- .../GlobalUsings.cs | 14 - modular/.config/dotnet-tools.json | 12 - modular/.github/workflows/build.yml | 30 - modular/.gitignore | 408 ----------- modular/AGENTS.md | 181 ----- modular/CleanApiStarter.Template.csproj | 31 - modular/LICENSE | 674 ------------------ modular/README.md | 91 --- modular/docker-compose.yml | 18 - modular/global.json | 6 - modular/scripts/install-template.sh | 16 - modular/scripts/test-coverage.sh | 60 -- modular/src/CleanApiStarter.Api/Api.http | 88 --- .../Endpoints/GoogleLoginPage.cs | 100 --- .../CleanApiStarter.Api/Endpoints/V1/Auth.cs | 39 - .../Endpoints/V1/Projects.cs | 176 ----- .../Endpoints/V2/Projects.cs | 35 - .../Services/CurrentUser.cs | 9 - .../appsettings.Development.json | 16 - .../CleanApiStarter.AppHost.csproj | 23 - .../src/CleanApiStarter.AppHost/Program.cs | 19 - .../Properties/launchSettings.json | 17 - ...leanApiStarter.Api.IntegrationTests.csproj | 29 - .../Features/Projects/ProjectsTests.cs | 40 -- .../GlobalUsings.cs | 9 - .../appsettings.Testing.json | 16 - .../CleanApiStarter.Tests.csproj | 22 - .../Common/ApiApplicationFactory.cs | 100 --- .../Common/AutoNSubstituteDataAttribute.cs | 13 - .../CleanApiStarter.Tests/GlobalUsings.cs | 18 - .../scripts => scripts}/install-template.sh | 5 +- scripts/sync-database.sh | 24 - {layered/scripts => scripts}/test-coverage.sh | 0 .../src => src}/CleanApiStarter.Api/Api.http | 0 .../CleanApiStarter.Api.csproj | 0 .../Common/Interfaces/IUser.cs | 0 .../Common/Models/ArrayResult.cs | 0 .../Common/Models/PaginatedQuery.cs | 0 .../Common/Models/PaginatedQueryValidator.cs | 0 .../Common/Models/PaginatedResult.cs | 0 .../Configuration/AppSettings.cs | 0 .../AuthenticationConfiguration.cs | 0 .../Configuration/AuthenticationSettings.cs | 0 .../Configuration/ConnectionStringSettings.cs | 0 .../GoogleAuthenticationSettings.cs | 0 .../JwtAuthenticationSettings.cs | 0 .../OptionsRegistrationExtensions.cs | 0 .../DependencyInjection.cs | 0 .../Domain/Entities/Project.cs | 0 .../Domain/Entities/ProjectMember.cs | 0 .../Domain/Entities/ProjectTask.cs | 0 .../Domain/Entities/ProjectTaskStatus.cs | 0 .../Endpoints/GoogleLoginPage.cs | 0 .../CleanApiStarter.Api/Endpoints/V1/Auth.cs | 0 .../Endpoints/V1/Projects.cs | 0 .../Endpoints/V2/Projects.cs | 0 .../Features/Auth/AuthTokenDto.cs | 0 .../Features/Auth/CurrentUserDto.cs | 0 .../Features/Auth/GoogleSignInDto.cs | 0 .../Features/Auth/GoogleSignInDtoValidator.cs | 0 .../Features/Auth/IAuthService.cs | 0 .../Projects/CreateProjectDtoValidator.cs | 0 .../Projects/CreateProjectTaskDtoValidator.cs | 0 .../Features/Projects/IProjectRepository.cs | 0 .../Features/Projects/IProjectService.cs | 0 .../Features/Projects/ProjectDto.cs | 0 .../Projects/ProjectOperationResults.cs | 0 .../Features/Projects/ProjectService.cs | 0 .../Features/Projects/ProjectTaskDto.cs | 0 .../Projects/UpdateProjectTaskDtoValidator.cs | 0 .../CleanApiStarter.Api/GlobalUsings.cs | 5 +- .../Infrastructure/DependencyInjection.cs | 0 .../Identity/ApplicationUser.cs | 0 .../Identity/GoogleAuthService.cs | 0 .../Persistence/ApplicationDbContext.cs | 0 .../Configuration/ProjectConfiguration.cs | 0 .../ProjectMemberConfiguration.cs | 0 .../Configuration/ProjectTaskConfiguration.cs | 0 .../Repositories/ProjectRepository.cs | 0 .../CleanApiStarter.Api/Program.cs | 0 .../Properties/launchSettings.json | 0 .../Services/CurrentUser.cs | 0 .../appsettings.Development.json | 0 .../CleanApiStarter.Api/appsettings.json | 0 .../CleanApiStarter.Api/config.nsdepcop | 0 .../CleanApiStarter.AppHost.csproj | 0 .../CleanApiStarter.AppHost/Program.cs | 0 .../Properties/launchSettings.json | 0 .../AspNetCoreDefaultServices.cs | 0 .../CleanApiStarter.AspNetCoreDefaults.csproj | 0 .../Extensions.cs | 0 .../GlobalUsings.cs | 0 .../IEndpointGroup.cs | 0 .../OpenApiDocumentationExtensions.cs | 0 .../OpenTelemetryDefaults.cs | 0 .../ProblemDetailsExceptionHandler.cs | 0 .../RequestIdMiddleware.cs | 2 +- .../UserIdHttpLoggingInterceptor.cs | 0 .../ValidationFilter.cs | 0 .../WebApplicationExtensions.cs | 0 ...leanApiStarter.Api.IntegrationTests.csproj | 0 .../Features/Projects/ProjectsTests.cs | 0 .../GlobalUsings.cs | 0 .../appsettings.Testing.json | 0 ...eanApiStarter.Application.UnitTests.csproj | 0 .../Features/Projects/ProjectServiceTests.cs | 0 .../GlobalUsings.cs | 2 +- .../CleanApiStarter.Tests.csproj | 0 .../Common/ApiApplicationFactory.cs | 0 .../Common/AutoNSubstituteDataAttribute.cs | 0 .../CleanApiStarter.Tests/GlobalUsings.cs | 0 201 files changed, 159 insertions(+), 6920 deletions(-) rename {layered/.config => .config}/dotnet-tools.json (100%) rename modular/.editorconfig => .editorconfig (100%) rename {modular/.template.config => .template.config}/template.json (88%) rename layered/CleanApiStarter.Template.csproj => CleanApiStarter.Template.csproj (88%) rename modular/CleanApiStarter.slnx => CleanApiStarter.slnx (100%) rename modular/Directory.Build.props => Directory.Build.props (100%) rename modular/Directory.Packages.props => Directory.Packages.props (100%) rename layered/docker-compose.yml => docker-compose.yml (100%) rename layered/global.json => global.json (100%) delete mode 100644 layered/.editorconfig delete mode 100644 layered/.github/workflows/build.yml delete mode 100644 layered/.gitignore delete mode 100644 layered/.template.config/template.json delete mode 100644 layered/AGENTS.md delete mode 100644 layered/CleanApiStarter.slnx delete mode 100644 layered/Directory.Build.props delete mode 100644 layered/Directory.Packages.props delete mode 100644 layered/LICENSE delete mode 100644 layered/README.md delete mode 100644 layered/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj delete mode 100644 layered/src/CleanApiStarter.Api/GlobalUsings.cs delete mode 100644 layered/src/CleanApiStarter.Api/Program.cs delete mode 100644 layered/src/CleanApiStarter.Api/Properties/launchSettings.json delete mode 100644 layered/src/CleanApiStarter.Api/appsettings.json delete mode 100644 layered/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj delete mode 100644 layered/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs delete mode 100644 layered/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs delete mode 100644 layered/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs delete mode 100644 layered/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs delete mode 100644 layered/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs delete mode 100644 layered/src/CleanApiStarter.Application/DependencyInjection.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs delete mode 100644 layered/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs delete mode 100644 layered/src/CleanApiStarter.Application/GlobalUsings.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj delete mode 100644 layered/src/CleanApiStarter.AspNetCore/Extensions.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/GlobalUsings.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/ValidationFilter.cs delete mode 100644 layered/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/AppSettings.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/AuthenticationSettings.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj delete mode 100644 layered/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/GlobalUsings.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs delete mode 100644 layered/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs delete mode 100644 layered/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj delete mode 100644 layered/src/CleanApiStarter.Domain/Entities/Project.cs delete mode 100644 layered/src/CleanApiStarter.Domain/Entities/ProjectMember.cs delete mode 100644 layered/src/CleanApiStarter.Domain/Entities/ProjectTask.cs delete mode 100644 layered/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj delete mode 100644 layered/src/CleanApiStarter.Infrastructure/DependencyInjection.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/GlobalUsings.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs delete mode 100644 layered/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs delete mode 100644 layered/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj delete mode 100644 layered/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs delete mode 100644 layered/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs delete mode 100644 modular/.config/dotnet-tools.json delete mode 100644 modular/.github/workflows/build.yml delete mode 100644 modular/.gitignore delete mode 100644 modular/AGENTS.md delete mode 100644 modular/CleanApiStarter.Template.csproj delete mode 100644 modular/LICENSE delete mode 100644 modular/README.md delete mode 100644 modular/docker-compose.yml delete mode 100644 modular/global.json delete mode 100755 modular/scripts/install-template.sh delete mode 100755 modular/scripts/test-coverage.sh delete mode 100644 modular/src/CleanApiStarter.Api/Api.http delete mode 100644 modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs delete mode 100644 modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs delete mode 100644 modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs delete mode 100644 modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs delete mode 100644 modular/src/CleanApiStarter.Api/Services/CurrentUser.cs delete mode 100644 modular/src/CleanApiStarter.Api/appsettings.Development.json delete mode 100644 modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj delete mode 100644 modular/src/CleanApiStarter.AppHost/Program.cs delete mode 100644 modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json delete mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj delete mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs delete mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs delete mode 100644 modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json delete mode 100644 modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj delete mode 100644 modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs delete mode 100644 modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs delete mode 100644 modular/tests/CleanApiStarter.Tests/GlobalUsings.cs rename {layered/scripts => scripts}/install-template.sh (69%) delete mode 100755 scripts/sync-database.sh rename {layered/scripts => scripts}/test-coverage.sh (100%) rename {layered/src => src}/CleanApiStarter.Api/Api.http (100%) rename {modular/src => src}/CleanApiStarter.Api/CleanApiStarter.Api.csproj (100%) rename {modular/src => src}/CleanApiStarter.Api/Common/Interfaces/IUser.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Common/Models/ArrayResult.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Common/Models/PaginatedResult.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/AppSettings.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/DependencyInjection.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Domain/Entities/Project.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs (100%) rename {layered/src => src}/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs (100%) rename {layered/src => src}/CleanApiStarter.Api/Endpoints/V1/Auth.cs (100%) rename {layered/src => src}/CleanApiStarter.Api/Endpoints/V1/Projects.cs (100%) rename {layered/src => src}/CleanApiStarter.Api/Endpoints/V2/Projects.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Auth/IAuthService.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/IProjectService.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/ProjectDto.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/ProjectService.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/GlobalUsings.cs (99%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Program.cs (100%) rename {modular/src => src}/CleanApiStarter.Api/Properties/launchSettings.json (100%) rename {layered/src => src}/CleanApiStarter.Api/Services/CurrentUser.cs (100%) rename {layered/src => src}/CleanApiStarter.Api/appsettings.Development.json (100%) rename {modular/src => src}/CleanApiStarter.Api/appsettings.json (100%) rename {modular/src => src}/CleanApiStarter.Api/config.nsdepcop (100%) rename {layered/src => src}/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj (100%) rename {layered/src => src}/CleanApiStarter.AppHost/Program.cs (100%) rename {layered/src => src}/CleanApiStarter.AppHost/Properties/launchSettings.json (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/Extensions.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs (90%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs (100%) rename {modular/src => src}/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs (100%) rename {layered/tests => tests}/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj (100%) rename {layered/tests => tests}/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs (100%) rename {layered/tests => tests}/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs (100%) rename {layered/tests => tests}/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json (100%) rename {modular/tests => tests}/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj (100%) rename {modular/tests => tests}/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs (100%) rename {modular/tests => tests}/CleanApiStarter.Application.UnitTests/GlobalUsings.cs (100%) rename {layered/tests => tests}/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj (100%) rename {layered/tests => tests}/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs (100%) rename {layered/tests => tests}/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs (100%) rename {layered/tests => tests}/CleanApiStarter.Tests/GlobalUsings.cs (100%) diff --git a/layered/.config/dotnet-tools.json b/.config/dotnet-tools.json similarity index 100% rename from layered/.config/dotnet-tools.json rename to .config/dotnet-tools.json diff --git a/modular/.editorconfig b/.editorconfig similarity index 100% rename from modular/.editorconfig rename to .editorconfig diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dfaa31f..c390d1d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,7 @@ version: 2 updates: - package-ecosystem: nuget - directories: - - "/layered" - - "/modular" + directory: "/" schedule: interval: weekly groups: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5ce968..111c83c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,18 +8,9 @@ on: jobs: build: - name: Build and test (${{ matrix.variant }}) + name: Build and test runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - variant: [layered, modular] - - defaults: - run: - working-directory: ${{ matrix.variant }} - steps: - name: Checkout uses: actions/checkout@v6 @@ -27,11 +18,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: ${{ matrix.variant }}/global.json - - - name: Sync database - working-directory: ${{ github.workspace }} - run: ./scripts/sync-database.sh + global-json-file: global.json - name: Restore run: dotnet restore CleanApiStarter.slnx diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1a02522..5dfba3b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,14 +15,9 @@ permissions: jobs: analyze: - name: Analyze (${{ matrix.variant }}) + name: Analyze runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - variant: [layered, modular] - steps: - name: Checkout uses: actions/checkout@v6 @@ -30,7 +25,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: ${{ matrix.variant }}/global.json + global-json-file: global.json - name: Initialize CodeQL uses: github/codeql-action/init@v4 @@ -40,11 +35,9 @@ jobs: queries: security-extended,security-and-quality - name: Restore - working-directory: ${{ matrix.variant }} run: dotnet restore CleanApiStarter.slnx - name: Build - working-directory: ${{ matrix.variant }} run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - name: Analyze diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cea56a..0715734 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,14 +9,9 @@ permissions: jobs: publish: - name: Publish ${{ matrix.variant }} to NuGet.org + name: Publish to NuGet.org runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - variant: [layered, modular] - steps: - name: Checkout uses: actions/checkout@v6 @@ -24,32 +19,27 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: ${{ matrix.variant }}/global.json + global-json-file: global.json - name: Extract version id: version run: echo "value=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - - name: Sync database - run: ./scripts/sync-database.sh - - name: Update template version - working-directory: ${{ matrix.variant }} run: | jq --arg v "${{ steps.version.outputs.value }}" \ '.symbols.caPackageVersion.defaultValue = $v' \ .template.config/template.json > tmp.json && mv tmp.json .template.config/template.json - name: Pack template - working-directory: ${{ matrix.variant }} env: RELEASE_NOTES: ${{ github.event.release.body }} - run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output ../artifacts -p:PackageVersion=${{ steps.version.outputs.value }} -p:PackageReleaseNotes="$RELEASE_NOTES" + run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts -p:PackageVersion=${{ steps.version.outputs.value }} -p:PackageReleaseNotes="$RELEASE_NOTES" - name: Upload package artifact uses: actions/upload-artifact@v7 with: - name: nuget-package-${{ matrix.variant }} + name: nuget-package path: artifacts/*.nupkg - name: Publish to NuGet diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index de46752..981cc62 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -8,20 +8,9 @@ on: jobs: template: - name: Verify template output (${{ matrix.variant }}) + name: Verify template output runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - variant: layered - shortName: clean-api-layered - package: CleanApiStarter.Template.Layered - - variant: modular - shortName: clean-api-modular - package: CleanApiStarter.Template.Modular - steps: - name: Checkout uses: actions/checkout@v6 @@ -29,20 +18,16 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - global-json-file: ${{ matrix.variant }}/global.json - - - name: Sync database - run: ./scripts/sync-database.sh + global-json-file: global.json - name: Pack template - working-directory: ${{ matrix.variant }} - run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output ../artifacts + run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts - name: Install template - run: dotnet new install artifacts/${{ matrix.package }}.0.0.0.nupkg --force + run: dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force - name: Create sample - run: dotnet new ${{ matrix.shortName }} -n DemoProduct -o artifacts/DemoProduct + run: dotnet new clean-api-starter -n DemoProduct -o artifacts/DemoProduct - name: Restore sample working-directory: artifacts/DemoProduct diff --git a/modular/.template.config/template.json b/.template.config/template.json similarity index 88% rename from modular/.template.config/template.json rename to .template.config/template.json index ed05d10..cbade50 100644 --- a/modular/.template.config/template.json +++ b/.template.config/template.json @@ -9,9 +9,9 @@ "PostgreSQL", "OpenTelemetry" ], - "identity": "CleanApiStarter.Template.Modular", - "name": "Clean API Starter (Modular)", - "shortName": "clean-api-modular", + "identity": "CleanApiStarter.Template", + "name": "Clean API Starter", + "shortName": "clean-api-starter", "sourceName": "CleanApiStarter", "preferNameDirectory": true, "symbols": { @@ -43,6 +43,7 @@ ".github/workflows/template.yml", "CleanApiStarter.Template.csproj", "CONTRIBUTING.md", + "adr/**", "docs/**", "scripts/install-template.sh" ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d484683..fcf2707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,15 @@ # Contributing to CleanApiStarter -Thanks for your interest in contributing. This repository ships **two `dotnet new` -template variants** that share documentation and a single source of truth for the -database schema: - -- [`layered/`](layered/) — multi-project Clean Architecture (`clean-api-layered`). -- [`modular/`](modular/) — single business project with NsDepCop-enforced - boundaries (`clean-api-modular`). - -Each variant is a self-contained solution. A change usually touches one variant; -some (docs, the database schema, CI) span both. See -[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) -for the reasoning behind the split. +Thanks for your interest in contributing. This repository is both a working +reference application and a packaged `dotnet new` template, so changes are +verified in both forms. The whole application is a single +`CleanApiStarter.Api` project with boundaries enforced by an analyzer; see +[`adr/`](adr/) and [docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) +for the reasoning. ## Prerequisites -- **.NET SDK** matching each variant's `global.json` (10.0.2xx; `rollForward: +- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: latestFeature` applies). - **Docker** — required for the integration tests (Testcontainers) and local PostgreSQL. @@ -25,74 +19,71 @@ for the reasoning behind the split. ```bash git clone https://github.com/cbjpdev/clean-api-starter.git cd clean-api-starter - -# Populate each variant's database/ from the shared root copy (see below). -./scripts/sync-database.sh - -# Work inside whichever variant you're changing: -cd layered # or: cd modular dotnet build CleanApiStarter.slnx dotnet test CleanApiStarter.slnx ``` -To run a variant's API locally via Aspire (starts PostgreSQL with the schema -applied, plus pgAdmin): +To run the API locally via Aspire (starts PostgreSQL with the schema applied, plus +pgAdmin): ```bash -cd layered # or: cd modular dotnet run --project src/CleanApiStarter.AppHost ``` +Alternatively, start PostgreSQL with `docker compose up -d` and run +`src/CleanApiStarter.Api` directly. + ## Making changes 1. Branch from `main` (convention: `feature/-short-description`). 2. Keep changes focused; unrelated refactoring belongs in its own PR. -3. If a change is conceptually shared (the domain model, the database schema, a - platform default), apply it to **both** variants so they don't drift. -4. Open a pull request against `main`. CI must pass before review. +3. Open a pull request against `main`. CI must pass before review. ### What CI enforces -Every workflow runs as a matrix over both variants -([build.yml](.github/workflows/build.yml), +The same checks run on every PR ([build.yml](.github/workflows/build.yml), [template.yml](.github/workflows/template.yml), [codeql.yml](.github/workflows/codeql.yml)): - **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run - `dotnet format CleanApiStarter.slnx` in the variant before pushing; style rules - live in each variant's `.editorconfig`. -- **Build and tests** — the full suite per variant, including the - Testcontainers-based integration tests. -- **Template verification** — each variant is packed, installed, and instantiated + `dotnet format CleanApiStarter.slnx` before pushing; style rules live in + [.editorconfig](.editorconfig). +- **Build and tests** — the full suite, including the Testcontainers-based + integration tests. +- **Template verification** — the template is packed, installed, and instantiated to confirm generated projects build. -- **CodeQL** security scanning per variant. +- **CodeQL** security scanning. ## Tests -- Unit tests: `/tests/CleanApiStarter.Application.UnitTests` (xUnit v3, - AutoFixture, NSubstitute, Shouldly). -- Integration tests: `/tests/CleanApiStarter.Api.IntegrationTests` - (xUnit v3 against a real PostgreSQL container; Docker must be running). -- Shared test infrastructure (the `ApiApplicationFactory` class fixture) - lives in `/tests/CleanApiStarter.Tests`. +- Unit tests: `tests/CleanApiStarter.Application.UnitTests` (xUnit v3, AutoFixture, + NSubstitute, Shouldly). +- Integration tests: `tests/CleanApiStarter.Api.IntegrationTests` (xUnit v3 + against a real PostgreSQL container; Docker must be running). +- Shared test infrastructure (the `ApiApplicationFactory` class fixture) lives in + `tests/CleanApiStarter.Tests`. New behavior needs tests. The integration test factory is an `IClassFixture`, so all tests in a class share one database — isolate through unique user ids or resource names rather than assuming a fresh schema. -For a local coverage report: `cd && ./scripts/test-coverage.sh`. +For a local coverage report: `./scripts/test-coverage.sh`. -## Database changes +## Boundary enforcement + +Dependency direction is enforced by [NsDepCop](https://github.com/realvizu/NsDepCop): +`src/CleanApiStarter.Api/config.nsdepcop` lists the illegal namespace directions +(for example `Domain ✗→ Infrastructure`, `Features ✗→ Infrastructure`), and +`WarningsAsErrors=NSDEPCOP01` makes a violation a build error. Update +`config.nsdepcop` when you add a layer or rule. `AspNetCoreDefaults` is kept +application-agnostic — don't introduce a dependency from it onto the Api's +settings or features. -`database/migrations` **at the repository root is the single source of truth.** -Each variant gets its own git-ignored copy via -[`scripts/sync-database.sh`](scripts/sync-database.sh); never edit the variant -copies directly. After changing the root scripts, re-run the sync (CI does this -automatically before building or packing). +## Database changes -The EF Core entity configurations in each variant -(`Infrastructure/Persistence/Configuration`) must be kept in sync with the SQL by -hand. When you change one, change the other — and apply it to both variants. +`database/migrations` is the schema's source of truth. The EF Core entity +configurations in `src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration` +must be kept in sync with the SQL by hand. When you change one, change the other. Conventions: @@ -102,33 +93,22 @@ Conventions: order by both the AppHost and the integration test factory. - Migrations must be idempotent (`CREATE TABLE IF NOT EXISTS`, etc.). -### Boundary enforcement - -- **Layered** enforces dependency direction through project references — a - violation simply doesn't compile. -- **Modular** enforces it with NsDepCop: `config.nsdepcop` in the `Api` project - lists illegal namespace directions and `WarningsAsErrors=NSDEPCOP01` makes a - violation a build error. Update `config.nsdepcop` when you add a layer or rule. - ## Template changes -If a change affects project structure, file names, or a variant's -`.template.config/template.json`, verify that variant's output locally: +If a change affects project structure, file names, or +[.template.config/template.json](.template.config/template.json), verify the +template output locally: ```bash -./scripts/sync-database.sh -cd layered # or: cd modular -dotnet pack CleanApiStarter.Template.csproj --configuration Release --output ../artifacts -# layered -> CleanApiStarter.Template.Layered / clean-api-layered -# modular -> CleanApiStarter.Template.Modular / clean-api-modular -dotnet new install ../artifacts/CleanApiStarter.Template.Layered.0.0.0.nupkg --force -dotnet new clean-api-layered -n DemoProduct -o /tmp/DemoProduct +dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts +dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force +dotnet new clean-api-starter -n DemoProduct -o /tmp/DemoProduct dotnet build /tmp/DemoProduct ``` -Repository-only files (this guide, `docs/`, root CI/release workflows, install -scripts) must stay outside the variant folders or be listed in the variant's -`template.json` `exclude` section so they don't ship inside generated projects. +Repository-only files (this guide, `adr/`, `docs/`, release/template CI workflows, +install scripts) must be listed in the `exclude` section of `template.json` so they +don't ship inside generated projects. ## Reporting issues diff --git a/layered/CleanApiStarter.Template.csproj b/CleanApiStarter.Template.csproj similarity index 88% rename from layered/CleanApiStarter.Template.csproj rename to CleanApiStarter.Template.csproj index 36b0398..8325b7d 100644 --- a/layered/CleanApiStarter.Template.csproj +++ b/CleanApiStarter.Template.csproj @@ -8,9 +8,9 @@ true $(NoWarn);NU5128 - CleanApiStarter.Template.Layered + CleanApiStarter.Template 0.0.0 - Clean API Starter Template (Layered) + Clean API Starter Template Chathuranga Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. dotnet-new;template;aspnetcore;api;clean-architecture;aspire;postgresql;opentelemetry;scalar;jwt;efcore @@ -24,7 +24,7 @@ diff --git a/modular/CleanApiStarter.slnx b/CleanApiStarter.slnx similarity index 100% rename from modular/CleanApiStarter.slnx rename to CleanApiStarter.slnx diff --git a/modular/Directory.Build.props b/Directory.Build.props similarity index 100% rename from modular/Directory.Build.props rename to Directory.Build.props diff --git a/modular/Directory.Packages.props b/Directory.Packages.props similarity index 100% rename from modular/Directory.Packages.props rename to Directory.Packages.props diff --git a/README.md b/README.md index 9de93f4..f76656d 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,102 @@ # CleanApiStarter -A Clean Architecture API starter for .NET 10, shipped as two `dotnet new` -template variants so you can pick the structure that fits the project — without -giving up enforced dependency boundaries in either. - -| Variant | Folder | `dotnet new` | Shape | Boundaries enforced by | -| --- | --- | --- | --- | --- | -| **Layered** | [`layered/`](layered/) | `clean-api-layered` | Multi-project (Api, Application, Domain, Infrastructure, …) | Project references (compiler) | -| **Modular** | [`modular/`](modular/) | `clean-api-modular` | One business project + reused platform projects | NsDepCop analyzer (build-breaking) | - -Both are genuine Clean Architectures — they differ in feature organization and in -*how* boundaries are enforced, not in whether dependencies point inward. See -[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) -for the reasoning behind the two variants, including how this relates to Vertical -Slice Architecture and the modular monolith. +`CleanApiStarter` is a Clean Architecture API starter for .NET 10, ASP.NET Core +Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API +versioning, FluentValidation, Scalar, EF Core, and automated tests. + +The whole application lives in a single `CleanApiStarter.Api` project, organized by +feature and layer folders, with the Clean Architecture dependency rule enforced at +compile time by [NsDepCop](https://github.com/realvizu/NsDepCop) rather than by +separate projects. The sample domain is project management: authenticated users +create projects, manage tasks, filter tasks by status, and complete them. + +The reasoning behind this structure — and how it evolved — is recorded in +[`adr/`](adr/) and [`docs/architecture`](docs/architecture/clean-architecture-and-vertical-slices.md). + +## Features + +- Single application project with feature-centric organization, plus an + application-agnostic `AspNetCoreDefaults` project and an Aspire `AppHost`. +- Compile-time boundary enforcement via NsDepCop (`config.nsdepcop`, + `WarningsAsErrors=NSDEPCOP01`): a forbidden dependency fails the build. +- Minimal APIs organized by endpoint group and API version folders. +- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. +- Versioned OpenAPI documents shown with Scalar. +- Google ID token sign-in that issues the API's own JWT. +- ASP.NET Core Identity for local users, roles, and external login storage. +- Real JWT bearer authentication for protected APIs. +- EF Core with PostgreSQL for application data and Identity storage. +- PostgreSQL local development through Aspire or Docker Compose. +- Database schema scripts under `database/migrations`. +- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. +- HTTP request logging with authenticated user id. +- `X-Request-ID` response header containing the current trace id. +- Problem Details exception responses. +- Response compression with Brotli and gzip. +- FluentValidation endpoint validation returning `422 Unprocessable Entity`. +- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. +- xUnit v3 unit and Testcontainers-backed integration tests. + +## Solution Layout + +```text +CleanApiStarter +├── database +│ └── migrations +├── scripts +├── src +│ ├── CleanApiStarter.Api ← the whole application +│ │ ├── config.nsdepcop ← enforced dependency rules +│ │ ├── Common ← shared kernel (paged results, IUser) +│ │ ├── Domain ← entities (no outward dependencies) +│ │ ├── Features ← Auth, Projects (endpoints + handlers + validators) +│ │ ├── Infrastructure ← DbContext, EF config, repositories, identity +│ │ ├── Configuration ← settings classes + options registration +│ │ ├── Endpoints, Services ← composition / web wiring +│ │ └── Program.cs +│ └── Common +│ ├── CleanApiStarter.AppHost ← Aspire orchestration +│ └── CleanApiStarter.AspNetCoreDefaults ← reusable, app-agnostic web/runtime defaults +└── tests + ├── CleanApiStarter.Api.IntegrationTests + ├── CleanApiStarter.Application.UnitTests + └── CleanApiStarter.Tests +``` -## Which one should I use? +Dependency direction (enforced by `config.nsdepcop`): -- **Layered** — when you want hard, physical boundaries and don't mind the - ceremony of multiple projects. Violations literally don't compile. -- **Modular** — for MVPs, smaller services, or teams that want feature-centric - organization and less ceremony, with boundaries still enforced at build time. +- `Domain` depends on nothing else in the app — not `Features`, not `Infrastructure`. +- `Features` (application logic) may not depend on `Infrastructure`; it talks to + abstractions that `Infrastructure` implements and DI wires up. +- `Infrastructure`, `Endpoints`, `Configuration`, and `Program.cs` form the + composition root. +- `AspNetCoreDefaults` is an application-agnostic platform project — it holds no + knowledge of the application's settings; the Api binds configuration-dependent + options (such as JWT validation) itself. -## Getting started +A violation — for example `using` an `Infrastructure` type from `Domain` — fails +the build with `error NSDEPCOP01`. -Each variant is a self-contained solution. Pick one and read its README -([layered](layered/README.md), [modular](modular/README.md)): +## Requirements -```bash -cd layered # or: cd modular -dotnet build CleanApiStarter.slnx -``` +- .NET SDK `10.0.203` or a compatible latest-feature SDK (pinned in `global.json`). +- Docker Desktop (for integration tests and local PostgreSQL). -To generate a new project from a variant once the templates are published: +## Getting started ```bash -dotnet new clean-api-layered -n MyApi -dotnet new clean-api-modular -n MyApi +dotnet run --project src/CleanApiStarter.AppHost # API + PostgreSQL via Aspire +# or +docker compose up -d && dotnet run --project src/CleanApiStarter.Api ``` -### Working in the repo - -The database schema lives once at the repo root (`database/`) and is synced into -each variant by `./scripts/sync-database.sh` (run it after cloning and after any -schema change). See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. +Run the tests with `dotnet test CleanApiStarter.slnx`. -## Repository layout +To generate a new project from the published template: +```bash +dotnet new clean-api-starter -n MyApi ``` -clean-api-starter/ -├── database/migrations/ ← single source of truth, synced into each variant -├── docs/architecture/ ← reasoning: Clean Architecture, vertical slices, duplication -├── scripts/ ← repo-level tooling (database sync) -├── layered/ ← variant 1: multi-project template (self-contained) -├── modular/ ← variant 2: single-project + NsDepCop template -├── CONTRIBUTING.md -└── LICENSE -``` - -Repo-level files (this README, `LICENSE`, `CONTRIBUTING.md`, `docs/`, CI) live at -the root and are shared across variants. Everything a generated project needs is -inside the variant folder. ## Contributing diff --git a/layered/docker-compose.yml b/docker-compose.yml similarity index 100% rename from layered/docker-compose.yml rename to docker-compose.yml diff --git a/layered/global.json b/global.json similarity index 100% rename from layered/global.json rename to global.json diff --git a/layered/.editorconfig b/layered/.editorconfig deleted file mode 100644 index f23b7d6..0000000 --- a/layered/.editorconfig +++ /dev/null @@ -1,367 +0,0 @@ -root = true - -# All files -[*] -indent_style = space - -# Xml files -[*.xml] -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 = true -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:warning -csharp_style_var_for_built_in_types = false:warning -csharp_style_var_when_type_is_apparent = false:warning - -# Prefer explicit types instead of var -dotnet_diagnostic.IDE0008.severity = warning - -# 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 - -#### 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 = namespace -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 - diff --git a/layered/.github/workflows/build.yml b/layered/.github/workflows/build.yml deleted file mode 100644 index 5d27005..0000000 --- a/layered/.github/workflows/build.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Build - -on: - pull_request: - push: - branches: - - main - -jobs: - build: - name: Build and test - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - global-json-file: global.json - - - name: Restore - run: dotnet restore CleanApiStarter.slnx - - - name: Build - run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - - - name: Test - run: dotnet test CleanApiStarter.slnx --no-build --configuration Release /nr:false -v:minimal diff --git a/layered/.gitignore b/layered/.gitignore deleted file mode 100644 index 2c00c02..0000000 --- a/layered/.gitignore +++ /dev/null @@ -1,408 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -.DS_Store - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[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 Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# 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 - -# JetBrains IDE and Junie local assistant files -.idea/ -.junie/ - -# 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 -.DS_Store - -# Synced from the repo-root database/ (single source of truth) by scripts/sync-database.sh -/database/ diff --git a/layered/.template.config/template.json b/layered/.template.config/template.json deleted file mode 100644 index 7102935..0000000 --- a/layered/.template.config/template.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/template", - "author": "Chathuranga", - "classifications": [ - "Web", - "API", - "Clean Architecture", - "Aspire", - "PostgreSQL", - "OpenTelemetry" - ], - "identity": "CleanApiStarter.Template.Layered", - "name": "Clean API Starter (Layered)", - "shortName": "clean-api-layered", - "sourceName": "CleanApiStarter", - "preferNameDirectory": true, - "symbols": { - "caPackageVersion": { - "type": "parameter", - "datatype": "text", - "defaultValue": "0.0.0", - "replaces": "0.0.0" - } - }, - "tags": { - "language": "C#", - "type": "solution" - }, - "sources": [ - { - "modifiers": [ - { - "exclude": [ - ".git/**", - ".idea/**", - ".junie/**", - ".vs/**", - "**/.DS_Store", - "**/bin/**", - "**/obj/**", - "artifacts/**", - ".github/workflows/release.yml", - ".github/workflows/template.yml", - "CleanApiStarter.Template.csproj", - "CONTRIBUTING.md", - "docs/**", - "scripts/install-template.sh" - ] - } - ] - } - ] -} diff --git a/layered/AGENTS.md b/layered/AGENTS.md deleted file mode 100644 index e92b9af..0000000 --- a/layered/AGENTS.md +++ /dev/null @@ -1,181 +0,0 @@ -# Agent Instructions - -This repository is a Clean Architecture API starter template named `CleanApiStarter`. - -## Naming - -- Use `CleanApiStarter` for solution, project, assembly, and namespace naming. -- Do not introduce `CleanArchitecture` namespaces or assembly names. -- Keep project names fully qualified: - - `CleanApiStarter.Api` - - `CleanApiStarter.Application` - - `CleanApiStarter.Configuration` - - `CleanApiStarter.Domain` - - `CleanApiStarter.Infrastructure` - - `CleanApiStarter.AppHost` - - `CleanApiStarter.AspNetCore` - - `CleanApiStarter.UnitTests` - -## Solution Structure - -- Keep clean architecture application layers under the `/src/` solution folder: - - API - - Application - - Configuration - - Domain - - Infrastructure -- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`: - - AppHost - - AspNetCore -- Keep database scripts under top-level `database/migrations`. -- Keep important root files in solution items, including `README.md`, `docker-compose.yml`, `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, and `global.json`. - -## Clean Architecture Rules - -- Dependencies point inward: - - `Domain` references nothing. - - `Application` references `Domain`. - - `Configuration` references no application layers and contains only plain options classes plus options registration helpers. - - `Infrastructure` references `Application` and `Configuration`. - - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- Keep repository interfaces in `Application`, not `Domain`. -- Keep database implementation details in `Infrastructure`. -- Keep domain models persistence-agnostic. Persistence mapping details belong in Infrastructure EF Core configuration. -- Use `required` for non-null required scalar properties on DTOs and domain entities. Do not use `= string.Empty` only to satisfy nullable reference type warnings. -- Keep collection properties initialized with `= []`. -- Keep EF navigation properties as `= null!` when EF is responsible for materializing them. -- Use nullable types such as `string?` or `DateTime?` only for genuinely optional values. -- Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path. -- Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern. -- Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason. -- Keep Identity-specific classes, such as `ApplicationUser` and auth services, under `CleanApiStarter.Infrastructure/Identity`. - -## API and Application Conventions - -- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Projects` or `Application/Features/Auth`. -- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. -- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. -- Return single resources as their DTO object directly. -- Use `ArrayResult` instead of returning raw arrays from non-paginated list endpoints. -- Use `PaginatedQuery` for paginated request parameters and return `PaginatedResult` directly for paginated collection responses. -- Use FluentValidation for request validation. Put validators beside feature request models in `Application`, and rely on the shared Minimal API validation endpoint filter in `CleanApiStarter.AspNetCore`. FluentValidation failures should return `422 Unprocessable Entity`. -- Do not use DataAnnotations for application request validation. -- Use Scalar, not Swagger/Swashbuckle. -- Use the .NET 10 API versioning/OpenAPI setup from the Microsoft .NET blog: - - `Asp.Versioning.Http` v10 - - `Asp.Versioning.Mvc.ApiExplorer` v10 - - `Asp.Versioning.OpenApi` - - `builder.Services.AddApiVersioning(...).AddApiExplorer(...).AddOpenApi();` - - `app.MapOpenApi().WithDocumentPerVersion();` - - `app.MapScalarApiReference(...)` configured from `app.DescribeApiVersions()` -- Do not add `Swashbuckle.AspNetCore`. -- Cancellation tokens are explicit: - - Do not use `CancellationToken cancellationToken = default` in service or repository contracts. - - API actions should accept `CancellationToken cancellationToken` and pass it through. -- Use explicit local types. The `.editorconfig` prefers explicit types over `var`. -- Use project-level `GlobalUsings.cs`; avoid adding file-level `using` directives unless there is a very specific reason. - -## Database and Aspire - -- Use a single Postgres database: `postgres`. -- Do not create or reference a separate feature-specific database. -- AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`. -- `docker-compose.yml` should use `POSTGRES_DB=postgres`. -- Database schema scripts live in `database/migrations`, for example: - - `database/migrations/V001__create_projects_and_tasks_tables.sql` -- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. -- EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`. -- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. -- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`. -- In Aspire, use a server resource name that does not conflict with the database resource name, for example: - - server resource: `postgres-server` - - database resource: `postgres` -- Aspire Postgres should use a volume mounted at `/var/lib/postgresql`. - -## ASP.NET Core Defaults - -- Keep `CleanApiStarter.AspNetCore`. -- It centralizes Aspire-friendly runtime defaults: - - OpenTelemetry traces, metrics, logs, and OTLP export - - problem-details exception handling - - `/version` endpoint - - health endpoints - - service discovery - - default HTTP client resilience - - security defaults such as removing the Kestrel `Server` response header -- API `Program.cs` should stay small and call: - - `builder.AddAspNetCoreDefaults();` - - `app.UseAspNetCoreDefaults();` - - `app.MapDefaultEndpoints();` -- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. -- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. -- Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`. -- Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers. -- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{ProjectId}`, `{TaskId}`, and `{UserId}`. -- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. -- Do not add generic options registration helpers until the template has multiple real options sections that need them. -- Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`. -- Keep dependency injection validation enabled with `ValidateOnBuild` and `ValidateScopes`. - -## API Style - -- Prefer Minimal APIs for this template. -- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Projects.cs`. -- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. -- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. -- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. -- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. -- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetProjectsV1` and `GetProjectsV2`. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. - -## Authentication - -- Authentication is API-first: - - clients obtain a Google ID token - - `POST /api/auth/google` validates it - - the API issues its own JWT -- Keep JWT bearer authentication setup in `CleanApiStarter.AspNetCore`. -- Keep local user/role storage in ASP.NET Core Identity under `CleanApiStarter.Infrastructure`. -- Do not use cookies as the default API auth mechanism. -- Keep the development Google login helper page unversioned so it can be opened directly in a browser. -- Protected API calls should send `Authorization: Bearer `. Send `X-Api-Version` only when selecting a non-default API version. - -## Packages - -- Manage versions centrally in `Directory.Packages.props`. -- Keep `PackageVersion` items sorted alphabetically by `Include`. -- Do not add package versions directly in individual `.csproj` files. - -## Template Packaging - -- This repository is also the `dotnet new` template source. -- Keep template metadata in `.template.config/template.json`. -- Keep NuGet template package metadata in `CleanApiStarter.Template.csproj`. -- Use `dotnet pack` and `dotnet nuget push` for template packaging and publishing. Do not use `nuget pack`, `nuget.exe`, or Mono. -- Use `scripts/install-template.sh` to pack and install the local template. -- Keep repo-only template packaging scripts excluded from generated template output. -- Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. -- Keep release publishing triggered by GitHub Release publication with `vX.Y.Z` tags, not manual version inputs. - -## Testing - -- Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. -- Reusable test helpers belong in `CleanApiStarter.Tests`. -- Application unit tests should reference `CleanApiStarter.Tests` instead of duplicating common test setup. -- API integration tests use MSTest and Testcontainers for Postgres. -- API integration tests should keep real JWT bearer authentication active. Generate test JWTs from `appsettings.Testing.json` instead of replacing authentication with a fake scheme. -- API integration tests should start a Postgres Testcontainer and apply SQL scripts from `database/migrations`. -- Keep test app settings in `appsettings.Testing.json`. -- Test methods must follow the naming pattern `UnitOfWork_StateUnderTest_ExpectedBehavior`. -- Tests must follow AAA format with explicit `// Arrange`, `// Act`, and `// Assert` sections. - -## Verification - -- After structural or package changes, run: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -- Aspire AppHost builds may need to run outside a sandbox because the Aspire SDK touches local runtime/process resources. diff --git a/layered/CleanApiStarter.slnx b/layered/CleanApiStarter.slnx deleted file mode 100644 index acb877a..0000000 --- a/layered/CleanApiStarter.slnx +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/layered/Directory.Build.props b/layered/Directory.Build.props deleted file mode 100644 index f2acb86..0000000 --- a/layered/Directory.Build.props +++ /dev/null @@ -1,10 +0,0 @@ - - - - net10.0 - true - - enable - enable - - \ No newline at end of file diff --git a/layered/Directory.Packages.props b/layered/Directory.Packages.props deleted file mode 100644 index 5b5aa04..0000000 --- a/layered/Directory.Packages.props +++ /dev/null @@ -1,60 +0,0 @@ - - - - true - 13.4.4 - 10.0.9 - 10.0.0 - 10.0.9 - 10.7.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/layered/LICENSE b/layered/LICENSE deleted file mode 100644 index f288702..0000000 --- a/layered/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/layered/README.md b/layered/README.md deleted file mode 100644 index a8c68c4..0000000 --- a/layered/README.md +++ /dev/null @@ -1,600 +0,0 @@ -# CleanApiStarter - -`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. - -The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. - -## Features - -- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. -- Minimal APIs organized by endpoint group and API version folders. -- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. -- Versioned OpenAPI documents shown with Scalar. -- Google ID token sign-in that issues the API's own JWT. -- ASP.NET Core Identity for local users, roles, and external login storage. -- Real JWT bearer authentication for protected APIs. -- EF Core with PostgreSQL for application data and Identity storage. -- PostgreSQL local development through Aspire or Docker Compose. -- Database schema scripts under `database/migrations`. -- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. -- HTTP request logging with authenticated user id. -- `X-Request-ID` response header containing the current trace id. -- Problem Details exception responses. -- Response compression with Brotli and gzip. -- FluentValidation endpoint validation returning `422 Unprocessable Entity`. -- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. -- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. -- xUnit v3 API integration tests with Testcontainers for PostgreSQL. -- Local coverage script that generates an HTML report with ReportGenerator. -- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. - -## Solution Layout - -```text -CleanApiStarter -├── database -│ └── migrations -├── scripts -├── src -│ ├── CleanApiStarter.Api -│ ├── CleanApiStarter.Application -│ ├── CleanApiStarter.Domain -│ ├── CleanApiStarter.Infrastructure -│ └── Common -│ ├── CleanApiStarter.AppHost -│ ├── CleanApiStarter.AspNetCore -│ └── CleanApiStarter.Configuration -└── tests - ├── CleanApiStarter.Api.IntegrationTests - ├── CleanApiStarter.Application.UnitTests - └── CleanApiStarter.Tests -``` - -Layer dependencies point inward: - -- `Domain` references nothing. -- `Application` references `Domain`. -- `Infrastructure` references `Application` and `Configuration`. -- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- `Configuration` contains plain settings classes and options registration. -- `AspNetCore` contains reusable web/runtime defaults. -- `AppHost` contains Aspire orchestration. - -## Requirements - -- .NET SDK `10.0.203` or compatible latest feature SDK -- Docker Desktop -- Google Chrome, only for the local coverage script opening the HTML report - -The SDK is pinned in `global.json`: - -```json -{ - "sdk": { - "version": "10.0.203", - "rollForward": "latestFeature" - } -} -``` - -## Use As A Template - -Install the template package from NuGet: - -```bash -dotnet new install CleanApiStarter.Template -``` - -Create a new solution: - -```bash -dotnet new clean-api-starter -n MyProduct -``` - -The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. - -While developing the template locally, run: - -```bash -scripts/install-template.sh -``` - -That script: - -- uninstalls the previous local template package -- packs the current repo with `CleanApiStarter.Template.csproj` -- installs the generated local `.nupkg` -- deletes the temporary package from `artifacts` - -Then create a local test solution: - -```bash -dotnet new clean-api-starter -n DemoProduct -``` - -## Run With Aspire - -```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj -``` - -Aspire starts: - -- the API -- a PostgreSQL container using `postgres:latest` -- pgAdmin -- the Aspire dashboard - -Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. - -The AppHost configures PostgreSQL like this: - -- server resource: `postgres-server` -- database resource: `postgres` -- data volume: `clean-api-starter-postgres-data` -- volume mount: `/var/lib/postgresql` -- init scripts: `database/migrations` - -PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. - -## Run With Docker Compose - -If you want only PostgreSQL without Aspire: - -```bash -docker compose up -d -dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. - -## Database - -Database scripts live here: - -```text -database/migrations -├── V001__create_projects_and_tasks_tables.sql -└── V002__create_identity_tables.sql -``` - -The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. - -`ApplicationDbContext` lives in: - -```text -src/CleanApiStarter.Infrastructure/Persistence -``` - -EF Core mappings live in: - -```text -src/CleanApiStarter.Infrastructure/Persistence/Configuration -``` - -## API Style - -The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. - -Endpoint groups live under version folders: - -```text -src/CleanApiStarter.Api/Endpoints/V1 -src/CleanApiStarter.Api/Endpoints/V2 -``` - -Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: - -```csharp -app.MapEndpoints(Assembly.GetExecutingAssembly()); -``` - -Endpoint names are globally unique across versions, for example: - -```text -GetProjectsV1 -GetProjectsV2 -``` - -## API Versioning - -API versioning uses header-based version selection: - -```http -X-Api-Version: 1.0 -``` - -The header is optional. Requests without `X-Api-Version` use v1. - -The project uses the .NET 10 API versioning/OpenAPI package setup: - -- `Asp.Versioning.Http` -- `Asp.Versioning.Mvc.ApiExplorer` -- `Asp.Versioning.OpenApi` -- `Microsoft.AspNetCore.OpenApi` - -OpenAPI documents are generated per version and displayed in Scalar. - -## Scalar - -This project uses Scalar instead of Swagger/Swashbuckle. - -When running in Development, OpenAPI and Scalar are mapped by the API: - -- versioned OpenAPI documents -- Scalar API reference with version selection - -## Authentication - -Authentication is API-first. - -The intended production flow is: - -1. A client obtains a Google ID token. -2. The client sends it to `POST /api/auth/google`. -3. The API validates the Google ID token. -4. The API creates or updates the local ASP.NET Core Identity user. -5. The API issues its own JWT. -6. Protected API calls use: - -```http -Authorization: Bearer -``` - -The API does not use cookies as the default authentication mechanism. - -### Google Client ID - -Create an OAuth client in Google Cloud Console and set the client id with user secrets: - -```bash -dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -For local browser testing, open the development helper page: - -```text -https://localhost:7285/auth/google-login -``` - -The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. - -## Authorization - -Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. - -Project authorization rules are implemented in the application service and repository queries: - -- users can only see projects they belong to -- users can only see and manage tasks inside projects they belong to -- project deletion is owner-only - -## Application Features - -The reference feature is project and task management. - -Projects: - -- create project -- list current user's projects -- get project by id -- delete project as owner - -Tasks: - -- create task under a project -- list tasks with `limit` and `offset` -- filter tasks by status -- get task by id -- update task -- complete task -- delete task - -The application project is organized by feature: - -```text -src/CleanApiStarter.Application/Features/Auth -src/CleanApiStarter.Application/Features/Projects -``` - -Cross-cutting abstractions live under: - -```text -src/CleanApiStarter.Application/Common -``` - -## Response Shapes - -Single-resource endpoints return the resource DTO directly. - -Paginated endpoints return `PaginatedResult`: - -```json -{ - "items": [], - "limit": 20, - "offset": 0, - "totalCount": 0, - "hasPreviousPage": false, - "hasNextPage": false -} -``` - -Non-paginated collection endpoints should return `ArrayResult`: - -```json -{ - "items": [], - "count": 0 -} -``` - -## Validation - -Request validation uses FluentValidation, not DataAnnotations. - -Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. - -Validation failures return: - -```http -422 Unprocessable Entity -``` - -## Configuration - -Configuration classes live in `CleanApiStarter.Configuration`. - -The root configuration object is: - -```csharp -AppSettings -``` - -It includes: - -- `ConnectionStrings.Postgres` -- `Authentication.Google.ClientId` -- `Authentication.Jwt.Issuer` -- `Authentication.Jwt.Audience` -- `Authentication.Jwt.SigningKey` -- `Authentication.Jwt.ExpirationMinutes` - -The API registers settings once: - -```csharp -builder.Services.AddAppSettings(builder.Configuration); -``` - -Services can inject `AppSettings` directly. - -## Observability - -`CleanApiStarter.AspNetCore` centralizes runtime defaults: - -- OpenTelemetry traces -- OpenTelemetry metrics -- OpenTelemetry logs -- OTLP export for Aspire -- structured log formatting -- HTTP request logging -- health checks -- service discovery -- HTTP client resilience -- problem details -- response compression -- request id header -- Kestrel `Server` header removal - -When the API runs under Aspire, inspect the dashboard for: - -- API request traces -- Npgsql database traces -- structured logs -- request logs with `UserId` -- runtime metrics -- resource health - -Every response includes: - -```http -X-Request-ID: -``` - -Use this value to correlate client responses with logs and traces. - -## Health And Version Endpoints - -The shared ASP.NET Core defaults map: - -```text -/health -/alive -/version -``` - -`/version` exposes the running application version. - -## Testing - -### Unit Tests - -Application unit tests use: - -- xUnit v3 -- AutoFixture.xUnit3 -- AutoFixture.AutoNSubstitute -- NSubstitute -- Shouldly - -Common test helpers live in: - -```text -tests/CleanApiStarter.Tests -``` - -Current reusable helpers: - -- `AutoNSubstituteDataAttribute` -- `ApiApplicationFactory` - -Test names follow: - -```text -UnitOfWork_StateUnderTest_ExpectedBehavior -``` - -Tests use AAA sections: - -```csharp -// Arrange -// Act -// Assert -``` - -Run unit tests: - -```bash -dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj -``` - -### API Integration Tests - -API integration tests use: - -- xUnit v3 -- Microsoft.AspNetCore.Mvc.Testing -- Testcontainers for PostgreSQL -- Shouldly - -The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. - -The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. - -Run integration tests: - -```bash -dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj -``` - -Run all tests: - -```bash -dotnet test CleanApiStarter.slnx -``` - -## Coverage - -Generate coverage and open the HTML report in Chrome: - -```bash -scripts/test-coverage.sh -``` - -The script: - -- deletes the previous `artifacts/coverage` folder -- restores local .NET tools -- runs tests with `XPlat Code Coverage` -- generates an HTML report with ReportGenerator -- opens the report in Google Chrome - -Coverage output: - -```text -artifacts/coverage/report/index.html -``` - -## Build - -Restore and build: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -## CI - -GitHub Actions workflows live in `.github/workflows`: - -- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. -- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. -- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. -- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. - -## Package Management - -Package versions are managed centrally in: - -```text -Directory.Packages.props -``` - -Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. - -## Coding Conventions - -- Use explicit local types. -- Use project-level `GlobalUsings.cs`. -- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. -- Use `required` and `init` for required non-null DTO and domain properties. -- Use nullable types only for genuinely optional values. -- Use structured logging message templates instead of interpolated log strings. -- Keep repository interfaces in `Application`. -- Keep repository implementations and EF Core details in `Infrastructure`. -- Keep domain models persistence-agnostic. -- Do not reintroduce Dapper for the default data access path. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. -- Do not add Swashbuckle; use Scalar. - -## Troubleshooting - -### Aspire certificate errors - -If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: - -```bash -dotnet dev-certs https --check --trust -``` - -If needed, reset and trust again: - -```bash -dotnet dev-certs https --clean -dotnet dev-certs https --trust -dotnet dev-certs https --check --trust -``` - -On macOS, accept the Keychain prompt and enter your password if requested. - -### PostgreSQL init scripts did not run - -Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. - -For Docker Compose: - -```bash -docker compose down -v -docker compose up -d -``` - -For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. - -### Postgres latest and data volumes - -This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: - -```text -/var/lib/postgresql -``` - -Do not mount the volume at: - -```text -/var/lib/postgresql/data -``` - -## License - -This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. diff --git a/layered/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/layered/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj deleted file mode 100644 index 49fc06b..0000000 --- a/layered/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - CleanApiStarter.Api - CleanApiStarter.Api - clean-api-starter - - - - - - - - - diff --git a/layered/src/CleanApiStarter.Api/GlobalUsings.cs b/layered/src/CleanApiStarter.Api/GlobalUsings.cs deleted file mode 100644 index 58be6da..0000000 --- a/layered/src/CleanApiStarter.Api/GlobalUsings.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Global using directives - -global using System.Reflection; -global using System.Security.Claims; -global using System.Text.Encodings.Web; - -global using CleanApiStarter.Api.Endpoints; -global using CleanApiStarter.Api.Services; -global using CleanApiStarter.Application; -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.AspNetCore; -global using CleanApiStarter.Configuration; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure; - -global using Microsoft.AspNetCore.Http; -global using Microsoft.AspNetCore.Mvc; -global using Microsoft.AspNetCore.Routing; diff --git a/layered/src/CleanApiStarter.Api/Program.cs b/layered/src/CleanApiStarter.Api/Program.cs deleted file mode 100644 index 4637d70..0000000 --- a/layered/src/CleanApiStarter.Api/Program.cs +++ /dev/null @@ -1,20 +0,0 @@ -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.AddAspNetCoreDefaults(); - -builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); -builder.Services.AddScoped(); - -WebApplication app = builder.Build(); - -app.UseAspNetCoreDefaults(); -app.MapOpenApiDocumentation(); -app.MapGoogleLoginPage(); -app.MapEndpoints(Assembly.GetExecutingAssembly()); -app.MapDefaultEndpoints(); - -app.Run(); - -public partial class Program; diff --git a/layered/src/CleanApiStarter.Api/Properties/launchSettings.json b/layered/src/CleanApiStarter.Api/Properties/launchSettings.json deleted file mode 100644 index a6d24cd..0000000 --- a/layered/src/CleanApiStarter.Api/Properties/launchSettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "scalar", - "applicationUrl": "https://localhost:7285", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/layered/src/CleanApiStarter.Api/appsettings.json b/layered/src/CleanApiStarter.Api/appsettings.json deleted file mode 100644 index 4d56694..0000000 --- a/layered/src/CleanApiStarter.Api/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/layered/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/layered/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj deleted file mode 100644 index 828befe..0000000 --- a/layered/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - CleanApiStarter.Application - CleanApiStarter.Application - - - - - - - - - - - - - diff --git a/layered/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/layered/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs deleted file mode 100644 index 5064363..0000000 --- a/layered/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CleanApiStarter.Application.Common.Interfaces; - -public interface IUser -{ - string? Id { get; } - List? Roles { get; } -} diff --git a/layered/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs b/layered/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs deleted file mode 100644 index 3e99c59..0000000 --- a/layered/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Application.Common.Models; - -public sealed class ArrayResult -{ - public required IReadOnlyCollection Items { get; init; } - - public int Count => Items.Count; -} diff --git a/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs b/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs deleted file mode 100644 index ca80f8a..0000000 --- a/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Application.Common.Models; - -public sealed class PaginatedQuery -{ - public int Limit { get; set; } = 20; - - public int Offset { get; set; } -} diff --git a/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs b/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs deleted file mode 100644 index 8db6a25..0000000 --- a/layered/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CleanApiStarter.Application.Common.Models; - -public sealed class PaginatedQueryValidator : AbstractValidator -{ - public PaginatedQueryValidator() - { - RuleFor(query => query.Limit) - .InclusiveBetween(1, 100); - - RuleFor(query => query.Offset) - .GreaterThanOrEqualTo(0); - } -} diff --git a/layered/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs b/layered/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs deleted file mode 100644 index c1259db..0000000 --- a/layered/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace CleanApiStarter.Application.Common.Models; - -public sealed class PaginatedResult -{ - public required IReadOnlyCollection Items { get; init; } - - public required int Limit { get; init; } - - public required int Offset { get; init; } - - public required int TotalCount { get; init; } - - public bool HasPreviousPage => Offset > 0; - - public bool HasNextPage => Offset + Limit < TotalCount; - - public PaginatedResult Map(Func map) - { - return new PaginatedResult - { - Items = Items.Select(map).ToArray(), - Limit = Limit, - Offset = Offset, - TotalCount = TotalCount - }; - } -} diff --git a/layered/src/CleanApiStarter.Application/DependencyInjection.cs b/layered/src/CleanApiStarter.Application/DependencyInjection.cs deleted file mode 100644 index 94651fd..0000000 --- a/layered/src/CleanApiStarter.Application/DependencyInjection.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Application; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplication(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - services.AddScoped(); - - return services; - } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/layered/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs deleted file mode 100644 index d115f40..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class AuthTokenDto -{ - public required string AccessToken { get; init; } - - public required DateTimeOffset ExpiresAt { get; init; } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/layered/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs deleted file mode 100644 index f64b9d1..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class CurrentUserDto -{ - public required string UserId { get; init; } - - public required string Email { get; init; } - - public required string Name { get; init; } - - public IReadOnlyCollection Roles { get; init; } = []; -} diff --git a/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs deleted file mode 100644 index b1ebed4..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class GoogleSignInDto -{ - public required string IdToken { get; init; } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs deleted file mode 100644 index 7ef17c3..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class GoogleSignInDtoValidator : AbstractValidator -{ - public GoogleSignInDtoValidator() - { - RuleFor(signIn => signIn.IdToken) - .NotEmpty(); - } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/layered/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs deleted file mode 100644 index 1e1fc6a..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public interface IAuthService -{ - Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); - - Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs deleted file mode 100644 index fb524b1..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class CreateProjectDtoValidator : AbstractValidator -{ - public CreateProjectDtoValidator() - { - RuleFor(project => project.Name) - .NotEmpty() - .MaximumLength(100); - - RuleFor(project => project.Description) - .MaximumLength(2_000); - } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs deleted file mode 100644 index c06a35f..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class CreateProjectTaskDtoValidator : AbstractValidator -{ - public CreateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.DueDate) - .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue) - .WithMessage("Due date must be in the future."); - } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs b/layered/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs deleted file mode 100644 index 8f2f054..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public interface IProjectRepository -{ - Task AddProjectAsync(Project project, CancellationToken cancellationToken); - - Task GetProjectAsync(Guid id, string userId, CancellationToken cancellationToken); - - Task> GetProjectsAsync(string userId, PaginatedQuery query, CancellationToken cancellationToken); - - Task IsProjectMemberAsync(Guid projectId, string userId, CancellationToken cancellationToken); - - Task IsProjectOwnerAsync(Guid projectId, string userId, CancellationToken cancellationToken); - - Task ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken); - - Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); - - Task AddTaskAsync(ProjectTask task, CancellationToken cancellationToken); - - Task GetTaskAsync(Guid projectId, Guid taskId, string userId, CancellationToken cancellationToken); - - Task GetTaskForUpdateAsync(Guid projectId, Guid taskId, string userId, CancellationToken cancellationToken); - - Task> GetTasksAsync( - Guid projectId, - string userId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken); - - Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task SaveChangesAsync(CancellationToken cancellationToken); -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs b/layered/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs deleted file mode 100644 index ff0a3c6..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public interface IProjectService -{ - Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken); - - Task GetProjectAsync(Guid id, CancellationToken cancellationToken); - - Task> GetProjectsAsync(PaginatedQuery query, CancellationToken cancellationToken); - - Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); - - Task CreateTaskAsync(Guid projectId, CreateProjectTaskDto taskDto, CancellationToken cancellationToken); - - Task GetTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken); - - Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken); - - Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs deleted file mode 100644 index c8d8356..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectDto -{ - public required Guid Id { get; init; } - - public required string Name { get; init; } - - public required string Description { get; init; } - - public required string OwnerUserId { get; init; } - - public required DateTime CreatedAt { get; init; } -} - -public sealed class CreateProjectDto -{ - public required string Name { get; init; } - - public required string Description { get; init; } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs deleted file mode 100644 index 2f4dfae..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public enum DeleteProjectResult -{ - Deleted, - NotFound, - Forbidden -} - -public enum ProjectTaskMutationResult -{ - Success, - NotFound, - AlreadyCompleted -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs deleted file mode 100644 index 9d162bd..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs +++ /dev/null @@ -1,248 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectService( - IProjectRepository projectRepository, - IUser currentUser, - ILogger logger) : IProjectService -{ - public async Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - DateTime now = DateTime.UtcNow; - Guid projectId = Guid.NewGuid(); - Project project = new() - { - Id = projectId, - Name = projectDto.Name, - Description = projectDto.Description, - OwnerUserId = userId, - CreatedAt = now, - Members = - [ - new ProjectMember - { - ProjectId = projectId, - UserId = userId, - CreatedAt = now - } - ] - }; - - Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); - - logger.LogInformation("Created project {ProjectId} for user {UserId}", createdProjectId, userId); - - return createdProjectId; - } - - public async Task GetProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - Project? project = await projectRepository.GetProjectAsync(id, userId, cancellationToken); - - return project == null ? null : MapProject(project); - } - - public async Task> GetProjectsAsync( - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - PaginatedResult projects = await projectRepository.GetProjectsAsync(userId, query, cancellationToken); - - logger.LogInformation( - "Retrieved {ProjectCount} projects for user {UserId} with limit {Limit} and offset {Offset}", - projects.Items.Count, - userId, - projects.Limit, - projects.Offset); - - return projects.Map(MapProject); - } - - public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken); - - if (!isMember) - { - return DeleteProjectResult.NotFound; - } - - bool isOwner = await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken); - - if (!isOwner) - { - return DeleteProjectResult.Forbidden; - } - - bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); - - return deleted ? DeleteProjectResult.Deleted : DeleteProjectResult.NotFound; - } - - public async Task CreateTaskAsync( - Guid projectId, - CreateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - ProjectTask task = new() - { - Id = Guid.NewGuid(), - ProjectId = projectId, - Title = taskDto.Title, - Description = taskDto.Description, - Status = ProjectTaskStatus.Todo, - DueDate = taskDto.DueDate, - CreatedAt = DateTime.UtcNow - }; - - Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); - - logger.LogInformation("Created task {TaskId} in project {ProjectId}", taskId, projectId); - - return taskId; - } - - public async Task GetTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskAsync(projectId, taskId, userId, cancellationToken); - - return task == null ? null : MapTask(task); - } - - public async Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - PaginatedResult tasks = await projectRepository.GetTasksAsync( - projectId, - userId, - status, - query, - cancellationToken); - - return tasks.Map(MapTask); - } - - public async Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - task.Title = taskDto.Title; - task.Description = taskDto.Description; - task.DueDate = taskDto.DueDate; - task.Status = taskDto.Status; - task.CompletedAt = taskDto.Status == ProjectTaskStatus.Done - ? task.CompletedAt ?? DateTime.UtcNow - : null; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task CompleteTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - if (task.Status == ProjectTaskStatus.Done) - { - return ProjectTaskMutationResult.AlreadyCompleted; - } - - task.Status = ProjectTaskStatus.Done; - task.CompletedAt = DateTime.UtcNow; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return false; - } - - return await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); - } - - private string GetCurrentUserId() - { - return currentUser.Id ?? throw new UnauthorizedAccessException(); - } - - private static ProjectDto MapProject(Project project) - { - return new ProjectDto - { - Id = project.Id, - Name = project.Name, - Description = project.Description, - OwnerUserId = project.OwnerUserId, - CreatedAt = project.CreatedAt - }; - } - - private static ProjectTaskDto MapTask(ProjectTask task) - { - return new ProjectTaskDto - { - Id = task.Id, - ProjectId = task.ProjectId, - Title = task.Title, - Description = task.Description, - Status = task.Status, - DueDate = task.DueDate, - CreatedAt = task.CreatedAt, - CompletedAt = task.CompletedAt - }; - } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs b/layered/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs deleted file mode 100644 index 03f71fa..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectTaskDto -{ - public required Guid Id { get; init; } - - public required Guid ProjectId { get; init; } - - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } - - public required DateTime CreatedAt { get; init; } - - public DateTime? CompletedAt { get; init; } -} - -public sealed class CreateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public DateTime? DueDate { get; init; } -} - -public sealed class UpdateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } -} diff --git a/layered/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs b/layered/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs deleted file mode 100644 index 0227274..0000000 --- a/layered/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class UpdateProjectTaskDtoValidator : AbstractValidator -{ - public UpdateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.Status) - .IsInEnum(); - - RuleFor(task => task.DueDate) - .Must((task, dueDate) => !dueDate.HasValue - || task.Status == ProjectTaskStatus.Done - || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) - .WithMessage("Due date must be in the future."); - } -} diff --git a/layered/src/CleanApiStarter.Application/GlobalUsings.cs b/layered/src/CleanApiStarter.Application/GlobalUsings.cs deleted file mode 100644 index a886fb3..0000000 --- a/layered/src/CleanApiStarter.Application/GlobalUsings.cs +++ /dev/null @@ -1,11 +0,0 @@ -global using System.Security.Claims; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; - -global using FluentValidation; - -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; diff --git a/layered/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/layered/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs deleted file mode 100644 index 2e7e5bc..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace CleanApiStarter.AspNetCore; - -public static partial class Extensions -{ - private static void AddSecurityDefaults(this IHostApplicationBuilder builder) - { - builder.Services.Configure( - options => options.AddServerHeader = false); - - builder.Services.AddHttpContextAccessor(); - } - - private static void AddProblemDetailsDefaults(this IHostApplicationBuilder builder) - { - builder.Services.AddExceptionHandler(); - builder.Services.AddProblemDetails(); - } - - private static void AddApiVersioningDefaults(this IHostApplicationBuilder builder) - { - builder.Services.AddApiVersioning(options => - { - options.DefaultApiVersion = new ApiVersion(1); - options.AssumeDefaultVersionWhenUnspecified = true; - options.ReportApiVersions = true; - options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version"); - }) - .AddApiExplorer(options => - { - options.GroupNameFormat = "'v'VVV"; - }) - .AddOpenApi(); - } - - private static void AddAuthenticationDefaults(this WebApplicationBuilder builder) - { - builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(); - - builder.Services.AddAuthorization(options => - { - options.FallbackPolicy = new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build(); - }); - - builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) - .Configure((options, appSettings) => - { - JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; - - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtSettings.Issuer, - ValidateAudience = true, - ValidAudience = jwtSettings.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - }); - } - - private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) - { - builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); - - builder.Services.AddHttpLogging(options => - { - options.CombineLogs = true; - options.LoggingFields = - HttpLoggingFields.RequestMethod | - HttpLoggingFields.RequestPath | - HttpLoggingFields.ResponseStatusCode | - HttpLoggingFields.Duration; - }); - - builder.Services.AddHttpLoggingInterceptor(); - } - - private static void AddResponseCompressionDefaults(this IHostApplicationBuilder builder) - { - builder.Services.AddResponseCompression(options => - { - options.EnableForHttps = true; - options.Providers.Add(); - options.Providers.Add(); - options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( - [ - "application/json", - "application/problem+json" - ]); - }); - - builder.Services.Configure(options => - options.Level = CompressionLevel.Fastest); - - builder.Services.Configure(options => - options.Level = CompressionLevel.Fastest); - } - - private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder) - { - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - http.AddStandardResilienceHandler(); - http.AddServiceDiscovery(); - }); - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/layered/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj deleted file mode 100644 index a27ae2d..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - CleanApiStarter.AspNetCore - CleanApiStarter.AspNetCore - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/layered/src/CleanApiStarter.AspNetCore/Extensions.cs b/layered/src/CleanApiStarter.AspNetCore/Extensions.cs deleted file mode 100644 index ca8c5b6..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/Extensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public static partial class Extensions -{ - public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder) - { - builder.Host.UseDefaultServiceProvider((_, options) => - { - options.ValidateOnBuild = true; - options.ValidateScopes = true; - }); - - builder.ConfigureOpenTelemetry(); - builder.AddSecurityDefaults(); - builder.AddProblemDetailsDefaults(); - builder.AddApiVersioningDefaults(); - builder.AddAuthenticationDefaults(); - builder.AddResponseCompressionDefaults(); - builder.AddHttpLoggingDefaults(); - builder.AddServiceDiscoveryDefaults(); - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication UseAspNetCoreDefaults(this WebApplication app) - { - if (!app.Environment.IsDevelopment()) - { - app.UseHsts(); - } - - app.UseHttpsRedirection(); - app.UseMiddleware(); - app.UseExceptionHandler(); - app.UseResponseCompression(); - app.UseAuthentication(); - app.UseHttpLogging(); - app.UseAuthorization(); - - return app; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - app.MapGet("/version", () => - { - Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly; - AssemblyInformationalVersionAttribute? informationalVersion = assembly - .GetCustomAttribute(); - - return Results.Ok(new - { - Application = app.Environment.ApplicationName, - Version = assembly.GetName().Version?.ToString(), - informationalVersion?.InformationalVersion - }); - }) - .WithName("GetVersion") - .WithTags("Version") - .AllowAnonymous(); - - app.MapHealthChecks("/health") - .AllowAnonymous(); - - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = registration => registration.Tags.Contains("live") - }) - .AllowAnonymous(); - - return app; - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/layered/src/CleanApiStarter.AspNetCore/GlobalUsings.cs deleted file mode 100644 index 1a87110..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ /dev/null @@ -1,38 +0,0 @@ -global using System.Diagnostics; -global using System.IdentityModel.Tokens.Jwt; -global using System.IO.Compression; -global using System.Reflection; -global using System.Security.Claims; -global using System.Text; - -global using Asp.Versioning; -global using Asp.Versioning.ApiExplorer; -global using Asp.Versioning.Builder; - -global using CleanApiStarter.Configuration; - -global using FluentValidation; -global using FluentValidation.Results; - -global using Microsoft.AspNetCore.Authentication.JwtBearer; -global using Microsoft.AspNetCore.Builder; -global using Microsoft.AspNetCore.Diagnostics; -global using Microsoft.AspNetCore.Diagnostics.HealthChecks; -global using Microsoft.AspNetCore.Http; -global using Microsoft.AspNetCore.HttpLogging; -global using Microsoft.AspNetCore.Mvc; -global using Microsoft.AspNetCore.ResponseCompression; -global using Microsoft.AspNetCore.Routing; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Diagnostics.HealthChecks; -global using Microsoft.Extensions.Hosting; -global using Microsoft.Extensions.Logging; -global using Microsoft.IdentityModel.Tokens; - -global using Npgsql; - -global using OpenTelemetry.Logs; -global using OpenTelemetry.Metrics; -global using OpenTelemetry.Trace; - -global using Scalar.AspNetCore; diff --git a/layered/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/layered/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs deleted file mode 100644 index 66a2135..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public interface IEndpointGroup -{ - static virtual int MajorVersion => 1; - - static virtual string? RoutePrefix => null; - - static abstract void Map(RouteGroupBuilder groupBuilder); -} diff --git a/layered/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/layered/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs deleted file mode 100644 index fba74c4..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public static class OpenApiDocumentationExtensions -{ - public static WebApplication MapOpenApiDocumentation(this WebApplication app) - { - if (!app.Environment.IsDevelopment()) - { - return app; - } - - app.MapOpenApi() - .WithDocumentPerVersion() - .AllowAnonymous(); - - app.MapScalarApiReference(options => - { - IReadOnlyList descriptions = app.DescribeApiVersions(); - - for (int index = 0; index < descriptions.Count; index++) - { - ApiVersionDescription description = descriptions[index]; - bool isDefault = index == descriptions.Count - 1; - - options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); - } - }) - .AllowAnonymous(); - - return app; - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/layered/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs deleted file mode 100644 index 70733a2..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public static partial class Extensions -{ - private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - logging.ParseStateValues = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddNpgsqlInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddNpgsql(); - }); - - bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.Configure(logging => logging.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); - } - - return builder; - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/layered/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs deleted file mode 100644 index ee1e506..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public sealed class ProblemDetailsExceptionHandler( - IProblemDetailsService problemDetailsService, - ILogger logger) : IExceptionHandler -{ - public async ValueTask TryHandleAsync( - HttpContext httpContext, - Exception exception, - CancellationToken cancellationToken) - { - if (exception is OperationCanceledException && httpContext.RequestAborted.IsCancellationRequested) - { - logger.LogDebug( - "Request {RequestMethod} {RequestPath} was canceled by the client", - httpContext.Request.Method, - httpContext.Request.Path); - - // 499 Client Closed Request (nginx convention); no body since the client is gone. - httpContext.Response.StatusCode = 499; - - return true; - } - - (int statusCode, ProblemDetails problemDetails) = exception switch - { - BadHttpRequestException badRequestException => ( - StatusCodes.Status400BadRequest, - new ProblemDetails - { - Status = StatusCodes.Status400BadRequest, - Title = "Bad Request", - Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", - Detail = badRequestException.Message - }), - UnauthorizedAccessException => ( - StatusCodes.Status401Unauthorized, - new ProblemDetails - { - Status = StatusCodes.Status401Unauthorized, - Title = "Unauthorized", - Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2" - }), - _ => ( - StatusCodes.Status500InternalServerError, - new ProblemDetails - { - Status = StatusCodes.Status500InternalServerError, - Title = "Internal Server Error", - Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", - Detail = "An unexpected error occurred." - }) - }; - - if (statusCode >= StatusCodes.Status500InternalServerError) - { - logger.LogError( - exception, - "Unhandled exception while processing {RequestMethod} {RequestPath}", - httpContext.Request.Method, - httpContext.Request.Path); - } - else - { - logger.LogWarning( - exception, - "Handled exception with status {StatusCode} while processing {RequestMethod} {RequestPath}", - statusCode, - httpContext.Request.Method, - httpContext.Request.Path); - } - - httpContext.Response.StatusCode = statusCode; - - return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext - { - HttpContext = httpContext, - ProblemDetails = problemDetails, - Exception = exception - }); - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/layered/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs deleted file mode 100644 index 003a9ef..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public sealed class RequestIdMiddleware(RequestDelegate next) -{ - public const string HeaderName = "X-Request-ID"; - - public async Task InvokeAsync(HttpContext context) - { - context.Response.OnStarting(() => - { - context.Response.Headers[HeaderName] = GetRequestId(context); - - return Task.CompletedTask; - }); - - await next(context); - } - - private static string GetRequestId(HttpContext context) - { - return Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/layered/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs deleted file mode 100644 index 81c7243..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor -{ - private const string UserIdParameterName = "UserId"; - private const string AnonymousUserId = "anonymous"; - - public ValueTask OnRequestAsync(HttpLoggingInterceptorContext logContext) - { - logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext)); - - return ValueTask.CompletedTask; - } - - public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) - { - logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext)); - - return ValueTask.CompletedTask; - } - - private static string GetUserId(HttpContext httpContext) - { - return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub) - ?? AnonymousUserId; - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/layered/src/CleanApiStarter.AspNetCore/ValidationFilter.cs deleted file mode 100644 index 63b2cc0..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/ValidationFilter.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public sealed class ValidationFilter : IEndpointFilter -{ - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - List validationFailures = []; - - foreach (object? argument in context.Arguments) - { - if (argument == null) - { - continue; - } - - Type validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); - object? validator = context.HttpContext.RequestServices.GetService(validatorType); - - if (validator is not IValidator fluentValidator) - { - continue; - } - - ValidationContext validationContext = new(argument); - ValidationResult validationResult = await fluentValidator.ValidateAsync( - validationContext, - context.HttpContext.RequestAborted); - - validationFailures.AddRange(validationResult.Errors); - } - - if (validationFailures.Count == 0) - { - return await next(context); - } - - Dictionary errors = validationFailures - .GroupBy(failure => failure.PropertyName) - .ToDictionary( - group => group.Key, - group => group.Select(failure => failure.ErrorMessage).ToArray()); - - return Results.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); - } -} diff --git a/layered/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/layered/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs deleted file mode 100644 index 87e5166..0000000 --- a/layered/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.AspNetCore; - -public static class WebApplicationExtensions -{ - public static WebApplication MapEndpoints(this WebApplication app, Assembly assembly) - { - IEnumerable endpointGroupTypes = assembly.GetExportedTypes() - .Where(type => type is { IsAbstract: false, IsInterface: false } - && type.IsAssignableTo(typeof(IEndpointGroup))); - - foreach (Type type in endpointGroupTypes) - { - ApiVersion apiVersion = GetApiVersion(type); - string groupName = type.Name; - string routePrefix = type.GetProperty(nameof(IEndpointGroup.RoutePrefix))?.GetValue(null) as string - ?? $"/api/{groupName}"; - ApiVersionSet versionSet = app.NewApiVersionSet() - .HasApiVersion(apiVersion) - .ReportApiVersions() - .Build(); - - RouteGroupBuilder group = app.MapGroup(routePrefix) - .WithTags(groupName) - .WithApiVersionSet(versionSet) - .MapToApiVersion(apiVersion) - .AddEndpointFilter(); - - type.GetMethod(nameof(IEndpointGroup.Map))!.Invoke(null, [group]); - } - - return app; - } - - private static ApiVersion GetApiVersion(Type endpointGroupType) - { - int majorVersion = endpointGroupType.GetProperty(nameof(IEndpointGroup.MajorVersion))?.GetValue(null) as int? ?? 1; - - return new ApiVersion(majorVersion); - } -} diff --git a/layered/src/CleanApiStarter.Configuration/AppSettings.cs b/layered/src/CleanApiStarter.Configuration/AppSettings.cs deleted file mode 100644 index 19cdf16..0000000 --- a/layered/src/CleanApiStarter.Configuration/AppSettings.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Configuration; - -public sealed class AppSettings -{ - [Required] - [ValidateObjectMembers] - public ConnectionStringSettings ConnectionStrings { get; set; } = new(); - - [Required] - [ValidateObjectMembers] - public AuthenticationSettings Authentication { get; set; } = new(); -} diff --git a/layered/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/layered/src/CleanApiStarter.Configuration/AuthenticationSettings.cs deleted file mode 100644 index 24f76fa..0000000 --- a/layered/src/CleanApiStarter.Configuration/AuthenticationSettings.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Configuration; - -public sealed class AuthenticationSettings -{ - [Required] - [ValidateObjectMembers] - public GoogleAuthenticationSettings Google { get; set; } = new(); - - [Required] - [ValidateObjectMembers] - public JwtAuthenticationSettings Jwt { get; set; } = new(); -} diff --git a/layered/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/layered/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj deleted file mode 100644 index 002e9d2..0000000 --- a/layered/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - CleanApiStarter.Configuration - CleanApiStarter.Configuration - - - - - - - - - - diff --git a/layered/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/layered/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs deleted file mode 100644 index 1299016..0000000 --- a/layered/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CleanApiStarter.Configuration; - -public sealed class ConnectionStringSettings -{ - [Required] - public string Postgres { get; set; } = string.Empty; -} diff --git a/layered/src/CleanApiStarter.Configuration/GlobalUsings.cs b/layered/src/CleanApiStarter.Configuration/GlobalUsings.cs deleted file mode 100644 index a2250c9..0000000 --- a/layered/src/CleanApiStarter.Configuration/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -global using System.ComponentModel.DataAnnotations; - -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Options; diff --git a/layered/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/layered/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs deleted file mode 100644 index b6f8aec..0000000 --- a/layered/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CleanApiStarter.Configuration; - -public sealed class GoogleAuthenticationSettings -{ - [Required] - public string ClientId { get; set; } = string.Empty; -} diff --git a/layered/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/layered/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs deleted file mode 100644 index 083c6d8..0000000 --- a/layered/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CleanApiStarter.Configuration; - -public sealed class JwtAuthenticationSettings -{ - [Required] - public string Issuer { get; set; } = string.Empty; - - [Required] - public string Audience { get; set; } = string.Empty; - - [Required] - [MinLength(32)] - public string SigningKey { get; set; } = string.Empty; - - [Range(1, 1440)] - public int ExpirationMinutes { get; set; } = 60; -} diff --git a/layered/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/layered/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs deleted file mode 100644 index 6b767c7..0000000 --- a/layered/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CleanApiStarter.Configuration; - -public static class OptionsRegistrationExtensions -{ - public static IServiceCollection AddAppSettings( - this IServiceCollection services, - IConfiguration configuration) - { - services.AddOptions() - .Bind(configuration) - .ValidateDataAnnotations() - .ValidateOnStart(); - - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - return services; - } -} diff --git a/layered/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj b/layered/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj deleted file mode 100644 index 115c60c..0000000 --- a/layered/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - CleanApiStarter.Domain - CleanApiStarter.Domain - - \ No newline at end of file diff --git a/layered/src/CleanApiStarter.Domain/Entities/Project.cs b/layered/src/CleanApiStarter.Domain/Entities/Project.cs deleted file mode 100644 index 37555ff..0000000 --- a/layered/src/CleanApiStarter.Domain/Entities/Project.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CleanApiStarter.Domain.Entities; - -public sealed class Project -{ - public required Guid Id { get; init; } - - public required string Name { get; init; } - - public required string Description { get; init; } - - public required string OwnerUserId { get; init; } - - public required DateTime CreatedAt { get; init; } - - public List Members { get; init; } = []; - - public List Tasks { get; init; } = []; -} diff --git a/layered/src/CleanApiStarter.Domain/Entities/ProjectMember.cs b/layered/src/CleanApiStarter.Domain/Entities/ProjectMember.cs deleted file mode 100644 index c55ef9f..0000000 --- a/layered/src/CleanApiStarter.Domain/Entities/ProjectMember.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Domain.Entities; - -public sealed class ProjectMember -{ - public required Guid ProjectId { get; init; } - - public required string UserId { get; init; } - - public required DateTime CreatedAt { get; init; } - - public Project Project { get; init; } = null!; -} diff --git a/layered/src/CleanApiStarter.Domain/Entities/ProjectTask.cs b/layered/src/CleanApiStarter.Domain/Entities/ProjectTask.cs deleted file mode 100644 index c12e61c..0000000 --- a/layered/src/CleanApiStarter.Domain/Entities/ProjectTask.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CleanApiStarter.Domain.Entities; - -public sealed class ProjectTask -{ - public required Guid Id { get; init; } - - public required Guid ProjectId { get; init; } - - public required string Title { get; set; } - - public required string Description { get; set; } - - public ProjectTaskStatus Status { get; set; } - - public DateTime? DueDate { get; set; } - - public required DateTime CreatedAt { get; init; } - - public DateTime? CompletedAt { get; set; } - - public Project Project { get; init; } = null!; -} diff --git a/layered/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs b/layered/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs deleted file mode 100644 index f553dc5..0000000 --- a/layered/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Domain.Entities; - -public enum ProjectTaskStatus -{ - Todo = 0, - InProgress = 1, - Done = 2 -} diff --git a/layered/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/layered/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj deleted file mode 100644 index 71d039f..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - CleanApiStarter.Infrastructure - CleanApiStarter.Infrastructure - - - - - - - - - - - - - - - diff --git a/layered/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/layered/src/CleanApiStarter.Infrastructure/DependencyInjection.cs deleted file mode 100644 index 12dcd54..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CleanApiStarter.Infrastructure; - -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure(this IServiceCollection services) - { - services.AddDbContext((serviceProvider, options) => - { - AppSettings appSettings = serviceProvider.GetRequiredService(); - options.UseNpgsql(appSettings.ConnectionStrings.Postgres); - }); - - services.AddIdentityCore() - .AddRoles() - .AddEntityFrameworkStores(); - - services.AddScoped(); - services.AddScoped(); - - return services; - } -} diff --git a/layered/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/layered/src/CleanApiStarter.Infrastructure/GlobalUsings.cs deleted file mode 100644 index 8801542..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ /dev/null @@ -1,21 +0,0 @@ -global using System.IdentityModel.Tokens.Jwt; -global using System.Security.Claims; -global using System.Text; - -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Configuration; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure.Identity; -global using CleanApiStarter.Infrastructure.Persistence; -global using CleanApiStarter.Infrastructure.Repositories; - -global using Google.Apis.Auth; - -global using Microsoft.AspNetCore.Identity; -global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -global using Microsoft.EntityFrameworkCore; -global using Microsoft.EntityFrameworkCore.Metadata.Builders; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.IdentityModel.Tokens; diff --git a/layered/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/layered/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs deleted file mode 100644 index c801b9f..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Identity; - -public sealed class ApplicationUser : IdentityUser; diff --git a/layered/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/layered/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs deleted file mode 100644 index 367a101..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs +++ /dev/null @@ -1,151 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Identity; - -public sealed class GoogleAuthService( - AppSettings appSettings, - UserManager userManager, - RoleManager roleManager) : IAuthService -{ - private const string GoogleLoginProvider = "Google"; - private const string DefaultRole = "User"; - - public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken) - { - GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken); - ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken); - IList roles = await userManager.GetRolesAsync(user); - DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes); - - return new AuthTokenDto - { - AccessToken = CreateAccessToken(user, payload.Name, roles, expiresAt), - ExpiresAt = expiresAt - }; - } - - public Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) - { - string userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; - string email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty; - string name = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty; - string[] roles = principal.FindAll(ClaimTypes.Role) - .Select(claim => claim.Value) - .ToArray(); - - return Task.FromResult(new CurrentUserDto - { - UserId = userId, - Email = email, - Name = name, - Roles = roles - }); - } - - private async Task ValidateGoogleTokenAsync(string idToken) - { - GoogleJsonWebSignature.ValidationSettings validationSettings = new() - { - Audience = [appSettings.Authentication.Google.ClientId] - }; - - return await GoogleJsonWebSignature.ValidateAsync(idToken, validationSettings); - } - - private async Task FindOrCreateUserAsync( - GoogleJsonWebSignature.Payload payload, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(payload.Email) || !payload.EmailVerified) - { - throw new UnauthorizedAccessException("Google account email must be verified."); - } - - ApplicationUser? user = await userManager.FindByLoginAsync(GoogleLoginProvider, payload.Subject); - - if (user == null) - { - user = await userManager.FindByEmailAsync(payload.Email); - } - - if (user == null) - { - user = new ApplicationUser - { - UserName = payload.Email, - Email = payload.Email, - EmailConfirmed = payload.EmailVerified - }; - - IdentityResult createResult = await userManager.CreateAsync(user); - ThrowIfFailed(createResult); - } - - UserLoginInfo googleLogin = new(GoogleLoginProvider, payload.Subject, GoogleLoginProvider); - IList logins = await userManager.GetLoginsAsync(user); - - if (!logins.Any(login => login.LoginProvider == GoogleLoginProvider && login.ProviderKey == payload.Subject)) - { - IdentityResult loginResult = await userManager.AddLoginAsync(user, googleLogin); - ThrowIfFailed(loginResult); - } - - await EnsureDefaultRoleAsync(user); - - return user; - } - - private async Task EnsureDefaultRoleAsync(ApplicationUser user) - { - if (!await roleManager.RoleExistsAsync(DefaultRole)) - { - IdentityResult roleResult = await roleManager.CreateAsync(new IdentityRole(DefaultRole)); - ThrowIfFailed(roleResult); - } - - if (!await userManager.IsInRoleAsync(user, DefaultRole)) - { - IdentityResult userRoleResult = await userManager.AddToRoleAsync(user, DefaultRole); - ThrowIfFailed(userRoleResult); - } - } - - private string CreateAccessToken( - ApplicationUser user, - string? googleName, - IEnumerable roles, - DateTimeOffset expiresAt) - { - JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; - SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)); - SigningCredentials signingCredentials = new(securityKey, SecurityAlgorithms.HmacSha256); - List claims = - [ - new(JwtRegisteredClaimNames.Sub, user.Id), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new(ClaimTypes.NameIdentifier, user.Id), - new(ClaimTypes.Email, user.Email ?? string.Empty), - new(ClaimTypes.Name, googleName ?? user.UserName ?? string.Empty) - ]; - - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); - - JwtSecurityToken token = new( - issuer: jwtSettings.Issuer, - audience: jwtSettings.Audience, - claims: claims, - expires: expiresAt.UtcDateTime, - signingCredentials: signingCredentials); - - return new JwtSecurityTokenHandler().WriteToken(token); - } - - private static void ThrowIfFailed(IdentityResult result) - { - if (result.Succeeded) - { - return; - } - - string errors = string.Join("; ", result.Errors.Select(error => error.Description)); - throw new InvalidOperationException(errors); - } -} diff --git a/layered/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs deleted file mode 100644 index d0e5c7e..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Persistence; - -public sealed class ApplicationDbContext(DbContextOptions options) - : IdentityDbContext(options) -{ - public DbSet Projects => Set(); - - public DbSet ProjectMembers => Set(); - - public DbSet ProjectTasks => Set(); - - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - - builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); - } -} diff --git a/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs deleted file mode 100644 index b919bb6..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; - -public sealed class ProjectConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("projects"); - - builder.HasKey(project => project.Id); - - builder.Property(project => project.Id) - .HasColumnName("id"); - - builder.Property(project => project.Name) - .HasColumnName("name") - .HasMaxLength(100) - .IsRequired(); - - builder.Property(project => project.Description) - .HasColumnName("description") - .IsRequired(); - - builder.Property(project => project.OwnerUserId) - .HasColumnName("owner_user_id") - .HasMaxLength(450) - .IsRequired(); - - builder.Property(project => project.CreatedAt) - .HasColumnName("created_at"); - } -} diff --git a/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs deleted file mode 100644 index f1a3cf1..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; - -public sealed class ProjectMemberConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("project_members"); - - builder.HasKey(member => new - { - member.ProjectId, - member.UserId - }); - - builder.Property(member => member.ProjectId) - .HasColumnName("project_id"); - - builder.Property(member => member.UserId) - .HasColumnName("user_id") - .HasMaxLength(450); - - builder.Property(member => member.CreatedAt) - .HasColumnName("created_at"); - - builder.HasOne(member => member.Project) - .WithMany(project => project.Members) - .HasForeignKey(member => member.ProjectId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs deleted file mode 100644 index aa0dfb5..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; - -public sealed class ProjectTaskConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("project_tasks"); - - builder.HasKey(task => task.Id); - - builder.Property(task => task.Id) - .HasColumnName("id"); - - builder.Property(task => task.ProjectId) - .HasColumnName("project_id"); - - builder.Property(task => task.Title) - .HasColumnName("title") - .HasMaxLength(150) - .IsRequired(); - - builder.Property(task => task.Description) - .HasColumnName("description") - .IsRequired(); - - builder.Property(task => task.Status) - .HasColumnName("status") - .HasConversion() - .HasMaxLength(20) - .IsRequired(); - - builder.Property(task => task.DueDate) - .HasColumnName("due_date"); - - builder.Property(task => task.CreatedAt) - .HasColumnName("created_at"); - - builder.Property(task => task.CompletedAt) - .HasColumnName("completed_at"); - - builder.HasOne(task => task.Project) - .WithMany(project => project.Tasks) - .HasForeignKey(task => task.ProjectId) - .OnDelete(DeleteBehavior.Cascade); - } -} diff --git a/layered/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs b/layered/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs deleted file mode 100644 index 04563a4..0000000 --- a/layered/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs +++ /dev/null @@ -1,158 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Repositories; - -public sealed class ProjectRepository(ApplicationDbContext dbContext) : IProjectRepository -{ - public async Task AddProjectAsync(Project project, CancellationToken cancellationToken) - { - dbContext.Projects.Add(project); - await dbContext.SaveChangesAsync(cancellationToken); - - return project.Id; - } - - public async Task GetProjectAsync(Guid id, string userId, CancellationToken cancellationToken) - { - return await dbContext.Projects - .AsNoTracking() - .Where(project => project.Id == id) - .Where(project => project.Members.Any(member => member.UserId == userId)) - .SingleOrDefaultAsync(cancellationToken); - } - - public async Task> GetProjectsAsync( - string userId, - PaginatedQuery query, - CancellationToken cancellationToken) - { - IQueryable projectsQuery = dbContext.Projects - .AsNoTracking() - .Where(project => project.Members.Any(member => member.UserId == userId)) - .OrderBy(project => project.Name); - - int totalCount = await projectsQuery.CountAsync(cancellationToken); - List projects = await projectsQuery - .Skip(query.Offset) - .Take(query.Limit) - .ToListAsync(cancellationToken); - - return new PaginatedResult - { - Items = projects, - Limit = query.Limit, - Offset = query.Offset, - TotalCount = totalCount - }; - } - - public async Task IsProjectMemberAsync(Guid projectId, string userId, CancellationToken cancellationToken) - { - return await dbContext.ProjectMembers - .AnyAsync(member => member.ProjectId == projectId && member.UserId == userId, cancellationToken); - } - - public async Task IsProjectOwnerAsync(Guid projectId, string userId, CancellationToken cancellationToken) - { - return await dbContext.Projects - .AnyAsync(project => project.Id == projectId && project.OwnerUserId == userId, cancellationToken); - } - - public async Task ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken) - { - return await dbContext.Projects - .AnyAsync(project => project.Id == projectId, cancellationToken); - } - - public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) - { - int rowsAffected = await dbContext.Projects - .Where(project => project.Id == id) - .ExecuteDeleteAsync(cancellationToken); - - return rowsAffected > 0; - } - - public async Task AddTaskAsync(ProjectTask task, CancellationToken cancellationToken) - { - dbContext.ProjectTasks.Add(task); - await dbContext.SaveChangesAsync(cancellationToken); - - return task.Id; - } - - public async Task GetTaskAsync( - Guid projectId, - Guid taskId, - string userId, - CancellationToken cancellationToken) - { - return await ProjectTasksForMember(projectId, userId) - .AsNoTracking() - .SingleOrDefaultAsync(task => task.Id == taskId, cancellationToken); - } - - public async Task GetTaskForUpdateAsync( - Guid projectId, - Guid taskId, - string userId, - CancellationToken cancellationToken) - { - return await ProjectTasksForMember(projectId, userId) - .SingleOrDefaultAsync(task => task.Id == taskId, cancellationToken); - } - - public async Task> GetTasksAsync( - Guid projectId, - string userId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken) - { - IQueryable tasksQuery = ProjectTasksForMember(projectId, userId); - - if (status.HasValue) - { - tasksQuery = tasksQuery.Where(task => task.Status == status.Value); - } - - tasksQuery = tasksQuery - .AsNoTracking() - .OrderBy(task => task.Status) - .ThenBy(task => task.DueDate) - .ThenBy(task => task.Title); - - int totalCount = await tasksQuery.CountAsync(cancellationToken); - List tasks = await tasksQuery - .Skip(query.Offset) - .Take(query.Limit) - .ToListAsync(cancellationToken); - - return new PaginatedResult - { - Items = tasks, - Limit = query.Limit, - Offset = query.Offset, - TotalCount = totalCount - }; - } - - public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) - { - int rowsAffected = await dbContext.ProjectTasks - .Where(task => task.ProjectId == projectId && task.Id == taskId) - .ExecuteDeleteAsync(cancellationToken); - - return rowsAffected > 0; - } - - public async Task SaveChangesAsync(CancellationToken cancellationToken) - { - return await dbContext.SaveChangesAsync(cancellationToken); - } - - private IQueryable ProjectTasksForMember(Guid projectId, string userId) - { - return dbContext.ProjectTasks - .Where(task => task.ProjectId == projectId) - .Where(task => task.Project.Members.Any(member => member.UserId == userId)); - } -} diff --git a/layered/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/layered/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj deleted file mode 100644 index 84de8d1..0000000 --- a/layered/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - CleanApiStarter.Application.UnitTests - CleanApiStarter.Application.UnitTests - - - - - - - - - - - - - - - - - - - - - - - diff --git a/layered/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/layered/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs deleted file mode 100644 index 73c9e68..0000000 --- a/layered/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.Application.UnitTests.Features.Projects; - -public sealed class ProjectServiceTests -{ - [Theory] - [AutoNSubstituteData] - public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyCompleted( - Guid projectId, - Guid taskId, - string userId, - [Frozen] IUser currentUser, - [Frozen] IProjectRepository projectRepository, - ProjectService sut) - { - // Arrange - CancellationToken cancellationToken = CancellationToken.None; - ProjectTask task = new() - { - Id = taskId, - ProjectId = projectId, - Title = "Write first unit test", - Description = "Cover already completed tasks", - Status = ProjectTaskStatus.Done, - CreatedAt = DateTime.UtcNow, - CompletedAt = DateTime.UtcNow - }; - - currentUser.Id.Returns(userId); - projectRepository - .GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken) - .Returns(Task.FromResult(task)); - - // Act - ProjectTaskMutationResult result = await sut.CompleteTaskAsync(projectId, taskId, cancellationToken); - - // Assert - result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); - _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); - } -} diff --git a/layered/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/layered/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs deleted file mode 100644 index 02c2906..0000000 --- a/layered/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ /dev/null @@ -1,14 +0,0 @@ -global using AutoFixture.Xunit3; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Tests.Common; - -global using Microsoft.Extensions.Logging; - -global using NSubstitute; - -global using Shouldly; - -global using Xunit; diff --git a/modular/.config/dotnet-tools.json b/modular/.config/dotnet-tools.json deleted file mode 100644 index 9288b0e..0000000 --- a/modular/.config/dotnet-tools.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-reportgenerator-globaltool": { - "version": "5.5.10", - "commands": [ - "reportgenerator" - ] - } - } -} \ No newline at end of file diff --git a/modular/.github/workflows/build.yml b/modular/.github/workflows/build.yml deleted file mode 100644 index 5d27005..0000000 --- a/modular/.github/workflows/build.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Build - -on: - pull_request: - push: - branches: - - main - -jobs: - build: - name: Build and test - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - global-json-file: global.json - - - name: Restore - run: dotnet restore CleanApiStarter.slnx - - - name: Build - run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - - - name: Test - run: dotnet test CleanApiStarter.slnx --no-build --configuration Release /nr:false -v:minimal diff --git a/modular/.gitignore b/modular/.gitignore deleted file mode 100644 index 2c00c02..0000000 --- a/modular/.gitignore +++ /dev/null @@ -1,408 +0,0 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -.DS_Store - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[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 Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# 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 - -# JetBrains IDE and Junie local assistant files -.idea/ -.junie/ - -# 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 -.DS_Store - -# Synced from the repo-root database/ (single source of truth) by scripts/sync-database.sh -/database/ diff --git a/modular/AGENTS.md b/modular/AGENTS.md deleted file mode 100644 index e92b9af..0000000 --- a/modular/AGENTS.md +++ /dev/null @@ -1,181 +0,0 @@ -# Agent Instructions - -This repository is a Clean Architecture API starter template named `CleanApiStarter`. - -## Naming - -- Use `CleanApiStarter` for solution, project, assembly, and namespace naming. -- Do not introduce `CleanArchitecture` namespaces or assembly names. -- Keep project names fully qualified: - - `CleanApiStarter.Api` - - `CleanApiStarter.Application` - - `CleanApiStarter.Configuration` - - `CleanApiStarter.Domain` - - `CleanApiStarter.Infrastructure` - - `CleanApiStarter.AppHost` - - `CleanApiStarter.AspNetCore` - - `CleanApiStarter.UnitTests` - -## Solution Structure - -- Keep clean architecture application layers under the `/src/` solution folder: - - API - - Application - - Configuration - - Domain - - Infrastructure -- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`: - - AppHost - - AspNetCore -- Keep database scripts under top-level `database/migrations`. -- Keep important root files in solution items, including `README.md`, `docker-compose.yml`, `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, and `global.json`. - -## Clean Architecture Rules - -- Dependencies point inward: - - `Domain` references nothing. - - `Application` references `Domain`. - - `Configuration` references no application layers and contains only plain options classes plus options registration helpers. - - `Infrastructure` references `Application` and `Configuration`. - - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- Keep repository interfaces in `Application`, not `Domain`. -- Keep database implementation details in `Infrastructure`. -- Keep domain models persistence-agnostic. Persistence mapping details belong in Infrastructure EF Core configuration. -- Use `required` for non-null required scalar properties on DTOs and domain entities. Do not use `= string.Empty` only to satisfy nullable reference type warnings. -- Keep collection properties initialized with `= []`. -- Keep EF navigation properties as `= null!` when EF is responsible for materializing them. -- Use nullable types such as `string?` or `DateTime?` only for genuinely optional values. -- Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path. -- Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern. -- Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason. -- Keep Identity-specific classes, such as `ApplicationUser` and auth services, under `CleanApiStarter.Infrastructure/Identity`. - -## API and Application Conventions - -- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Projects` or `Application/Features/Auth`. -- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. -- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. -- Return single resources as their DTO object directly. -- Use `ArrayResult` instead of returning raw arrays from non-paginated list endpoints. -- Use `PaginatedQuery` for paginated request parameters and return `PaginatedResult` directly for paginated collection responses. -- Use FluentValidation for request validation. Put validators beside feature request models in `Application`, and rely on the shared Minimal API validation endpoint filter in `CleanApiStarter.AspNetCore`. FluentValidation failures should return `422 Unprocessable Entity`. -- Do not use DataAnnotations for application request validation. -- Use Scalar, not Swagger/Swashbuckle. -- Use the .NET 10 API versioning/OpenAPI setup from the Microsoft .NET blog: - - `Asp.Versioning.Http` v10 - - `Asp.Versioning.Mvc.ApiExplorer` v10 - - `Asp.Versioning.OpenApi` - - `builder.Services.AddApiVersioning(...).AddApiExplorer(...).AddOpenApi();` - - `app.MapOpenApi().WithDocumentPerVersion();` - - `app.MapScalarApiReference(...)` configured from `app.DescribeApiVersions()` -- Do not add `Swashbuckle.AspNetCore`. -- Cancellation tokens are explicit: - - Do not use `CancellationToken cancellationToken = default` in service or repository contracts. - - API actions should accept `CancellationToken cancellationToken` and pass it through. -- Use explicit local types. The `.editorconfig` prefers explicit types over `var`. -- Use project-level `GlobalUsings.cs`; avoid adding file-level `using` directives unless there is a very specific reason. - -## Database and Aspire - -- Use a single Postgres database: `postgres`. -- Do not create or reference a separate feature-specific database. -- AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`. -- `docker-compose.yml` should use `POSTGRES_DB=postgres`. -- Database schema scripts live in `database/migrations`, for example: - - `database/migrations/V001__create_projects_and_tasks_tables.sql` -- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. -- EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`. -- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. -- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`. -- In Aspire, use a server resource name that does not conflict with the database resource name, for example: - - server resource: `postgres-server` - - database resource: `postgres` -- Aspire Postgres should use a volume mounted at `/var/lib/postgresql`. - -## ASP.NET Core Defaults - -- Keep `CleanApiStarter.AspNetCore`. -- It centralizes Aspire-friendly runtime defaults: - - OpenTelemetry traces, metrics, logs, and OTLP export - - problem-details exception handling - - `/version` endpoint - - health endpoints - - service discovery - - default HTTP client resilience - - security defaults such as removing the Kestrel `Server` response header -- API `Program.cs` should stay small and call: - - `builder.AddAspNetCoreDefaults();` - - `app.UseAspNetCoreDefaults();` - - `app.MapDefaultEndpoints();` -- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. -- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. -- Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`. -- Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers. -- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{ProjectId}`, `{TaskId}`, and `{UserId}`. -- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. -- Do not add generic options registration helpers until the template has multiple real options sections that need them. -- Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`. -- Keep dependency injection validation enabled with `ValidateOnBuild` and `ValidateScopes`. - -## API Style - -- Prefer Minimal APIs for this template. -- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Projects.cs`. -- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. -- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. -- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. -- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. -- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetProjectsV1` and `GetProjectsV2`. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. - -## Authentication - -- Authentication is API-first: - - clients obtain a Google ID token - - `POST /api/auth/google` validates it - - the API issues its own JWT -- Keep JWT bearer authentication setup in `CleanApiStarter.AspNetCore`. -- Keep local user/role storage in ASP.NET Core Identity under `CleanApiStarter.Infrastructure`. -- Do not use cookies as the default API auth mechanism. -- Keep the development Google login helper page unversioned so it can be opened directly in a browser. -- Protected API calls should send `Authorization: Bearer `. Send `X-Api-Version` only when selecting a non-default API version. - -## Packages - -- Manage versions centrally in `Directory.Packages.props`. -- Keep `PackageVersion` items sorted alphabetically by `Include`. -- Do not add package versions directly in individual `.csproj` files. - -## Template Packaging - -- This repository is also the `dotnet new` template source. -- Keep template metadata in `.template.config/template.json`. -- Keep NuGet template package metadata in `CleanApiStarter.Template.csproj`. -- Use `dotnet pack` and `dotnet nuget push` for template packaging and publishing. Do not use `nuget pack`, `nuget.exe`, or Mono. -- Use `scripts/install-template.sh` to pack and install the local template. -- Keep repo-only template packaging scripts excluded from generated template output. -- Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. -- Keep release publishing triggered by GitHub Release publication with `vX.Y.Z` tags, not manual version inputs. - -## Testing - -- Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. -- Reusable test helpers belong in `CleanApiStarter.Tests`. -- Application unit tests should reference `CleanApiStarter.Tests` instead of duplicating common test setup. -- API integration tests use MSTest and Testcontainers for Postgres. -- API integration tests should keep real JWT bearer authentication active. Generate test JWTs from `appsettings.Testing.json` instead of replacing authentication with a fake scheme. -- API integration tests should start a Postgres Testcontainer and apply SQL scripts from `database/migrations`. -- Keep test app settings in `appsettings.Testing.json`. -- Test methods must follow the naming pattern `UnitOfWork_StateUnderTest_ExpectedBehavior`. -- Tests must follow AAA format with explicit `// Arrange`, `// Act`, and `// Assert` sections. - -## Verification - -- After structural or package changes, run: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -- Aspire AppHost builds may need to run outside a sandbox because the Aspire SDK touches local runtime/process resources. diff --git a/modular/CleanApiStarter.Template.csproj b/modular/CleanApiStarter.Template.csproj deleted file mode 100644 index 87b4dfb..0000000 --- a/modular/CleanApiStarter.Template.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - net10.0 - false - true - false - true - true - $(NoWarn);NU5128 - - CleanApiStarter.Template.Modular - 0.0.0 - Clean API Starter Template (Modular) - Chathuranga - Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. - dotnet-new;template;aspnetcore;api;clean-architecture;aspire;postgresql;opentelemetry;scalar;jwt;efcore - Template - LICENSE - README.md - - - - - - - - diff --git a/modular/LICENSE b/modular/LICENSE deleted file mode 100644 index f288702..0000000 --- a/modular/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/modular/README.md b/modular/README.md deleted file mode 100644 index ccf0d39..0000000 --- a/modular/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# CleanApiStarter (Modular) - -`CleanApiStarter` is a Clean Architecture API starter for .NET 10, ASP.NET Core -Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API -versioning, FluentValidation, Scalar, EF Core, and automated tests. - -This is the **modular** variant: the whole application lives in a single -`CleanApiStarter.Api` project, organized by feature and layer folders, with the -Clean Architecture dependency rule enforced at compile time by -[NsDepCop](https://github.com/realvizu/NsDepCop) instead of separate projects. -The sample domain is project management: authenticated users create projects, -manage tasks, filter tasks by status, and complete them. - -> Prefer hard, project-reference boundaries? Use the **layered** variant -> (`clean-api-layered`) instead. - -## Features - -- Single application project with feature-centric organization and reusable - platform projects (`AspNetCore`, `Configuration`) plus an Aspire `AppHost`. -- Compile-time boundary enforcement via NsDepCop (`config.nsdepcop`, - `WarningsAsErrors=NSDEPCOP01`): a forbidden dependency fails the build. -- Minimal APIs organized by endpoint group and API version folders. -- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. -- Versioned OpenAPI documents shown with Scalar. -- Google ID token sign-in that issues the API's own JWT. -- ASP.NET Core Identity for local users, roles, and external login storage. -- Real JWT bearer authentication for protected APIs. -- EF Core with PostgreSQL for application data and Identity storage. -- PostgreSQL local development through Aspire or Docker Compose. -- Database schema scripts under `database/migrations`. -- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. -- HTTP request logging with authenticated user id. -- `X-Request-ID` response header containing the current trace id. -- Problem Details exception responses. -- Response compression with Brotli and gzip. -- FluentValidation endpoint validation returning `422 Unprocessable Entity`. -- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. -- xUnit v3 unit and Testcontainers-backed integration tests. - -## Solution Layout - -```text -CleanApiStarter -├── database -│ └── migrations -├── scripts -├── src -│ ├── CleanApiStarter.Api ← the whole application -│ │ ├── config.nsdepcop ← enforced dependency rules -│ │ ├── Common ← shared kernel (paged results, IUser) -│ │ ├── Domain ← entities (no outward dependencies) -│ │ ├── Features ← Auth, Projects (endpoints + handlers + validators) -│ │ ├── Infrastructure ← DbContext, EF config, repositories, identity -│ │ ├── Endpoints, Services ← composition / web wiring -│ │ └── Program.cs -│ └── Common -│ ├── CleanApiStarter.AppHost ← Aspire orchestration -│ ├── CleanApiStarter.AspNetCore ← reusable web/runtime defaults -│ └── CleanApiStarter.Configuration ← settings classes + options registration -└── tests - ├── CleanApiStarter.Api.IntegrationTests - ├── CleanApiStarter.Application.UnitTests - └── CleanApiStarter.Tests -``` - -Dependency direction (enforced by `config.nsdepcop`): - -- `Domain` depends on nothing else in the app — not `Features`, not `Infrastructure`. -- `Features` (application logic) may not depend on `Infrastructure`; it talks to - abstractions that `Infrastructure` implements and DI wires up. -- `Infrastructure`, `Endpoints`, and `Program.cs` form the composition root. -- `AspNetCore`, `Configuration`, and `AppHost` are reusable platform projects. - -A violation — for example `using` an `Infrastructure` type from `Domain` — fails -the build with `error NSDEPCOP01`. - -## Requirements - -- .NET SDK `10.0.203` or a compatible latest-feature SDK (pinned in `global.json`). -- Docker Desktop (for integration tests and local PostgreSQL). - -## Getting started - -```bash -dotnet run --project src/CleanApiStarter.AppHost # API + PostgreSQL via Aspire -# or -docker compose up -d && dotnet run --project src/CleanApiStarter.Api -``` - -Run the tests with `dotnet test CleanApiStarter.slnx`. diff --git a/modular/docker-compose.yml b/modular/docker-compose.yml deleted file mode 100644 index 1536d3e..0000000 --- a/modular/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:latest - container_name: clean-api-starter-postgres - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres - ports: - - "5432:5432" - volumes: - - postgres-data:/var/lib/postgresql - - ./database/migrations:/docker-entrypoint-initdb.d:ro - -volumes: - postgres-data: diff --git a/modular/global.json b/modular/global.json deleted file mode 100644 index 8994a62..0000000 --- a/modular/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "sdk": { - "version": "10.0.203", - "rollForward": "latestFeature" - } -} \ No newline at end of file diff --git a/modular/scripts/install-template.sh b/modular/scripts/install-template.sh deleted file mode 100755 index 39afb8c..0000000 --- a/modular/scripts/install-template.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -package="CleanApiStarter.Template.Modular" -pkg="$root/artifacts/$package.0.0.0.nupkg" - -cd "$root" - -# Ensure the variant's database/ is populated from the shared root source. -../scripts/sync-database.sh - -dotnet new uninstall "$package" 2>/dev/null || true -dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" -dotnet new install "$pkg" --force -rm -f "$pkg" diff --git a/modular/scripts/test-coverage.sh b/modular/scripts/test-coverage.sh deleted file mode 100755 index d7f0b9d..0000000 --- a/modular/scripts/test-coverage.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -coverage_dir="$repo_root/artifacts/coverage" -test_results_dir="$coverage_dir/test-results" -coverage_report_dir="$coverage_dir/report" - -cd "$repo_root" - -rm -rf "$coverage_dir" -mkdir -p "$test_results_dir" "$coverage_report_dir" - -dotnet tool restore - -dotnet test CleanApiStarter.slnx \ - --collect:"XPlat Code Coverage" \ - --disable-build-servers \ - --results-directory "$test_results_dir" \ - --logger trx \ - /m:1 \ - /nr:false \ - -v:minimal - -coverage_files=() - -while IFS= read -r coverage_file; do - coverage_files+=("$coverage_file") -done < <(find "$test_results_dir" -path "*/In/*" -prune -o -type f -name "coverage.cobertura.xml" -print | sort) - -if [ "${#coverage_files[@]}" -eq 0 ]; then - echo "No coverage.cobertura.xml files were generated." - exit 1 -fi - -coverage_reports="$(IFS=';'; echo "${coverage_files[*]}")" - -dotnet reportgenerator \ - "-reports:$coverage_reports" \ - "-targetdir:$coverage_report_dir" \ - "-reporttypes:Html" \ - "-title:CleanApiStarter Coverage" - -coverage_index="$coverage_report_dir/index.html" - -echo "Coverage report: $coverage_index" - -case "$(uname -s)" in - Darwin) - open "$coverage_index" - ;; - Linux) - if command -v xdg-open > /dev/null && [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then - xdg-open "$coverage_index" - fi - ;; - MINGW*|MSYS*|CYGWIN*) - cmd.exe //c start "" "$(cygpath -w "$coverage_index")" - ;; -esac diff --git a/modular/src/CleanApiStarter.Api/Api.http b/modular/src/CleanApiStarter.Api/Api.http deleted file mode 100644 index 07f57a3..0000000 --- a/modular/src/CleanApiStarter.Api/Api.http +++ /dev/null @@ -1,88 +0,0 @@ -@CleanApiStarter_Api_HostAddress = https://localhost:7285 -@AccessToken = paste-api-jwt-here - -POST {{CleanApiStarter_Api_HostAddress}}/api/auth/google -Content-Type: application/json - -{ - "idToken": "paste-google-id-token-here" -} - -### - -GET {{CleanApiStarter_Api_HostAddress}}/api/projects?limit=20&offset=0 -Accept: application/json -Authorization: Bearer {{AccessToken}} - -### - -POST {{CleanApiStarter_Api_HostAddress}}/api/projects -Content-Type: application/json -Authorization: Bearer {{AccessToken}} - -{ - "name": "Clean API Starter", - "description": "Template implementation work." -} - -### - -GET {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000 -Accept: application/json -Authorization: Bearer {{AccessToken}} - -### - -POST {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks -Content-Type: application/json -Authorization: Bearer {{AccessToken}} - -{ - "title": "Add observability defaults", - "description": "Configure logs, traces, metrics, and request ids.", - "dueDate": "2026-12-31T00:00:00Z" -} - -### - -GET {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks?limit=20&offset=0&status=Todo -Accept: application/json -Authorization: Bearer {{AccessToken}} - -### - -PUT {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks/00000000-0000-0000-0000-000000000000 -Content-Type: application/json -Authorization: Bearer {{AccessToken}} - -{ - "title": "Add observability defaults", - "description": "Configure logs, traces, metrics, and request ids.", - "status": "InProgress", - "dueDate": "2026-12-31T00:00:00Z" -} - -### - -POST {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks/00000000-0000-0000-0000-000000000000/complete -Authorization: Bearer {{AccessToken}} - -### - -GET {{CleanApiStarter_Api_HostAddress}}/api/projects?limit=20&offset=0 -Accept: application/json -X-Api-Version: 2.0 -Authorization: Bearer {{AccessToken}} - -### - -DELETE {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000 -Authorization: Bearer {{AccessToken}} - -### - -GET {{CleanApiStarter_Api_HostAddress}}/api/auth/me -Accept: application/json -Authorization: Bearer {{AccessToken}} - -### diff --git a/modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs deleted file mode 100644 index 516c2a1..0000000 --- a/modular/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs +++ /dev/null @@ -1,100 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints; - -public static class GoogleLoginPage -{ - public static WebApplication MapGoogleLoginPage(this WebApplication app) - { - if (!app.Environment.IsDevelopment()) - { - return app; - } - - app.MapGet("/auth/google-login", (AppSettings appSettings) => - { - string clientId = HtmlEncoder.Default.Encode(appSettings.Authentication.Google.ClientId); - - string html = - $$""" - - - - - - Google Login - - - - -

Google Login

-
-
- -
-

API JWT

- - -
-
Sign in with Google to generate a token.
- - - - - """; - - return Results.Content(html, "text/html"); - }) - .AllowAnonymous() - .WithName("GoogleLoginPage"); - - return app; - } -} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs deleted file mode 100644 index dc064cb..0000000 --- a/modular/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V1; - -public sealed class Auth : IEndpointGroup -{ - public static int MajorVersion => 1; - - public static string RoutePrefix => "/api/auth"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.MapPost("/google", SignInWithGoogle) - .AllowAnonymous() - .WithName("SignInWithGoogleV1"); - - groupBuilder.MapGet("/me", GetCurrentUser) - .RequireAuthorization() - .WithName("GetCurrentUserV1"); - } - - private static async Task SignInWithGoogle( - GoogleSignInDto signInDto, - IAuthService authService, - CancellationToken cancellationToken) - { - AuthTokenDto token = await authService.SignInWithGoogleAsync(signInDto, cancellationToken); - - return Results.Ok(token); - } - - private static async Task GetCurrentUser( - ClaimsPrincipal principal, - IAuthService authService, - CancellationToken cancellationToken) - { - CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); - - return Results.Ok(currentUser); - } -} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs deleted file mode 100644 index c55c4a9..0000000 --- a/modular/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs +++ /dev/null @@ -1,176 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V1; - -public sealed class Projects : IEndpointGroup -{ - public static int MajorVersion => 1; - - public static string RoutePrefix => "/api/projects"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapPost("/", CreateProject) - .WithName("CreateProjectV1"); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV1"); - - groupBuilder.MapGet("/{id:guid}", GetProject) - .WithName("GetProjectV1"); - - groupBuilder.MapDelete("/{id:guid}", DeleteProject) - .WithName("DeleteProjectV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks", CreateTask) - .WithName("CreateProjectTaskV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks", GetTasks) - .WithName("GetProjectTasksV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks/{taskId:guid}", GetTask) - .WithName("GetProjectTaskV1"); - - groupBuilder.MapPut("/{projectId:guid}/tasks/{taskId:guid}", UpdateTask) - .WithName("UpdateProjectTaskV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", CompleteTask) - .WithName("CompleteProjectTaskV1"); - - groupBuilder.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", DeleteTask) - .WithName("DeleteProjectTaskV1"); - } - - private static async Task CreateProject( - CreateProjectDto projectDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid id = await projectService.CreateProjectAsync(projectDto, cancellationToken); - - return Results.Created($"/api/projects/{id}", id); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(projects); - } - - private static async Task GetProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectDto? project = await projectService.GetProjectAsync(id, cancellationToken); - - return project == null ? Results.NotFound() : Results.Ok(project); - } - - private static async Task DeleteProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - DeleteProjectResult result = await projectService.DeleteProjectAsync(id, cancellationToken); - - return result switch - { - DeleteProjectResult.Deleted => Results.NoContent(), - DeleteProjectResult.Forbidden => Results.Forbid(), - _ => Results.NotFound() - }; - } - - private static async Task CreateTask( - Guid projectId, - CreateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid? taskId = await projectService.CreateTaskAsync(projectId, taskDto, cancellationToken); - - return taskId == null - ? Results.NotFound() - : Results.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); - } - - private static async Task GetTasks( - Guid projectId, - [FromQuery] ProjectTaskStatus? status, - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult? tasks = await projectService.GetTasksAsync( - projectId, - status, - query, - cancellationToken); - - return tasks == null ? Results.NotFound() : Results.Ok(tasks); - } - - private static async Task GetTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskDto? task = await projectService.GetTaskAsync(projectId, taskId, cancellationToken); - - return task == null ? Results.NotFound() : Results.Ok(task); - } - - private static async Task UpdateTask( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.UpdateTaskAsync( - projectId, - taskId, - taskDto, - cancellationToken); - - return result == ProjectTaskMutationResult.Success - ? Results.NoContent() - : Results.NotFound(); - } - - private static async Task CompleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.CompleteTaskAsync(projectId, taskId, cancellationToken); - - return result switch - { - ProjectTaskMutationResult.Success => Results.NoContent(), - ProjectTaskMutationResult.AlreadyCompleted => Results.Conflict(new - { - detail = "Task is already completed." - }), - _ => Results.NotFound() - }; - } - - private static async Task DeleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - bool deleted = await projectService.DeleteTaskAsync(projectId, taskId, cancellationToken); - - return deleted ? Results.NoContent() : Results.NotFound(); - } -} diff --git a/modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs deleted file mode 100644 index a0736b0..0000000 --- a/modular/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V2; - -public sealed class Projects : IEndpointGroup -{ - public static int MajorVersion => 2; - - public static string RoutePrefix => "/api/projects"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV2"); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(new - { - ApiVersion = "2.0", - projects.Items, - projects.Limit, - projects.Offset, - projects.TotalCount, - projects.HasPreviousPage, - projects.HasNextPage - }); - } -} diff --git a/modular/src/CleanApiStarter.Api/Services/CurrentUser.cs b/modular/src/CleanApiStarter.Api/Services/CurrentUser.cs deleted file mode 100644 index 37c0828..0000000 --- a/modular/src/CleanApiStarter.Api/Services/CurrentUser.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CleanApiStarter.Api.Services; - -public class CurrentUser(IHttpContextAccessor httpContextAccessor) : IUser -{ - 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/modular/src/CleanApiStarter.Api/appsettings.Development.json b/modular/src/CleanApiStarter.Api/appsettings.Development.json deleted file mode 100644 index 68309f2..0000000 --- a/modular/src/CleanApiStarter.Api/appsettings.Development.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "ConnectionStrings": { - "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" - }, - "Authentication": { - "Google": { - "ClientId": "" - }, - "Jwt": { - "Issuer": "CleanApiStarter", - "Audience": "CleanApiStarter.Api", - "SigningKey": "development-only-signing-key-change-me", - "ExpirationMinutes": 60 - } - } -} diff --git a/modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj deleted file mode 100644 index 8fd8e8e..0000000 --- a/modular/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - CleanApiStarter.AppHost - CleanApiStarter.AppHost - clean-api-starter-apphost - - - - - - - - - - - - - - - - diff --git a/modular/src/CleanApiStarter.AppHost/Program.cs b/modular/src/CleanApiStarter.AppHost/Program.cs deleted file mode 100644 index 7c5b157..0000000 --- a/modular/src/CleanApiStarter.AppHost/Program.cs +++ /dev/null @@ -1,19 +0,0 @@ -IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); - -string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); -string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); - -IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") - .WithImageTag("18") - .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") - .WithInitFiles(databaseMigrationsPath) - .WithPgAdmin(); - -IResourceBuilder database = postgresServer.AddDatabase("postgres") - .WithCreationScript("SELECT 1;"); - -builder.AddProject("api") - .WithReference(database) - .WaitFor(database); - -builder.Build().Run(); diff --git a/modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json b/modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json deleted file mode 100644 index 046e309..0000000 --- a/modular/src/CleanApiStarter.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17134;http://localhost:15170", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21030", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057" - } - } - } -} diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj deleted file mode 100644 index 275bbf0..0000000 --- a/modular/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - CleanApiStarter.Api.IntegrationTests - CleanApiStarter.Api.IntegrationTests - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs deleted file mode 100644 index bc17707..0000000 --- a/modular/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; - -public sealed class ProjectsTests : IClassFixture> -{ - private readonly HttpClient _client; - - public ProjectsTests(ApiApplicationFactory applicationFactory) - { - _client = applicationFactory.CreateClient(); - - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - applicationFactory.CreateAccessToken("integration-test-user")); - } - - [Fact] - public async Task Projects_PostAuthenticatedRequest_CreatesProject() - { - // Arrange - object request = new - { - Name = "Integration test project", - Description = "Created through the API integration test host" - }; - - // Act - HttpResponseMessage response = await _client.PostAsJsonAsync("/api/projects", request, - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - response.StatusCode.ShouldBe(HttpStatusCode.Created); - - Guid projectId = await response.Content.ReadFromJsonAsync( - TestContext.Current.CancellationToken); - projectId.ShouldNotBe(Guid.Empty); - - response.Headers.Location.ShouldNotBeNull(); - response.Headers.Location!.ToString().ShouldContain($"/api/projects/{projectId}"); - } -} diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs deleted file mode 100644 index d1837d5..0000000 --- a/modular/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs +++ /dev/null @@ -1,9 +0,0 @@ -global using System.Net; -global using System.Net.Http.Headers; -global using System.Net.Http.Json; - -global using CleanApiStarter.Tests.Common; - -global using Shouldly; - -global using Xunit; diff --git a/modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json deleted file mode 100644 index 8fdbc0f..0000000 --- a/modular/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "ConnectionStrings": { - "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" - }, - "Authentication": { - "Google": { - "ClientId": "integration-tests" - }, - "Jwt": { - "Issuer": "CleanApiStarter.Tests", - "Audience": "CleanApiStarter.Api.Tests", - "SigningKey": "integration-tests-signing-key-change-me", - "ExpirationMinutes": 60 - } - } -} diff --git a/modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj deleted file mode 100644 index 3167dbc..0000000 --- a/modular/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - CleanApiStarter.Tests - CleanApiStarter.Tests - - - - - - - - - - - - - - - - - diff --git a/modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs deleted file mode 100644 index 01f01a0..0000000 --- a/modular/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs +++ /dev/null @@ -1,100 +0,0 @@ -namespace CleanApiStarter.Tests.Common; - -public sealed class ApiApplicationFactory : WebApplicationFactory, IAsyncLifetime - where TProgram : class -{ - private const string TestIssuer = "CleanApiStarter.Tests"; - private const string TestAudience = "CleanApiStarter.Api.Tests"; - private const string TestSigningKey = "integration-tests-signing-key-change-me"; - - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:18") - .WithDatabase("postgres") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); - - public async ValueTask InitializeAsync() - { - await postgres.StartAsync(); - await RunDatabaseScriptsAsync(); - } - - public string CreateAccessToken(string userId) - { - SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(TestSigningKey)); - SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256); - List claims = - [ - new(JwtRegisteredClaimNames.Sub, userId), - new(ClaimTypes.NameIdentifier, userId), - new(ClaimTypes.Email, $"{userId}@example.test"), - new(ClaimTypes.Name, userId), - new(ClaimTypes.Role, "User") - ]; - - JwtSecurityToken token = new( - issuer: TestIssuer, - audience: TestAudience, - claims: claims, - expires: DateTime.UtcNow.AddMinutes(60), - signingCredentials: credentials); - - return new JwtSecurityTokenHandler().WriteToken(token); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Testing"); - - builder.ConfigureAppConfiguration(configurationBuilder => - { - string settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.Testing.json"); - Dictionary connectionStringOverride = new() - { - ["ConnectionStrings:Postgres"] = postgres.GetConnectionString() - }; - - configurationBuilder.AddJsonFile(settingsPath, optional: false); - configurationBuilder.AddInMemoryCollection(connectionStringOverride); - }); - } - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await postgres.DisposeAsync(); - } - - private async Task RunDatabaseScriptsAsync() - { - await using NpgsqlConnection connection = new(postgres.GetConnectionString()); - await connection.OpenAsync(); - - foreach (string scriptPath in GetDatabaseScriptPaths()) - { - string sql = await File.ReadAllTextAsync(scriptPath); - - await using NpgsqlCommand command = new(sql, connection); - await command.ExecuteNonQueryAsync(); - } - } - - private static IEnumerable GetDatabaseScriptPaths() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - - while (directory != null) - { - string migrationsPath = Path.Combine(directory.FullName, "database", "migrations"); - - if (Directory.Exists(migrationsPath)) - { - return Directory.GetFiles(migrationsPath, "*.sql").Order(StringComparer.Ordinal); - } - - directory = directory.Parent; - } - - throw new DirectoryNotFoundException("Could not find database/migrations from the test output directory."); - } -} diff --git a/modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs deleted file mode 100644 index 1ff103c..0000000 --- a/modular/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace CleanApiStarter.Tests.Common; - -public sealed class AutoNSubstituteDataAttribute() : AutoDataAttribute(CreateFixture) -{ - private static IFixture CreateFixture() - { - Fixture fixture = new(); - - fixture.Customize(new AutoNSubstituteCustomization()); - - return fixture; - } -} diff --git a/modular/tests/CleanApiStarter.Tests/GlobalUsings.cs b/modular/tests/CleanApiStarter.Tests/GlobalUsings.cs deleted file mode 100644 index 5d1b365..0000000 --- a/modular/tests/CleanApiStarter.Tests/GlobalUsings.cs +++ /dev/null @@ -1,18 +0,0 @@ -global using System.IdentityModel.Tokens.Jwt; -global using System.Security.Claims; -global using System.Text; - -global using AutoFixture; -global using AutoFixture.AutoNSubstitute; -global using AutoFixture.Xunit3; - -global using Microsoft.AspNetCore.Hosting; -global using Microsoft.AspNetCore.Mvc.Testing; -global using Microsoft.Extensions.Configuration; -global using Microsoft.IdentityModel.Tokens; - -global using Npgsql; - -global using Testcontainers.PostgreSql; - -global using Xunit; diff --git a/layered/scripts/install-template.sh b/scripts/install-template.sh similarity index 69% rename from layered/scripts/install-template.sh rename to scripts/install-template.sh index 6b93962..ecfed48 100755 --- a/layered/scripts/install-template.sh +++ b/scripts/install-template.sh @@ -2,14 +2,11 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -package="CleanApiStarter.Template.Layered" +package="CleanApiStarter.Template" pkg="$root/artifacts/$package.0.0.0.nupkg" cd "$root" -# Ensure the variant's database/ is populated from the shared root source. -../scripts/sync-database.sh - dotnet new uninstall "$package" 2>/dev/null || true dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" dotnet new install "$pkg" --force diff --git a/scripts/sync-database.sh b/scripts/sync-database.sh deleted file mode 100755 index 9ab403c..0000000 --- a/scripts/sync-database.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Copies the single source-of-truth database/ (repo root) into each variant. -# The variant copies are git-ignored; this keeps one canonical set of migrations -# while letting each variant stay self-contained for `dotnet new` packaging. -# -# Run this before packing a template locally, or before running a variant's -# Aspire AppHost. Integration tests find the migrations via a directory walk-up, -# so they work without syncing. CI runs this automatically. -set -euo pipefail - -root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source_dir="$root/database" - -if [ ! -d "$source_dir" ]; then - echo "Source database directory not found: $source_dir" >&2 - exit 1 -fi - -for variant in layered modular; do - dest="$root/$variant/database" - rm -rf "$dest" - cp -R "$source_dir" "$dest" - echo "Synced database -> $variant/database" -done diff --git a/layered/scripts/test-coverage.sh b/scripts/test-coverage.sh similarity index 100% rename from layered/scripts/test-coverage.sh rename to scripts/test-coverage.sh diff --git a/layered/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http similarity index 100% rename from layered/src/CleanApiStarter.Api/Api.http rename to src/CleanApiStarter.Api/Api.http diff --git a/modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj similarity index 100% rename from modular/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj rename to src/CleanApiStarter.Api/CleanApiStarter.Api.csproj diff --git a/modular/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs b/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs rename to src/CleanApiStarter.Api/Common/Interfaces/IUser.cs diff --git a/modular/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs b/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs rename to src/CleanApiStarter.Api/Common/Models/ArrayResult.cs diff --git a/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs diff --git a/modular/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs diff --git a/modular/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/AppSettings.cs b/src/CleanApiStarter.Api/Configuration/AppSettings.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/AppSettings.cs rename to src/CleanApiStarter.Api/Configuration/AppSettings.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs b/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs rename to src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs rename to src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs diff --git a/modular/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs rename to src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs diff --git a/modular/src/CleanApiStarter.Api/DependencyInjection.cs b/src/CleanApiStarter.Api/DependencyInjection.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/DependencyInjection.cs rename to src/CleanApiStarter.Api/DependencyInjection.cs diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/Project.cs b/src/CleanApiStarter.Api/Domain/Entities/Project.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Domain/Entities/Project.cs rename to src/CleanApiStarter.Api/Domain/Entities/Project.cs diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs diff --git a/modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs diff --git a/layered/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs similarity index 100% rename from layered/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs rename to src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs diff --git a/layered/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs similarity index 100% rename from layered/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs rename to src/CleanApiStarter.Api/Endpoints/V1/Auth.cs diff --git a/layered/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs similarity index 100% rename from layered/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs rename to src/CleanApiStarter.Api/Endpoints/V1/Projects.cs diff --git a/layered/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs similarity index 100% rename from layered/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs rename to src/CleanApiStarter.Api/Endpoints/V2/Projects.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs rename to src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs rename to src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs rename to src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs rename to src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs rename to src/CleanApiStarter.Api/Features/Auth/IAuthService.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs rename to src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs rename to src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs b/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs rename to src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs b/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs rename to src/CleanApiStarter.Api/Features/Projects/IProjectService.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs rename to src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs rename to src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs rename to src/CleanApiStarter.Api/Features/Projects/ProjectService.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs rename to src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs diff --git a/modular/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs rename to src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs diff --git a/modular/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs similarity index 99% rename from modular/src/CleanApiStarter.Api/GlobalUsings.cs rename to src/CleanApiStarter.Api/GlobalUsings.cs index 23c9c2a..5cf350f 100644 --- a/modular/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -1,5 +1,6 @@ // Global using directives +global using System.ComponentModel.DataAnnotations; global using System.IdentityModel.Tokens.Jwt; global using System.Reflection; global using System.Security.Claims; @@ -9,6 +10,7 @@ global using CleanApiStarter.Api; global using CleanApiStarter.Api.Common.Interfaces; global using CleanApiStarter.Api.Common.Models; +global using CleanApiStarter.Api.Configuration; global using CleanApiStarter.Api.Domain.Entities; global using CleanApiStarter.Api.Endpoints; global using CleanApiStarter.Api.Features.Auth; @@ -17,7 +19,6 @@ global using CleanApiStarter.Api.Infrastructure.Identity; global using CleanApiStarter.Api.Infrastructure.Persistence; global using CleanApiStarter.Api.Infrastructure.Repositories; -global using CleanApiStarter.Api.Configuration; global using CleanApiStarter.Api.Services; global using CleanApiStarter.AspNetCoreDefaults; @@ -25,8 +26,6 @@ global using Google.Apis.Auth; -global using System.ComponentModel.DataAnnotations; - global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Identity; diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs rename to src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs rename to src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs rename to src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs diff --git a/modular/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs b/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs rename to src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs diff --git a/modular/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs similarity index 100% rename from modular/src/CleanApiStarter.Api/Program.cs rename to src/CleanApiStarter.Api/Program.cs diff --git a/modular/src/CleanApiStarter.Api/Properties/launchSettings.json b/src/CleanApiStarter.Api/Properties/launchSettings.json similarity index 100% rename from modular/src/CleanApiStarter.Api/Properties/launchSettings.json rename to src/CleanApiStarter.Api/Properties/launchSettings.json diff --git a/layered/src/CleanApiStarter.Api/Services/CurrentUser.cs b/src/CleanApiStarter.Api/Services/CurrentUser.cs similarity index 100% rename from layered/src/CleanApiStarter.Api/Services/CurrentUser.cs rename to src/CleanApiStarter.Api/Services/CurrentUser.cs diff --git a/layered/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json similarity index 100% rename from layered/src/CleanApiStarter.Api/appsettings.Development.json rename to src/CleanApiStarter.Api/appsettings.Development.json diff --git a/modular/src/CleanApiStarter.Api/appsettings.json b/src/CleanApiStarter.Api/appsettings.json similarity index 100% rename from modular/src/CleanApiStarter.Api/appsettings.json rename to src/CleanApiStarter.Api/appsettings.json diff --git a/modular/src/CleanApiStarter.Api/config.nsdepcop b/src/CleanApiStarter.Api/config.nsdepcop similarity index 100% rename from modular/src/CleanApiStarter.Api/config.nsdepcop rename to src/CleanApiStarter.Api/config.nsdepcop diff --git a/layered/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj similarity index 100% rename from layered/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj rename to src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj diff --git a/layered/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs similarity index 100% rename from layered/src/CleanApiStarter.AppHost/Program.cs rename to src/CleanApiStarter.AppHost/Program.cs diff --git a/layered/src/CleanApiStarter.AppHost/Properties/launchSettings.json b/src/CleanApiStarter.AppHost/Properties/launchSettings.json similarity index 100% rename from layered/src/CleanApiStarter.AppHost/Properties/launchSettings.json rename to src/CleanApiStarter.AppHost/Properties/launchSettings.json diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs rename to src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj rename to src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs rename to src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs rename to src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs rename to src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs rename to src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs b/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs similarity index 90% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs rename to src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs index 7c5099d..27c9215 100644 --- a/modular/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs @@ -2,7 +2,7 @@ namespace CleanApiStarter.AspNetCoreDefaults; public sealed class RequestIdMiddleware(RequestDelegate next) { - public const string HeaderName = "X-Request-ID"; + private const string HeaderName = "X-Request-ID"; public async Task InvokeAsync(HttpContext context) { diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs b/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs rename to src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs b/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs rename to src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs diff --git a/modular/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs similarity index 100% rename from modular/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs diff --git a/layered/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj similarity index 100% rename from layered/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj rename to tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj diff --git a/layered/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs similarity index 100% rename from layered/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs rename to tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs diff --git a/layered/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs similarity index 100% rename from layered/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs rename to tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs diff --git a/layered/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json similarity index 100% rename from layered/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json rename to tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json diff --git a/modular/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj similarity index 100% rename from modular/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj rename to tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj diff --git a/modular/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs similarity index 100% rename from modular/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs rename to tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs diff --git a/modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs similarity index 100% rename from modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs rename to tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs index 32fce59..1faa155 100644 --- a/modular/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs @@ -1,8 +1,8 @@ global using AutoFixture.Xunit3; global using CleanApiStarter.Api.Common.Interfaces; -global using CleanApiStarter.Api.Features.Projects; global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Api.Features.Projects; global using CleanApiStarter.Tests.Common; global using Microsoft.Extensions.Logging; diff --git a/layered/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj similarity index 100% rename from layered/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj rename to tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj diff --git a/layered/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs similarity index 100% rename from layered/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs rename to tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs diff --git a/layered/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs similarity index 100% rename from layered/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs rename to tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs diff --git a/layered/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.Tests/GlobalUsings.cs similarity index 100% rename from layered/tests/CleanApiStarter.Tests/GlobalUsings.cs rename to tests/CleanApiStarter.Tests/GlobalUsings.cs From eb5872bf7a270f3525b95d592e03f8b26c444984 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sat, 20 Jun 2026 18:24:29 +0800 Subject: [PATCH 07/18] docs: remove source transcript, add architecture summary to README - Delete docs/architecture/ardalis-clean-architecture-vsa-transcript.md. - Strip source attribution (author/transcript references) from clean-architecture-and-vertical-slices.md and reframe its now-stale two-variant wording for the single-solution structure. - Add an 'Architecture in brief' summary near the top of README.md that distills the three-decisions framing and links to the full architecture doc and the ADRs. --- README.md | 23 ++- ...dalis-clean-architecture-vsa-transcript.md | 180 ------------------ .../clean-architecture-and-vertical-slices.md | 46 ++--- 3 files changed, 39 insertions(+), 210 deletions(-) delete mode 100644 docs/architecture/ardalis-clean-architecture-vsa-transcript.md diff --git a/README.md b/README.md index f76656d..50a9ca3 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,27 @@ compile time by [NsDepCop](https://github.com/realvizu/NsDepCop) rather than by separate projects. The sample domain is project management: authenticated users create projects, manage tasks, filter tasks by status, and complete them. -The reasoning behind this structure — and how it evolved — is recorded in -[`adr/`](adr/) and [`docs/architecture`](docs/architecture/clean-architecture-and-vertical-slices.md). +## Architecture in brief + +CleanApiStarter treats architecture as three separate decisions rather than one: + +- **Feature organization** — *where do I find the code?* Related code for a + capability lives together (vertical slices), not scattered across technical + folders. +- **Dependency management** — *what may depend on what?* Clean Architecture is, at + its core, about dependency direction: business logic stays free of + infrastructure, and dependencies point inward. Here that rule is enforced at + build time by an analyzer (NsDepCop) rather than by splitting the app into many + projects. +- **Code reuse** — *is logic consistent across features?* Share what is genuinely + shared (the domain model, infrastructure); duplicate only slice-level glue + (request/response, validators, handlers) instead of coupling features through the + wrong abstraction. + +Project count is an implementation detail, not an architectural principle. For the +full reasoning see +[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md); +for how the structure evolved, see the [decision records](adr/). ## Features diff --git a/docs/architecture/ardalis-clean-architecture-vsa-transcript.md b/docs/architecture/ardalis-clean-architecture-vsa-transcript.md deleted file mode 100644 index dea55bd..0000000 --- a/docs/architecture/ardalis-clean-architecture-vsa-transcript.md +++ /dev/null @@ -1,180 +0,0 @@ -# Transcript — Ardalis on Clean Architecture vs. Vertical Slices - -> **Attribution / source.** This is a lightly edited transcript of a talk by -> **Steve Smith (Ardalis)** responding to a video by Nick Chapsas about .NET -> practices he no longer recommends. It is reproduced here only as a reference -> for the architectural decisions recorded in -> [`clean-architecture-and-vertical-slices.md`](clean-architecture-and-vertical-slices.md). -> -> The text was auto-transcribed and then lightly cleaned for readability; -> wording is approximate and may contain transcription errors. **Prefer linking -> to the original video** rather than redistributing this transcript — if this -> repository is public, consider replacing the body below with a link and a short -> summary to respect the author's copyright. - ---- - -## On the "six-project solution" criticism - -I recently watched a video from Nick Chapsas about software practices he used to -recommend for .NET developers that he doesn't really use anymore, and one of the -first topics was Clean Architecture. I actually agree with a lot of what Nick -said — especially that a giant six-project solution is probably not the right -default for many applications today. (Granted, Microsoft is responsible for a -third of those projects now, due to Aspire.) A six-project solution is not a -prerequisite for good architecture — which is slightly awkward for me to admit, -since I maintain one of the more popular Clean Architecture templates in the .NET -ecosystem. More than one, it turns out. - -The industry has evolved, technology has evolved, and that's a good thing. - -## Clean Architecture and Vertical Slices are not opposites - -One thing I want to clarify: **Vertical Slice Architecture and Clean Architecture -are not opposites. They solve different problems.** Many people confuse Clean -Architecture with a specific folder structure from around 2018. Those are not the -same thing. - -To me, Clean Architecture is fundamentally about **dependency direction**. It's -about protecting your business logic from external dependencies, isolating -infrastructure, encapsulation, and enforcing boundaries. It is *not* about -whether you have one, two, or twelve projects. - -And separately from that, **you should build in vertical slices.** I've been -advocating for feature-based organization for over a decade — I wrote about -vertical slices and vertical features for ASP.NET on DevIQ around 2015, and in an -MSDN magazine article around 2016, a couple of years before Jimmy Bogard's -vertical slice architecture article. - -So when Nick says applications are easier to understand when organized around -features — yes, absolutely. If I need to add a feature related to orders, I should -navigate to the orders feature set and add it there, with little need to look -elsewhere. That part is not controversial. - -## Where older layered architectures went wrong - -Early layered architectures put *all* data access in one layer, *all* business -logic in another, and the UI in a third. With MVC, the UI layer also had root -folders for controllers, models, view models, and views. So every time you wanted -to add a feature, you had to add files in every one of these layers and folders. -The constant scrolling around the directory structure added a lot of unnecessary -friction. That's the whole reason many of us in the .NET community started -recommending vertical slices and feature folders. - -It didn't help that the .NET community decided over time that every application -needed a dozen or more projects. We may have gone a little overboard. Some clients -had solutions containing *hundreds* of projects; we've worked to bring those -numbers down to something more sane. - -## The duplication myth in "pure" vertical slices - -Nick talks about vertical slice architecture as a great alternative to Clean -Architecture, but never shows what his preferred architecture actually looks like. -I looked at the second most popular VSA template on GitHub. Everyone touts that -vertical slices are great because you have everything you need in one folder and -never bounce around. Is that really true? - -There's a root-level `features` folder with, say, heroes and teams, and inside an -endpoint you'll find the records, the endpoint, the handler, a validator, and a -summary — all together. Great, the endpoint-specific types are in one file. - -But what *isn't* in there? The actual logic — adding the hero, saving changes for -persistence — where does that live? In a `common` folder, which contains all the -usual Clean Architecture folders: domain, persistence, infrastructure. To see how -"add hero" works, you go into the team aggregate where the domain logic lives. - -So it's really not that different structurally. In this template, the only thing -centralized *as a feature* are the types needed for an endpoint — not the business -logic, not the persistence, not other infrastructure. If you add a feature that -touches a new domain entity or changes how it's stored, you'll need to bounce -around to those other folders. That's fine — that's just how things work. - -**Understand that in most cases, even when people talk about vertical slices, -they're mostly only talking about the UI layer.** Very few developers or -architects suggest you put *all* the business logic and persistence separately in -each feature folder. Not everything belongs duplicated into every feature folder -forever. Some abstractions are useful. Sharing domain concepts instead of -duplicating them is helpful. Reusing consistent infrastructure — like how you -configure your DbContext — makes sense, instead of having a DbContext in every -feature folder. - -## The right framing: three separate decisions - -This is why the conversation shouldn't be framed as "vertical slices *or* Clean -Architecture," as if they're against each other. That's the wrong framing. -**Feature organization, code reuse, and dependency management are separate aspects -of your architectural choices:** - -- **Feature organization** answers *"where do I find the code?"* -- **Code reuse** ensures the logic in your application is consistent between - features. -- **Dependency management** is about deciding what is allowed to depend on what. - -These are all valid things to optimize. - -## Why "minimal clean" exists, and enforcing rules without projects - -I created the minimal Clean Architecture template because I agreed with a lot of -the criticisms of classic Clean Architecture over the years. Giant multi-repo / -multi-project solutions became cumbersome — too much ceremony, too much project -hopping. I also figured out a way to **still enforce the rules of dependency -management without having separate projects.** - -Nick makes the point that, especially with AI, having locality of files helps the -AI discover things. Maybe — they have tools like ripgrep to find anything. But -**consistency really matters for LLM/agent-driven development**: a consistent way -things are organized and behavior is applied matters a lot. And humans naturally -do better when related things are contained together too. Many practices people -are discovering for making AI write better code turn out to be the same things -that were great for human developers. - -So minimal clean keeps the benefits I still cared about — dependency inversion, -isolation of infrastructure, proper encapsulation, testability — but is much -simpler, with far less ceremony and fewer projects. With Aspire it's about three -projects, but just one for the application, which makes it simpler to navigate and -allows a more feature-centric organization. There's just one web project, and at -the top you immediately see the features (e.g. cart features, product features), -and inside each you see vertical slices for each endpoint. - -## Toward modular monoliths - -I was also trying to solve another problem: proper **modules**. Most ASP.NET Core -solutions lack them, partly because the traditional Clean Architecture layout -doesn't map well to real modules — unless you're building microservices, where -each application *is* the module. - -But most teams don't need microservices. Many reach for them to solve -*organizational* problems more than technical ones, and many who justify them -technically really just needed **modularity with real boundaries** and couldn't -figure out how to get that without putting a network hop between modules. If you -need real modules, having only `core`, `infrastructure`, and `web` projects -doesn't cut it. Real modules require autonomy, encapsulation, and clearly defined -public contracts. A giant shared core project and a big infrastructure project -provide no boundaries between modules — they exist to fix the *dependency* -problem, which is the point of Clean Architecture, not the *modularity* problem. - -So with minimal clean I didn't move away from boundaries — I moved toward -**module boundaries**. These days, for most serious business applications, my -go-to is a **modular monolith**: not distributed microservices, not a giant shared -layered monolith, but a modular system organized around business capabilities -(books, email, orders, reports, user management) at the top level. To work on -reporting, you stay in the reporting module. You get clear contracts, explicit -module boundaries, and strong encapsulation that keeps modules independent — all -still organized around features and built in vertical slices. These ideas -absolutely coexist. - -This gets you many of the benefits people wanted from microservices without the -operational tax of a distributed system. The minimal clean template works great as -a single module in this style. You get feature-centric organization top to bottom, -pragmatic boundaries, strong modularity, and less ceremony — and you can scale up -to different teams owning different modules, with fewer accidental distributed -systems. - -## Closing - -I appreciated Nick's video — he's reacting against a very real problem. But the -answer isn't to throw away architectural boundaries and rules; it's to use -*better* boundaries. Microservices have lost some luster, but the need for -modularity remains. And keeping more of the system in a monorepo makes it much -easier for AI agents to work on effectively — which further argues for modular -monoliths over a microservices-first approach. diff --git a/docs/architecture/clean-architecture-and-vertical-slices.md b/docs/architecture/clean-architecture-and-vertical-slices.md index ed09d87..f7a2d56 100644 --- a/docs/architecture/clean-architecture-and-vertical-slices.md +++ b/docs/architecture/clean-architecture-and-vertical-slices.md @@ -1,14 +1,8 @@ # Clean Architecture, Vertical Slices, and Duplication -This document records the architectural reasoning behind CleanApiStarter and, in -particular, why the template ships in two flavors (`layered` and `modular`). It -exists so that contributors and template users understand *why* the structure is -the way it is, not just *what* it is. - -The thinking here was heavily shaped by a talk from Steve Smith (Ardalis) -responding to the "is Clean Architecture dead?" debate. A cleaned-up transcript -of that talk is kept alongside this file in -[`ardalis-clean-architecture-vsa-transcript.md`](ardalis-clean-architecture-vsa-transcript.md). +This document records the architectural reasoning behind CleanApiStarter — *why* +the structure is the way it is, not just *what* it is. The history of how the +structure evolved is captured in the [architecture decision records](../../adr/). ## The core idea: three independent decisions @@ -112,17 +106,21 @@ broken. The two variants enforce the same rule with different mechanisms: ``` -## The two variants in this repo +## Two ways to enforce the same rule + +The dependency rule can be enforced two ways, and both are legitimate Clean +Architectures — they differ only in feature organization and enforcement mechanism, +not in whether they respect dependency direction: -| | `layered` | `modular` | +| | Multi-project | Single project | | --- | --- | --- | -| Shape | Multi-project (Api, Application, Domain, Infrastructure, …) | One business project (`Api`) + reused platform projects (`AspNetCore`, `Configuration`) + `AppHost` | +| Shape | A project per layer (Api, Application, Domain, Infrastructure, …) | One application project organized by feature folders | | Feature organization | Feature folders inside layered projects | Vertical slices in `Features/` | -| Enforcement | Project references (compiler) | NsDepCop (analyzer, build-breaking) | -| Best for | Teams who want hard, physical boundaries | MVPs, smaller apps, less ceremony, feature-centric work | +| Enforcement | Project references (compiler) | Namespace analyzer, build-breaking | +| Best for | Teams who want hard, physical boundaries | Smaller apps, less ceremony, feature-centric work | -Both are legitimate Clean Architectures — they differ in feature organization and -enforcement mechanism, not in whether they respect dependency direction. +CleanApiStarter uses the single-project approach; see the +[decision records](../../adr/) for how it arrived there. ## Where this is heading: the modular monolith @@ -136,8 +134,8 @@ Modules.Billing.Internal.*`). This gives much of the modularity and independence people reach microservices for — autonomy, encapsulation, clear contracts — without the operational tax of a -distributed system. The `modular` variant is intentionally a stepping stone in -this direction: its `Features/` layout can grow into `Modules/` over time. +distributed system. The single-project layout here is intentionally a stepping +stone in this direction: its `Features/` layout can grow into `Modules/` over time. ## Summary @@ -146,14 +144,6 @@ this direction: its `Features/` layout can grow into `Modules/` over time. - Project count is an implementation detail, not an architectural principle. - Duplicate slice-level glue freely; never duplicate domain rules or infrastructure. -- Enforce boundaries with something that breaks the build — project references - (`layered`) or NsDepCop (`modular`). +- Enforce boundaries with something that breaks the build — a namespace analyzer + (used here) or project references. - The modular monolith is the long-term target for serious business apps. - -## Source - -Steve Smith (Ardalis), talk on Clean Architecture vs. Vertical Slice -Architecture (responding to Nick Chapsas). See the transcript in -[`ardalis-clean-architecture-vsa-transcript.md`](ardalis-clean-architecture-vsa-transcript.md). -Prefer linking to the original video over redistributing the transcript; see the -attribution note in that file. From fa640cea618fdc52e09ca49e3c085393bf8ebfb8 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 11:03:02 +0800 Subject: [PATCH 08/18] chore: nest solution folders in .slnx to mirror the directory tree Config files under Solution Items now sit in nested .config/, .github/workflows/, and .template.config/ folders instead of a flat list. Fold the virtual src/Common group into src/ (the projects are siblings on disk) and normalize tests paths to forward slashes. --- CleanApiStarter.slnx | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index fba1bd8..25ba3a9 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -1,40 +1,45 @@ - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - + + From b566f70331899abdc6458c94b91b92586d8bb422 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 11:35:56 +0800 Subject: [PATCH 09/18] refactor(features): split Tasks into Projects/Tasks sub-feature Folders-only step toward proper vertical slices: move the task contracts (ProjectTaskDto, Create/Update task validators) into Features/Projects/Tasks with a matching CleanApiStarter.Api.Features.Projects.Tasks namespace. Auth stays a flat capability folder. Handlers and endpoints are untouched. --- .../Projects/{ => Tasks}/CreateProjectTaskDtoValidator.cs | 2 +- .../Features/Projects/{ => Tasks}/ProjectTaskDto.cs | 2 +- .../Projects/{ => Tasks}/UpdateProjectTaskDtoValidator.cs | 2 +- src/CleanApiStarter.Api/GlobalUsings.cs | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) rename src/CleanApiStarter.Api/Features/Projects/{ => Tasks}/CreateProjectTaskDtoValidator.cs (90%) rename src/CleanApiStarter.Api/Features/Projects/{ => Tasks}/ProjectTaskDto.cs (94%) rename src/CleanApiStarter.Api/Features/Projects/{ => Tasks}/UpdateProjectTaskDtoValidator.cs (93%) diff --git a/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs similarity index 90% rename from src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs rename to src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs index fd275e8..621bc61 100644 --- a/src/CleanApiStarter.Api/Features/Projects/CreateProjectTaskDtoValidator.cs +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Features.Projects; +namespace CleanApiStarter.Api.Features.Projects.Tasks; public sealed class CreateProjectTaskDtoValidator : AbstractValidator { diff --git a/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs similarity index 94% rename from src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs rename to src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs index e663046..fb754a7 100644 --- a/src/CleanApiStarter.Api/Features/Projects/ProjectTaskDto.cs +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Features.Projects; +namespace CleanApiStarter.Api.Features.Projects.Tasks; public sealed class ProjectTaskDto { diff --git a/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs similarity index 93% rename from src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs rename to src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs index e6b9068..81df0b6 100644 --- a/src/CleanApiStarter.Api/Features/Projects/UpdateProjectTaskDtoValidator.cs +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Features.Projects; +namespace CleanApiStarter.Api.Features.Projects.Tasks; public sealed class UpdateProjectTaskDtoValidator : AbstractValidator { diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 5cf350f..4dc204a 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -15,6 +15,7 @@ global using CleanApiStarter.Api.Endpoints; global using CleanApiStarter.Api.Features.Auth; global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Features.Projects.Tasks; global using CleanApiStarter.Api.Infrastructure; global using CleanApiStarter.Api.Infrastructure.Identity; global using CleanApiStarter.Api.Infrastructure.Persistence; From d3241c36210bad59e2ff6f64cdd5760eaa1ab77e Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 11:42:55 +0800 Subject: [PATCH 10/18] refactor(features): co-locate endpoint groups with features, drop Endpoints/ folder Move the Auth/Projects (V1) and Projects (V2) endpoint groups and the dev Google login page into their feature folders with matching namespaces; remove the separate Endpoints/ folder. Endpoint groups are still discovered by reflection and continue to call ProjectService / IAuthService (handlers unchanged). Drop the now-unused Endpoints global using; refresh the README layout. Verified: build, format, unit and integration tests pass. --- README.md | 4 ++-- .../{Endpoints/V1 => Features/Auth}/Auth.cs | 2 +- .../{Endpoints => Features/Auth}/GoogleLoginPage.cs | 2 +- .../{Endpoints/V1 => Features/Projects}/Projects.cs | 2 +- .../{Endpoints => Features/Projects}/V2/Projects.cs | 2 +- src/CleanApiStarter.Api/GlobalUsings.cs | 1 - 6 files changed, 6 insertions(+), 7 deletions(-) rename src/CleanApiStarter.Api/{Endpoints/V1 => Features/Auth}/Auth.cs (96%) rename src/CleanApiStarter.Api/{Endpoints => Features/Auth}/GoogleLoginPage.cs (99%) rename src/CleanApiStarter.Api/{Endpoints/V1 => Features/Projects}/Projects.cs (99%) rename src/CleanApiStarter.Api/{Endpoints => Features/Projects}/V2/Projects.cs (94%) diff --git a/README.md b/README.md index 50a9ca3..98e5fd3 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,10 @@ CleanApiStarter │ │ ├── config.nsdepcop ← enforced dependency rules │ │ ├── Common ← shared kernel (paged results, IUser) │ │ ├── Domain ← entities (no outward dependencies) -│ │ ├── Features ← Auth, Projects (endpoints + handlers + validators) +│ │ ├── Features ← Auth, Projects/Tasks (endpoints + DTOs + validators per feature) │ │ ├── Infrastructure ← DbContext, EF config, repositories, identity │ │ ├── Configuration ← settings classes + options registration -│ │ ├── Endpoints, Services ← composition / web wiring +│ │ ├── Services ← composition / web wiring (CurrentUser) │ │ └── Program.cs │ └── Common │ ├── CleanApiStarter.AppHost ← Aspire orchestration diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Features/Auth/Auth.cs similarity index 96% rename from src/CleanApiStarter.Api/Endpoints/V1/Auth.cs rename to src/CleanApiStarter.Api/Features/Auth/Auth.cs index dc064cb..757232c 100644 --- a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs +++ b/src/CleanApiStarter.Api/Features/Auth/Auth.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Endpoints.V1; +namespace CleanApiStarter.Api.Features.Auth; public sealed class Auth : IEndpointGroup { diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs similarity index 99% rename from src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs rename to src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs index 516c2a1..61d4a4b 100644 --- a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs +++ b/src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Endpoints; +namespace CleanApiStarter.Api.Features.Auth; public static class GoogleLoginPage { diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/Projects.cs similarity index 99% rename from src/CleanApiStarter.Api/Endpoints/V1/Projects.cs rename to src/CleanApiStarter.Api/Features/Projects/Projects.cs index c55c4a9..b6f2097 100644 --- a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs +++ b/src/CleanApiStarter.Api/Features/Projects/Projects.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Endpoints.V1; +namespace CleanApiStarter.Api.Features.Projects; public sealed class Projects : IEndpointGroup { diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs similarity index 94% rename from src/CleanApiStarter.Api/Endpoints/V2/Projects.cs rename to src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs index a0736b0..5cfd4bf 100644 --- a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs +++ b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Endpoints.V2; +namespace CleanApiStarter.Api.Features.Projects.V2; public sealed class Projects : IEndpointGroup { diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 4dc204a..d32fc96 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -12,7 +12,6 @@ global using CleanApiStarter.Api.Common.Models; global using CleanApiStarter.Api.Configuration; global using CleanApiStarter.Api.Domain.Entities; -global using CleanApiStarter.Api.Endpoints; global using CleanApiStarter.Api.Features.Auth; global using CleanApiStarter.Api.Features.Projects; global using CleanApiStarter.Api.Features.Projects.Tasks; From 04c5e55272d20796960a75e36be3d326e7aa8f37 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 12:00:18 +0800 Subject: [PATCH 11/18] refactor(features): full vertical slices (one file per operation) Retire the monolithic ProjectService, IProjectService, and the result enums. Each operation is now a self-contained slice co-locating its request record, validator, endpoint mapping, and handler that talks directly to IProjectRepository / IAuthService: Auth/ SignInWithGoogle, GetCurrentUser Projects/ CreateProject, GetProjects, GetProject, DeleteProject Projects/Tasks/ CreateTask, GetTasks, GetTask, UpdateTask, CompleteTask, DeleteTask Projects/V2/ GetProjects Thin per-feature IEndpointGroup classes (Auth, Projects, V2.Projects) just wire the route group and delegate to each slice's Map. Handlers return TypedResults directly, removing the DeleteProjectResult / ProjectTaskMutationResult indirection. Mapping moves to ProjectDto.From / ProjectTaskDto.From; a shared IUser.RequireId() guards the user id. IAuthService.SignInWithGoogleAsync now takes the id token string, dropping GoogleSignInDto. NsDepCop's Features-not-Infrastructure rule still holds (slices use interfaces; impls stay in Infrastructure). Unit test now covers the CompleteTask slice handler (asserts 409 + no save); Application.UnitTests gains a Microsoft.AspNetCore.App framework reference to assert on typed results. Build, format, unit + integration tests, and template pack/instantiate/build all pass. --- .../Common/Interfaces/UserExtensions.cs | 13 + .../DependencyInjection.cs | 1 - src/CleanApiStarter.Api/Features/Auth/Auth.cs | 29 +- .../Features/Auth/GetCurrentUser.cs | 21 ++ .../Features/Auth/GoogleSignInDto.cs | 6 - .../Features/Auth/GoogleSignInDtoValidator.cs | 10 - .../Features/Auth/IAuthService.cs | 2 +- .../Features/Auth/SignInWithGoogle.cs | 32 +++ .../Features/Projects/CreateProject.cs | 57 ++++ .../Projects/CreateProjectDtoValidator.cs | 14 - .../Features/Projects/DeleteProject.cs | 33 +++ .../Features/Projects/GetProject.cs | 23 ++ .../Features/Projects/GetProjects.cs | 24 ++ .../Features/Projects/IProjectService.cs | 32 --- .../Features/Projects/ProjectDto.cs | 17 +- .../Projects/ProjectOperationResults.cs | 15 -- .../Features/Projects/ProjectService.cs | 248 ------------------ .../Features/Projects/Projects.cs | 173 +----------- .../Features/Projects/Tasks/CompleteTask.cs | 41 +++ .../Tasks/CreateProjectTaskDtoValidator.cs | 19 -- .../Features/Projects/Tasks/CreateTask.cs | 60 +++++ .../Features/Projects/Tasks/DeleteTask.cs | 29 ++ .../Features/Projects/Tasks/GetTask.cs | 28 ++ .../Features/Projects/Tasks/GetTasks.cs | 35 +++ .../Features/Projects/Tasks/ProjectTaskDto.cs | 33 +-- .../Tasks/UpdateProjectTaskDtoValidator.cs | 24 -- .../Features/Projects/Tasks/UpdateTask.cs | 67 +++++ .../Features/Projects/V2/GetProjects.cs | 35 +++ .../Features/Projects/V2/Projects.cs | 22 +- .../Identity/GoogleAuthService.cs | 4 +- ...eanApiStarter.Application.UnitTests.csproj | 4 + .../CompleteTaskTests.cs} | 13 +- .../GlobalUsings.cs | 4 +- 33 files changed, 553 insertions(+), 615 deletions(-) create mode 100644 src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs create mode 100644 src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs delete mode 100644 src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs delete mode 100644 src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs create mode 100644 src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/CreateProject.cs delete mode 100644 src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/GetProject.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/GetProjects.cs delete mode 100644 src/CleanApiStarter.Api/Features/Projects/IProjectService.cs delete mode 100644 src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs delete mode 100644 src/CleanApiStarter.Api/Features/Projects/ProjectService.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs delete mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs delete mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs rename tests/CleanApiStarter.Application.UnitTests/Features/Projects/{ProjectServiceTests.cs => Tasks/CompleteTaskTests.cs} (67%) diff --git a/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs b/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs new file mode 100644 index 0000000..cf0892d --- /dev/null +++ b/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Api.Common.Interfaces; + +public static class UserExtensions +{ + /// + /// Returns the authenticated user's id, or throws if the request is unauthenticated. + /// Endpoints are protected by authorization, so this is a defensive guard. + /// + public static string RequireId(this IUser user) + { + return user.Id ?? throw new UnauthorizedAccessException(); + } +} diff --git a/src/CleanApiStarter.Api/DependencyInjection.cs b/src/CleanApiStarter.Api/DependencyInjection.cs index b745e75..cf5dc70 100644 --- a/src/CleanApiStarter.Api/DependencyInjection.cs +++ b/src/CleanApiStarter.Api/DependencyInjection.cs @@ -5,7 +5,6 @@ public static class DependencyInjection public static IServiceCollection AddApplication(this IServiceCollection services) { services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - services.AddScoped(); return services; } diff --git a/src/CleanApiStarter.Api/Features/Auth/Auth.cs b/src/CleanApiStarter.Api/Features/Auth/Auth.cs index 757232c..e141ccb 100644 --- a/src/CleanApiStarter.Api/Features/Auth/Auth.cs +++ b/src/CleanApiStarter.Api/Features/Auth/Auth.cs @@ -8,32 +8,7 @@ public sealed class Auth : IEndpointGroup public static void Map(RouteGroupBuilder groupBuilder) { - groupBuilder.MapPost("/google", SignInWithGoogle) - .AllowAnonymous() - .WithName("SignInWithGoogleV1"); - - groupBuilder.MapGet("/me", GetCurrentUser) - .RequireAuthorization() - .WithName("GetCurrentUserV1"); - } - - private static async Task SignInWithGoogle( - GoogleSignInDto signInDto, - IAuthService authService, - CancellationToken cancellationToken) - { - AuthTokenDto token = await authService.SignInWithGoogleAsync(signInDto, cancellationToken); - - return Results.Ok(token); - } - - private static async Task GetCurrentUser( - ClaimsPrincipal principal, - IAuthService authService, - CancellationToken cancellationToken) - { - CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); - - return Results.Ok(currentUser); + SignInWithGoogle.Map(groupBuilder); + GetCurrentUser.Map(groupBuilder); } } diff --git a/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs b/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs new file mode 100644 index 0000000..9e791f7 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs @@ -0,0 +1,21 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public static class GetCurrentUser +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/me", Handle) + .RequireAuthorization() + .WithName("GetCurrentUserV1"); + } + + private static async Task Handle( + ClaimsPrincipal principal, + IAuthService authService, + CancellationToken cancellationToken) + { + CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); + + return TypedResults.Ok(currentUser); + } +} diff --git a/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs deleted file mode 100644 index 821a8ef..0000000 --- a/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CleanApiStarter.Api.Features.Auth; - -public sealed class GoogleSignInDto -{ - public required string IdToken { get; init; } -} diff --git a/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs deleted file mode 100644 index 66bfced..0000000 --- a/src/CleanApiStarter.Api/Features/Auth/GoogleSignInDtoValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.Api.Features.Auth; - -public sealed class GoogleSignInDtoValidator : AbstractValidator -{ - public GoogleSignInDtoValidator() - { - RuleFor(signIn => signIn.IdToken) - .NotEmpty(); - } -} diff --git a/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs index db9f544..eb9e328 100644 --- a/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs +++ b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs @@ -2,7 +2,7 @@ namespace CleanApiStarter.Api.Features.Auth; public interface IAuthService { - Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); + Task SignInWithGoogleAsync(string idToken, CancellationToken cancellationToken); Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); } diff --git a/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs b/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs new file mode 100644 index 0000000..a32e68c --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public static class SignInWithGoogle +{ + public sealed record Request(string IdToken); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.IdToken) + .NotEmpty(); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/google", Handle) + .AllowAnonymous() + .WithName("SignInWithGoogleV1"); + } + + private static async Task Handle( + Request request, + IAuthService authService, + CancellationToken cancellationToken) + { + AuthTokenDto token = await authService.SignInWithGoogleAsync(request.IdToken, cancellationToken); + + return TypedResults.Ok(token); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs b/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs new file mode 100644 index 0000000..ec27e08 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs @@ -0,0 +1,57 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class CreateProject +{ + public sealed record Request(string Name, string Description); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(request => request.Description) + .MaximumLength(2_000); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/", Handle) + .WithName("CreateProjectV1"); + } + + private static async Task Handle( + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + DateTime now = DateTime.UtcNow; + Guid projectId = Guid.NewGuid(); + Project project = new() + { + Id = projectId, + Name = request.Name, + Description = request.Description, + OwnerUserId = userId, + CreatedAt = now, + Members = + [ + new ProjectMember + { + ProjectId = projectId, + UserId = userId, + CreatedAt = now + } + ] + }; + + Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); + + return TypedResults.Created($"/api/projects/{createdProjectId}", createdProjectId); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs deleted file mode 100644 index d5dfbb7..0000000 --- a/src/CleanApiStarter.Api/Features/Projects/CreateProjectDtoValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CleanApiStarter.Api.Features.Projects; - -public sealed class CreateProjectDtoValidator : AbstractValidator -{ - public CreateProjectDtoValidator() - { - RuleFor(project => project.Name) - .NotEmpty() - .MaximumLength(100); - - RuleFor(project => project.Description) - .MaximumLength(2_000); - } -} diff --git a/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs b/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs new file mode 100644 index 0000000..d141700 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs @@ -0,0 +1,33 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class DeleteProject +{ + public static void Map(RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", Handle) + .WithName("DeleteProjectV1"); + } + + private static async Task Handle( + Guid id, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + if (!await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken)) + { + return TypedResults.Forbid(); + } + + bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); + + return deleted ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/GetProject.cs b/src/CleanApiStarter.Api/Features/Projects/GetProject.cs new file mode 100644 index 0000000..563a00c --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/GetProject.cs @@ -0,0 +1,23 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class GetProject +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", Handle) + .WithName("GetProjectV1"); + } + + private static async Task Handle( + Guid id, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + Project? project = await projectRepository.GetProjectAsync(id, currentUser.RequireId(), cancellationToken); + + return project is null + ? TypedResults.NotFound() + : TypedResults.Ok(ProjectDto.From(project)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs b/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs new file mode 100644 index 0000000..8bbdc05 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class GetProjects +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/", Handle) + .WithName("GetProjectsV1"); + } + + private static async Task Handle( + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectRepository.GetProjectsAsync( + currentUser.RequireId(), + query, + cancellationToken); + + return TypedResults.Ok(projects.Map(ProjectDto.From)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs b/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs deleted file mode 100644 index 9569d13..0000000 --- a/src/CleanApiStarter.Api/Features/Projects/IProjectService.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CleanApiStarter.Api.Features.Projects; - -public interface IProjectService -{ - Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken); - - Task GetProjectAsync(Guid id, CancellationToken cancellationToken); - - Task> GetProjectsAsync(PaginatedQuery query, CancellationToken cancellationToken); - - Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); - - Task CreateTaskAsync(Guid projectId, CreateProjectTaskDto taskDto, CancellationToken cancellationToken); - - Task GetTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken); - - Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken); - - Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); -} diff --git a/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs index 30294a1..40f617f 100644 --- a/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs +++ b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs @@ -11,11 +11,16 @@ public sealed class ProjectDto public required string OwnerUserId { get; init; } public required DateTime CreatedAt { get; init; } -} -public sealed class CreateProjectDto -{ - public required string Name { get; init; } - - public required string Description { get; init; } + public static ProjectDto From(Project project) + { + return new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + OwnerUserId = project.OwnerUserId, + CreatedAt = project.CreatedAt + }; + } } diff --git a/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs deleted file mode 100644 index ae436bb..0000000 --- a/src/CleanApiStarter.Api/Features/Projects/ProjectOperationResults.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CleanApiStarter.Api.Features.Projects; - -public enum DeleteProjectResult -{ - Deleted, - NotFound, - Forbidden -} - -public enum ProjectTaskMutationResult -{ - Success, - NotFound, - AlreadyCompleted -} diff --git a/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs deleted file mode 100644 index 5f71197..0000000 --- a/src/CleanApiStarter.Api/Features/Projects/ProjectService.cs +++ /dev/null @@ -1,248 +0,0 @@ -namespace CleanApiStarter.Api.Features.Projects; - -public sealed class ProjectService( - IProjectRepository projectRepository, - IUser currentUser, - ILogger logger) : IProjectService -{ - public async Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - DateTime now = DateTime.UtcNow; - Guid projectId = Guid.NewGuid(); - Project project = new() - { - Id = projectId, - Name = projectDto.Name, - Description = projectDto.Description, - OwnerUserId = userId, - CreatedAt = now, - Members = - [ - new ProjectMember - { - ProjectId = projectId, - UserId = userId, - CreatedAt = now - } - ] - }; - - Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); - - logger.LogInformation("Created project {ProjectId} for user {UserId}", createdProjectId, userId); - - return createdProjectId; - } - - public async Task GetProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - Project? project = await projectRepository.GetProjectAsync(id, userId, cancellationToken); - - return project == null ? null : MapProject(project); - } - - public async Task> GetProjectsAsync( - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - PaginatedResult projects = await projectRepository.GetProjectsAsync(userId, query, cancellationToken); - - logger.LogInformation( - "Retrieved {ProjectCount} projects for user {UserId} with limit {Limit} and offset {Offset}", - projects.Items.Count, - userId, - projects.Limit, - projects.Offset); - - return projects.Map(MapProject); - } - - public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken); - - if (!isMember) - { - return DeleteProjectResult.NotFound; - } - - bool isOwner = await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken); - - if (!isOwner) - { - return DeleteProjectResult.Forbidden; - } - - bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); - - return deleted ? DeleteProjectResult.Deleted : DeleteProjectResult.NotFound; - } - - public async Task CreateTaskAsync( - Guid projectId, - CreateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - ProjectTask task = new() - { - Id = Guid.NewGuid(), - ProjectId = projectId, - Title = taskDto.Title, - Description = taskDto.Description, - Status = ProjectTaskStatus.Todo, - DueDate = taskDto.DueDate, - CreatedAt = DateTime.UtcNow - }; - - Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); - - logger.LogInformation("Created task {TaskId} in project {ProjectId}", taskId, projectId); - - return taskId; - } - - public async Task GetTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskAsync(projectId, taskId, userId, cancellationToken); - - return task == null ? null : MapTask(task); - } - - public async Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - PaginatedResult tasks = await projectRepository.GetTasksAsync( - projectId, - userId, - status, - query, - cancellationToken); - - return tasks.Map(MapTask); - } - - public async Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - task.Title = taskDto.Title; - task.Description = taskDto.Description; - task.DueDate = taskDto.DueDate; - task.Status = taskDto.Status; - task.CompletedAt = taskDto.Status == ProjectTaskStatus.Done - ? task.CompletedAt ?? DateTime.UtcNow - : null; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task CompleteTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - if (task.Status == ProjectTaskStatus.Done) - { - return ProjectTaskMutationResult.AlreadyCompleted; - } - - task.Status = ProjectTaskStatus.Done; - task.CompletedAt = DateTime.UtcNow; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return false; - } - - return await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); - } - - private string GetCurrentUserId() - { - return currentUser.Id ?? throw new UnauthorizedAccessException(); - } - - private static ProjectDto MapProject(Project project) - { - return new ProjectDto - { - Id = project.Id, - Name = project.Name, - Description = project.Description, - OwnerUserId = project.OwnerUserId, - CreatedAt = project.CreatedAt - }; - } - - private static ProjectTaskDto MapTask(ProjectTask task) - { - return new ProjectTaskDto - { - Id = task.Id, - ProjectId = task.ProjectId, - Title = task.Title, - Description = task.Description, - Status = task.Status, - DueDate = task.DueDate, - CreatedAt = task.CreatedAt, - CompletedAt = task.CompletedAt - }; - } -} diff --git a/src/CleanApiStarter.Api/Features/Projects/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/Projects.cs index b6f2097..1ac63f5 100644 --- a/src/CleanApiStarter.Api/Features/Projects/Projects.cs +++ b/src/CleanApiStarter.Api/Features/Projects/Projects.cs @@ -10,167 +10,16 @@ public static void Map(RouteGroupBuilder groupBuilder) { groupBuilder.RequireAuthorization(); - groupBuilder.MapPost("/", CreateProject) - .WithName("CreateProjectV1"); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV1"); - - groupBuilder.MapGet("/{id:guid}", GetProject) - .WithName("GetProjectV1"); - - groupBuilder.MapDelete("/{id:guid}", DeleteProject) - .WithName("DeleteProjectV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks", CreateTask) - .WithName("CreateProjectTaskV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks", GetTasks) - .WithName("GetProjectTasksV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks/{taskId:guid}", GetTask) - .WithName("GetProjectTaskV1"); - - groupBuilder.MapPut("/{projectId:guid}/tasks/{taskId:guid}", UpdateTask) - .WithName("UpdateProjectTaskV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", CompleteTask) - .WithName("CompleteProjectTaskV1"); - - groupBuilder.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", DeleteTask) - .WithName("DeleteProjectTaskV1"); - } - - private static async Task CreateProject( - CreateProjectDto projectDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid id = await projectService.CreateProjectAsync(projectDto, cancellationToken); - - return Results.Created($"/api/projects/{id}", id); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(projects); - } - - private static async Task GetProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectDto? project = await projectService.GetProjectAsync(id, cancellationToken); - - return project == null ? Results.NotFound() : Results.Ok(project); - } - - private static async Task DeleteProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - DeleteProjectResult result = await projectService.DeleteProjectAsync(id, cancellationToken); - - return result switch - { - DeleteProjectResult.Deleted => Results.NoContent(), - DeleteProjectResult.Forbidden => Results.Forbid(), - _ => Results.NotFound() - }; - } - - private static async Task CreateTask( - Guid projectId, - CreateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid? taskId = await projectService.CreateTaskAsync(projectId, taskDto, cancellationToken); - - return taskId == null - ? Results.NotFound() - : Results.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); - } - - private static async Task GetTasks( - Guid projectId, - [FromQuery] ProjectTaskStatus? status, - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult? tasks = await projectService.GetTasksAsync( - projectId, - status, - query, - cancellationToken); - - return tasks == null ? Results.NotFound() : Results.Ok(tasks); - } - - private static async Task GetTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskDto? task = await projectService.GetTaskAsync(projectId, taskId, cancellationToken); - - return task == null ? Results.NotFound() : Results.Ok(task); - } - - private static async Task UpdateTask( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.UpdateTaskAsync( - projectId, - taskId, - taskDto, - cancellationToken); - - return result == ProjectTaskMutationResult.Success - ? Results.NoContent() - : Results.NotFound(); - } - - private static async Task CompleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.CompleteTaskAsync(projectId, taskId, cancellationToken); - - return result switch - { - ProjectTaskMutationResult.Success => Results.NoContent(), - ProjectTaskMutationResult.AlreadyCompleted => Results.Conflict(new - { - detail = "Task is already completed." - }), - _ => Results.NotFound() - }; - } - - private static async Task DeleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - bool deleted = await projectService.DeleteTaskAsync(projectId, taskId, cancellationToken); - - return deleted ? Results.NoContent() : Results.NotFound(); + CreateProject.Map(groupBuilder); + GetProjects.Map(groupBuilder); + GetProject.Map(groupBuilder); + DeleteProject.Map(groupBuilder); + + CreateTask.Map(groupBuilder); + GetTasks.Map(groupBuilder); + GetTask.Map(groupBuilder); + UpdateTask.Map(groupBuilder); + CompleteTask.Map(groupBuilder); + DeleteTask.Map(groupBuilder); } } diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs new file mode 100644 index 0000000..909cd93 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs @@ -0,0 +1,41 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class CompleteTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", Handle) + .WithName("CompleteProjectTaskV1"); + } + + public static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + if (task is null) + { + return TypedResults.NotFound(); + } + + if (task.Status == ProjectTaskStatus.Done) + { + return TypedResults.Conflict("Task is already completed."); + } + + task.Status = ProjectTaskStatus.Done; + task.CompletedAt = DateTime.UtcNow; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return TypedResults.NoContent(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs deleted file mode 100644 index 621bc61..0000000 --- a/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CleanApiStarter.Api.Features.Projects.Tasks; - -public sealed class CreateProjectTaskDtoValidator : AbstractValidator -{ - public CreateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.DueDate) - .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue) - .WithMessage("Due date must be in the future."); - } -} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs new file mode 100644 index 0000000..aeb9cda --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs @@ -0,0 +1,60 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class CreateTask +{ + public sealed record Request(string Title, string Description, DateTime? DueDate); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(request => request.Description) + .MaximumLength(4_000); + + RuleFor(request => request.DueDate) + .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) + .When(request => request.DueDate.HasValue) + .WithMessage("Due date must be in the future."); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/{projectId:guid}/tasks", Handle) + .WithName("CreateProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + ProjectTask task = new() + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = request.Title, + Description = request.Description, + Status = ProjectTaskStatus.Todo, + DueDate = request.DueDate, + CreatedAt = DateTime.UtcNow + }; + + Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); + + return TypedResults.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs new file mode 100644 index 0000000..2d88ec0 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs @@ -0,0 +1,29 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class DeleteTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("DeleteProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + bool deleted = await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); + + return deleted ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs new file mode 100644 index 0000000..a986de3 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs @@ -0,0 +1,28 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class GetTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("GetProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + return task is null + ? TypedResults.NotFound() + : TypedResults.Ok(ProjectTaskDto.From(task)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs new file mode 100644 index 0000000..1c49ac7 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class GetTasks +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{projectId:guid}/tasks", Handle) + .WithName("GetProjectTasksV1"); + } + + private static async Task Handle( + Guid projectId, + [FromQuery] ProjectTaskStatus? status, + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + PaginatedResult tasks = await projectRepository.GetTasksAsync( + projectId, + userId, + status, + query, + cancellationToken); + + return TypedResults.Ok(tasks.Map(ProjectTaskDto.From)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs index fb754a7..1c10d90 100644 --- a/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs @@ -17,24 +17,19 @@ public sealed class ProjectTaskDto public required DateTime CreatedAt { get; init; } public DateTime? CompletedAt { get; init; } -} - -public sealed class CreateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public DateTime? DueDate { get; init; } -} -public sealed class UpdateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } + public static ProjectTaskDto From(ProjectTask task) + { + return new ProjectTaskDto + { + Id = task.Id, + ProjectId = task.ProjectId, + Title = task.Title, + Description = task.Description, + Status = task.Status, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt + }; + } } diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs deleted file mode 100644 index 81df0b6..0000000 --- a/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CleanApiStarter.Api.Features.Projects.Tasks; - -public sealed class UpdateProjectTaskDtoValidator : AbstractValidator -{ - public UpdateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.Status) - .IsInEnum(); - - RuleFor(task => task.DueDate) - .Must((task, dueDate) => !dueDate.HasValue - || task.Status == ProjectTaskStatus.Done - || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) - .WithMessage("Due date must be in the future."); - } -} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs new file mode 100644 index 0000000..df42df1 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs @@ -0,0 +1,67 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class UpdateTask +{ + public sealed record Request(string Title, string Description, ProjectTaskStatus Status, DateTime? DueDate); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(request => request.Description) + .MaximumLength(4_000); + + RuleFor(request => request.Status) + .IsInEnum(); + + RuleFor(request => request.DueDate) + .Must((request, dueDate) => !dueDate.HasValue + || request.Status == ProjectTaskStatus.Done + || dueDate.Value > DateTime.UtcNow) + .When(request => request.DueDate.HasValue && request.Status != ProjectTaskStatus.Done) + .WithMessage("Due date must be in the future."); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPut("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("UpdateProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + if (task is null) + { + return TypedResults.NotFound(); + } + + task.Title = request.Title; + task.Description = request.Description; + task.DueDate = request.DueDate; + task.Status = request.Status; + task.CompletedAt = request.Status == ProjectTaskStatus.Done + ? task.CompletedAt ?? DateTime.UtcNow + : null; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return TypedResults.NoContent(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs new file mode 100644 index 0000000..7728936 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.V2; + +public static class GetProjects +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/", Handle) + .WithName("GetProjectsV2"); + } + + private static async Task Handle( + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectRepository.GetProjectsAsync( + currentUser.RequireId(), + query, + cancellationToken); + + PaginatedResult page = projects.Map(ProjectDto.From); + + return TypedResults.Ok(new + { + ApiVersion = "2.0", + page.Items, + page.Limit, + page.Offset, + page.TotalCount, + page.HasPreviousPage, + page.HasNextPage + }); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs index 5cfd4bf..e751908 100644 --- a/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs +++ b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs @@ -10,26 +10,6 @@ public static void Map(RouteGroupBuilder groupBuilder) { groupBuilder.RequireAuthorization(); - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV2"); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(new - { - ApiVersion = "2.0", - projects.Items, - projects.Limit, - projects.Offset, - projects.TotalCount, - projects.HasPreviousPage, - projects.HasNextPage - }); + GetProjects.Map(groupBuilder); } } diff --git a/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs index 17d3c13..9c15b69 100644 --- a/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs @@ -8,9 +8,9 @@ public sealed class GoogleAuthService( private const string GoogleLoginProvider = "Google"; private const string DefaultRole = "User"; - public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken) + public async Task SignInWithGoogleAsync(string idToken, CancellationToken cancellationToken) { - GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken); + GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(idToken); ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken); IList roles = await userManager.GetRolesAsync(user); DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes); diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj index 7b7ad99..2e6a957 100644 --- a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs similarity index 67% rename from tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs rename to tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs index b39323a..380ac3c 100644 --- a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs +++ b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs @@ -1,16 +1,15 @@ -namespace CleanApiStarter.Api.UnitTests.Features.Projects; +namespace CleanApiStarter.Api.UnitTests.Features.Projects.Tasks; -public sealed class ProjectServiceTests +public sealed class CompleteTaskTests { [Theory] [AutoNSubstituteData] - public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyCompleted( + public async Task Handle_TaskIsAlreadyCompleted_ReturnsConflictAndDoesNotSave( Guid projectId, Guid taskId, string userId, [Frozen] IUser currentUser, - [Frozen] IProjectRepository projectRepository, - ProjectService sut) + [Frozen] IProjectRepository projectRepository) { // Arrange CancellationToken cancellationToken = CancellationToken.None; @@ -31,10 +30,10 @@ public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyComplet .Returns(Task.FromResult(task)); // Act - ProjectTaskMutationResult result = await sut.CompleteTaskAsync(projectId, taskId, cancellationToken); + IResult result = await CompleteTask.Handle(projectId, taskId, projectRepository, currentUser, cancellationToken); // Assert - result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); + result.ShouldBeOfType>(); _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); } } diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs index 1faa155..06bc60d 100644 --- a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs @@ -3,9 +3,11 @@ global using CleanApiStarter.Api.Common.Interfaces; global using CleanApiStarter.Api.Domain.Entities; global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Features.Projects.Tasks; global using CleanApiStarter.Tests.Common; -global using Microsoft.Extensions.Logging; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.HttpResults; global using NSubstitute; From 5482b93096d4cde4490fefb2dd2e20bd70e8b3c2 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 12:10:01 +0800 Subject: [PATCH 12/18] docs: update README layout for full vertical slices Features is now one self-contained file per operation (endpoint + request + validator + handler); fix the composition-root bullet (no Endpoints folder). --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 98e5fd3..baa45a5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ CleanApiStarter │ │ ├── config.nsdepcop ← enforced dependency rules │ │ ├── Common ← shared kernel (paged results, IUser) │ │ ├── Domain ← entities (no outward dependencies) -│ │ ├── Features ← Auth, Projects/Tasks (endpoints + DTOs + validators per feature) +│ │ ├── Features ← Auth, Projects/Tasks — one file per operation +│ │ │ (endpoint + request + validator + handler) │ │ ├── Infrastructure ← DbContext, EF config, repositories, identity │ │ ├── Configuration ← settings classes + options registration │ │ ├── Services ← composition / web wiring (CurrentUser) @@ -85,9 +86,11 @@ CleanApiStarter Dependency direction (enforced by `config.nsdepcop`): - `Domain` depends on nothing else in the app — not `Features`, not `Infrastructure`. -- `Features` (application logic) may not depend on `Infrastructure`; it talks to - abstractions that `Infrastructure` implements and DI wires up. -- `Infrastructure`, `Endpoints`, `Configuration`, and `Program.cs` form the +- `Features` holds one self-contained slice per operation (endpoint + request + + validator + handler). A handler may not depend on `Infrastructure`; it talks to + abstractions (`IProjectRepository`, `IAuthService`) that `Infrastructure` + implements and DI wires up. +- `Infrastructure`, `Configuration`, `Services`, and `Program.cs` form the composition root. - `AspNetCoreDefaults` is an application-agnostic platform project — it holds no knowledge of the application's settings; the Api binds configuration-dependent From 0928014fe428361f7919a4c0692f2749da698534 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 12:10:45 +0800 Subject: [PATCH 13/18] docs: describe single-solution NsDepCop enforcement (drop stale two-variant framing) --- .../clean-architecture-and-vertical-slices.md | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/docs/architecture/clean-architecture-and-vertical-slices.md b/docs/architecture/clean-architecture-and-vertical-slices.md index f7a2d56..e045bd2 100644 --- a/docs/architecture/clean-architecture-and-vertical-slices.md +++ b/docs/architecture/clean-architecture-and-vertical-slices.md @@ -86,25 +86,18 @@ defined once. ## How the boundaries are enforced Dependency rules are only real if something *fails the build* when they are -broken. The two variants enforce the same rule with different mechanisms: - -- **`layered` (multi-project):** enforced by **project references**. `Domain` - has no reference to `Infrastructure`, so a violating `using` simply does not - compile. This is the strongest possible enforcement and comes "for free" from - the project graph — at the cost of more projects and more ceremony. - -- **`modular` (single app project):** enforced by **[NsDepCop](https://github.com/realvizu/NsDepCop)**, - a Roslyn analyzer that polices *namespace* dependencies at compile time. A - declarative `config.nsdepcop` lists illegal directions, and - `NSDEPCOP01` in the csproj turns a - violation into a build-breaking error. This recreates the layered guarantee - inside one project. The ruleset is default-allow, then blacklists the few - inward-violating directions: - - ```xml - - - ``` +broken. CleanApiStarter enforces them with **[NsDepCop](https://github.com/realvizu/NsDepCop)**, +a Roslyn analyzer that polices *namespace* dependencies at compile time. A +declarative `config.nsdepcop` lists the illegal directions, and +`NSDEPCOP01` in the csproj turns a violation +into a build-breaking error — recreating inside a single project the guarantee +that separate projects would otherwise give through project references. The +ruleset is default-allow, then blacklists the inward-violating directions: + +```xml + + +``` ## Two ways to enforce the same rule From f089ebc069514b97b120a88ccb31aed7d252a5b6 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 21:13:24 +0800 Subject: [PATCH 14/18] docs: add SKILLS.md how-to playbook (replaces AGENTS.md) Task-oriented recipes (add an endpoint/feature/migration) with copy-pasteable slice templates, instead of a descriptive auto-loaded AGENTS.md. Not an auto-load filename, so it costs no idle context tokens. Linked from CONTRIBUTING. --- CONTRIBUTING.md | 3 ++ SKILLS.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 SKILLS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcf2707..ed4b1b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,9 @@ Alternatively, start PostgreSQL with `docker compose up -d` and run 2. Keep changes focused; unrelated refactoring belongs in its own PR. 3. Open a pull request against `main`. CI must pass before review. +For step-by-step recipes (add an endpoint, a feature, or a migration), see +[SKILLS.md](SKILLS.md). + ### What CI enforces The same checks run on every PR ([build.yml](.github/workflows/build.yml), diff --git a/SKILLS.md b/SKILLS.md new file mode 100644 index 0000000..ec25988 --- /dev/null +++ b/SKILLS.md @@ -0,0 +1,85 @@ +# Skills — How-To Playbook + +Task recipes for working in this codebase. Unlike an `AGENTS.md` / `CLAUDE.md`, +this file is **not auto-loaded** into agent context — open the recipe you need when +you need it. Keep entries short and example-first. + +The app is a single `CleanApiStarter.Api` project organised as **vertical slices**: +one file per operation, co-locating its request, validator, endpoint, and handler. +Dependency boundaries are enforced at build time by NsDepCop +(`src/CleanApiStarter.Api/config.nsdepcop`). + +## Add an endpoint to an existing feature + +1. Add one file per operation in the feature folder, e.g. + `Features/Projects/ArchiveProject.cs`: + + ```csharp + namespace CleanApiStarter.Api.Features.Projects; + + public static class ArchiveProject + { + public sealed record Request(string Reason); // omit if no body + + public sealed class Validator : AbstractValidator + { + public Validator() => RuleFor(request => request.Reason).NotEmpty(); + } + + public static void Map(RouteGroupBuilder group) => + group.MapPost("/{id:guid}/archive", Handle).WithName("ArchiveProjectV1"); + + private static async Task Handle( + Guid id, + Request request, + IProjectRepository projectRepository, // an interface — never an Infrastructure type + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + // orchestrate via the repository, then return a typed result + return TypedResults.NoContent(); + } + } + ``` + +2. Register it in the feature's endpoint group `Map` + (`Features/Projects/Projects.cs`): `ArchiveProject.Map(groupBuilder);` + +3. If the handler needs new data access, add a method to `IProjectRepository` + (in `Features/Projects/`) and implement it in + `Infrastructure/Repositories/ProjectRepository.cs`. + +Conventions: + +- **Handlers depend only on interfaces** (`IProjectRepository`, `IAuthService`), + never on `Infrastructure` types — NsDepCop breaks the build otherwise. +- Validators are auto-registered (`AddValidatorsFromAssembly`) and run by the + global `ValidationFilter`, returning `422` on failure. +- Return `TypedResults.*` (`Ok` / `Created` / `NoContent` / `NotFound` / + `Conflict` / `Forbid`) directly — no result-enum indirection. +- Map entities to DTOs with a static `From(...)` factory on the DTO. + +## Add a new feature (capability) + +1. Create `Features//`. +2. Add a thin endpoint group implementing `IEndpointGroup` (`RoutePrefix`, + `MajorVersion`, and `Map` delegating to each slice's `Map`). It is discovered by + reflection — no manual registration needed. +3. Add slices as above. Put a nested resource in a sub-folder (e.g. + `Projects/Tasks/`); a new API version in a `V2/` sub-folder. + +## Add a database change + +1. Add `database/migrations/V__description.sql` (idempotent; + `TIMESTAMP WITH TIME ZONE`, values in UTC). +2. Keep the matching EF config in + `Infrastructure/Persistence/Configuration/*Configuration.cs` in sync by hand. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet format CleanApiStarter.slnx --verify-no-changes +dotnet test CleanApiStarter.slnx # Docker required for the integration tests +``` From 342c91da250961e6eb9951fad1ad1cd9cf6f6822 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 21:58:32 +0800 Subject: [PATCH 15/18] feat: add project Agent Skills (.claude/skills), replace SKILLS.md doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Claude Code project skills: - /add-endpoint — add an API operation as a vertical slice (path-scoped to Features/) - /add-migration — add a schema change + keep EF config in sync (path-scoped to database + persistence) Each is auto-loaded when relevant (paths frontmatter) or invokable directly. The skills ship inside generated projects; .claude/settings.local.json is git-ignored and excluded from the packed template. Remove the plain SKILLS.md doc (superseded); CONTRIBUTING now points at the skills. --- .claude/skills/add-endpoint/SKILL.md | 85 +++++++++++++++++++++++++++ .claude/skills/add-migration/SKILL.md | 39 ++++++++++++ .gitignore | 3 + .template.config/template.json | 1 + CONTRIBUTING.md | 6 +- CleanApiStarter.Template.csproj | 2 +- SKILLS.md | 85 --------------------------- 7 files changed, 133 insertions(+), 88 deletions(-) create mode 100644 .claude/skills/add-endpoint/SKILL.md create mode 100644 .claude/skills/add-migration/SKILL.md delete mode 100644 SKILLS.md diff --git a/.claude/skills/add-endpoint/SKILL.md b/.claude/skills/add-endpoint/SKILL.md new file mode 100644 index 0000000..4c244bf --- /dev/null +++ b/.claude/skills/add-endpoint/SKILL.md @@ -0,0 +1,85 @@ +--- +name: add-endpoint +description: Add or change an API endpoint in this codebase as a vertical slice — one file per operation co-locating its request, validator, endpoint mapping, and handler. Use when adding an endpoint, operation, or whole feature to the CleanApiStarter API. +paths: src/CleanApiStarter.Api/Features/** +--- + +# Add an API endpoint (vertical slice) + +This API is a single `CleanApiStarter.Api` project organised as **vertical slices**: +one file per operation. Boundaries are enforced at build time by NsDepCop +(`src/CleanApiStarter.Api/config.nsdepcop`) — a handler must not reference an +`Infrastructure` type or the build fails. + +## Add an operation to an existing feature + +1. Create one file per operation in the feature folder, e.g. + `src/CleanApiStarter.Api/Features/Projects/ArchiveProject.cs`: + + ```csharp + namespace CleanApiStarter.Api.Features.Projects; + + public static class ArchiveProject + { + public sealed record Request(string Reason); // omit if no body + + public sealed class Validator : AbstractValidator + { + public Validator() => RuleFor(request => request.Reason).NotEmpty(); + } + + public static void Map(RouteGroupBuilder group) => + group.MapPost("/{id:guid}/archive", Handle).WithName("ArchiveProjectV1"); + + private static async Task Handle( + Guid id, + Request request, + IProjectRepository projectRepository, // an interface — never an Infrastructure type + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + // orchestrate via the repository, then return a typed result + return TypedResults.NoContent(); + } + } + ``` + +2. Register the slice in the feature's endpoint group `Map` + (`Features/Projects/Projects.cs`): add `ArchiveProject.Map(groupBuilder);`. + +3. If the handler needs new data access, add a method to `IProjectRepository` + (`Features/Projects/IProjectRepository.cs`) and implement it in + `Infrastructure/Repositories/ProjectRepository.cs`. + +## Add a new feature (capability) + +1. Create `Features//`. +2. Add a thin endpoint group implementing `IEndpointGroup` — it sets `RoutePrefix`, + `MajorVersion`, and a `Map` that calls each slice's `Map`. It is discovered by + reflection; no manual registration needed. +3. Add slices as above. Put a nested resource in a sub-folder (e.g. + `Projects/Tasks/`) and a new API version in a `V2/` sub-folder. + +## Conventions + +- **Handlers depend only on interfaces** (`IProjectRepository`, `IAuthService`), + never on `Infrastructure` types. NsDepCop (`Features ✗→ Infrastructure`) breaks + the build otherwise. +- Validators are auto-registered (`AddValidatorsFromAssembly`) and run by the global + `ValidationFilter`, returning `422` on failure. Just add a nested + `Validator : AbstractValidator`. +- Return `TypedResults.*` (`Ok` / `Created` / `NoContent` / `NotFound` / + `Conflict` / `Forbid`) directly — there is no result-enum indirection. +- Map entities to DTOs with a static `From(...)` factory on the DTO + (e.g. `ProjectDto.From(project)`); for pages use `result.Map(ProjectDto.From)`. +- Group-level auth: the `Projects` group calls `RequireAuthorization()`; set + `.AllowAnonymous()` / `.RequireAuthorization()` per route when it differs. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet format CleanApiStarter.slnx --verify-no-changes +dotnet test CleanApiStarter.slnx # Docker required for the integration tests +``` diff --git a/.claude/skills/add-migration/SKILL.md b/.claude/skills/add-migration/SKILL.md new file mode 100644 index 0000000..f6dd172 --- /dev/null +++ b/.claude/skills/add-migration/SKILL.md @@ -0,0 +1,39 @@ +--- +name: add-migration +description: Add or change the PostgreSQL schema in this codebase. Use when adding or modifying a database table, column, index, or migration in CleanApiStarter, including keeping the EF Core entity configuration in sync. +paths: database/migrations/**, src/CleanApiStarter.Api/Infrastructure/Persistence/** +--- + +# Add a database change + +`database/migrations` is the schema's single source of truth. There are no EF +migrations; the EF Core entity configurations must be kept in sync with the SQL +by hand. + +## Steps + +1. Add `database/migrations/V__short_description.sql`, numbered after the + latest file. Migrations are applied in file-name order by both the Aspire + AppHost and the integration test factory, so they must be **idempotent** + (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, …). + +2. Update the matching EF config in + `src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/*Configuration.cs` + (an `IEntityTypeConfiguration`), and the entity in + `src/CleanApiStarter.Api/Domain/Entities/` if columns changed. + +## Conventions + +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC + (`DateTime.UtcNow` in code). Do not use plain `TIMESTAMP`. +- Identity tables live in `V002__create_identity_tables.sql`; application tables in + `V001`. Keep new application tables in their own `V` file. +- Entities are persistence-agnostic POCOs; mapping details (column names, lengths, + relationships, delete behaviour) belong in the EF configuration, not the entity. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet test CleanApiStarter.slnx # integration tests apply the scripts to a real container +``` diff --git a/.gitignore b/.gitignore index 0e2fcbc..aa9d4f9 100644 --- a/.gitignore +++ b/.gitignore @@ -403,3 +403,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .DS_Store + +# Claude Code local settings (skills under .claude/skills/ are tracked) +.claude/settings.local.json diff --git a/.template.config/template.json b/.template.config/template.json index cbade50..daf08a7 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -38,6 +38,7 @@ "**/.DS_Store", "**/bin/**", "**/obj/**", + ".claude/settings.local.json", "artifacts/**", ".github/workflows/release.yml", ".github/workflows/template.yml", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed4b1b9..2406786 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,8 +39,10 @@ Alternatively, start PostgreSQL with `docker compose up -d` and run 2. Keep changes focused; unrelated refactoring belongs in its own PR. 3. Open a pull request against `main`. CI must pass before review. -For step-by-step recipes (add an endpoint, a feature, or a migration), see -[SKILLS.md](SKILLS.md). +Working in Claude Code? This repo ships project skills in +[`.claude/skills/`](.claude/skills/) — run `/add-endpoint` or `/add-migration` +(or let Claude apply them automatically) for step-by-step, convention-correct +recipes. ### What CI enforces diff --git a/CleanApiStarter.Template.csproj b/CleanApiStarter.Template.csproj index 8325b7d..d82b175 100644 --- a/CleanApiStarter.Template.csproj +++ b/CleanApiStarter.Template.csproj @@ -24,7 +24,7 @@ diff --git a/SKILLS.md b/SKILLS.md deleted file mode 100644 index ec25988..0000000 --- a/SKILLS.md +++ /dev/null @@ -1,85 +0,0 @@ -# Skills — How-To Playbook - -Task recipes for working in this codebase. Unlike an `AGENTS.md` / `CLAUDE.md`, -this file is **not auto-loaded** into agent context — open the recipe you need when -you need it. Keep entries short and example-first. - -The app is a single `CleanApiStarter.Api` project organised as **vertical slices**: -one file per operation, co-locating its request, validator, endpoint, and handler. -Dependency boundaries are enforced at build time by NsDepCop -(`src/CleanApiStarter.Api/config.nsdepcop`). - -## Add an endpoint to an existing feature - -1. Add one file per operation in the feature folder, e.g. - `Features/Projects/ArchiveProject.cs`: - - ```csharp - namespace CleanApiStarter.Api.Features.Projects; - - public static class ArchiveProject - { - public sealed record Request(string Reason); // omit if no body - - public sealed class Validator : AbstractValidator - { - public Validator() => RuleFor(request => request.Reason).NotEmpty(); - } - - public static void Map(RouteGroupBuilder group) => - group.MapPost("/{id:guid}/archive", Handle).WithName("ArchiveProjectV1"); - - private static async Task Handle( - Guid id, - Request request, - IProjectRepository projectRepository, // an interface — never an Infrastructure type - IUser currentUser, - CancellationToken cancellationToken) - { - string userId = currentUser.RequireId(); - // orchestrate via the repository, then return a typed result - return TypedResults.NoContent(); - } - } - ``` - -2. Register it in the feature's endpoint group `Map` - (`Features/Projects/Projects.cs`): `ArchiveProject.Map(groupBuilder);` - -3. If the handler needs new data access, add a method to `IProjectRepository` - (in `Features/Projects/`) and implement it in - `Infrastructure/Repositories/ProjectRepository.cs`. - -Conventions: - -- **Handlers depend only on interfaces** (`IProjectRepository`, `IAuthService`), - never on `Infrastructure` types — NsDepCop breaks the build otherwise. -- Validators are auto-registered (`AddValidatorsFromAssembly`) and run by the - global `ValidationFilter`, returning `422` on failure. -- Return `TypedResults.*` (`Ok` / `Created` / `NoContent` / `NotFound` / - `Conflict` / `Forbid`) directly — no result-enum indirection. -- Map entities to DTOs with a static `From(...)` factory on the DTO. - -## Add a new feature (capability) - -1. Create `Features//`. -2. Add a thin endpoint group implementing `IEndpointGroup` (`RoutePrefix`, - `MajorVersion`, and `Map` delegating to each slice's `Map`). It is discovered by - reflection — no manual registration needed. -3. Add slices as above. Put a nested resource in a sub-folder (e.g. - `Projects/Tasks/`); a new API version in a `V2/` sub-folder. - -## Add a database change - -1. Add `database/migrations/V__description.sql` (idempotent; - `TIMESTAMP WITH TIME ZONE`, values in UTC). -2. Keep the matching EF config in - `Infrastructure/Persistence/Configuration/*Configuration.cs` in sync by hand. - -## Verify - -```bash -dotnet build CleanApiStarter.slnx -dotnet format CleanApiStarter.slnx --verify-no-changes -dotnet test CleanApiStarter.slnx # Docker required for the integration tests -``` From f0df2313840700680222400dfa6b9ea5b9ef3dcc Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 22:09:12 +0800 Subject: [PATCH 16/18] refactor: inline validator registration, rename infrastructure DI class The AddApplication extension did nothing but register validators after the slice refactor, so inline that single call into Program.cs and delete the file. Rename the infrastructure DI holder from the generic DependencyInjection to InfrastructureServiceCollectionExtensions (method AddInfrastructure unchanged). --- src/CleanApiStarter.Api/DependencyInjection.cs | 11 ----------- ...s => InfrastructureServiceCollectionExtensions.cs} | 2 +- src/CleanApiStarter.Api/Program.cs | 2 +- .../Features/Projects/Tasks/CompleteTaskTests.cs | 2 +- 4 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 src/CleanApiStarter.Api/DependencyInjection.cs rename src/CleanApiStarter.Api/Infrastructure/{DependencyInjection.cs => InfrastructureServiceCollectionExtensions.cs} (92%) diff --git a/src/CleanApiStarter.Api/DependencyInjection.cs b/src/CleanApiStarter.Api/DependencyInjection.cs deleted file mode 100644 index cf5dc70..0000000 --- a/src/CleanApiStarter.Api/DependencyInjection.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CleanApiStarter.Api; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplication(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - - return services; - } -} diff --git a/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Api/Infrastructure/InfrastructureServiceCollectionExtensions.cs similarity index 92% rename from src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs rename to src/CleanApiStarter.Api/Infrastructure/InfrastructureServiceCollectionExtensions.cs index e853d8a..a23d439 100644 --- a/src/CleanApiStarter.Api/Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Api/Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ namespace CleanApiStarter.Api.Infrastructure; -public static class DependencyInjection +public static class InfrastructureServiceCollectionExtensions { public static IServiceCollection AddInfrastructure(this IServiceCollection services) { diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 3dc49e0..c36aa07 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -4,7 +4,7 @@ builder.Services.AddAppSettings(builder.Configuration); builder.Services.AddJwtBearerOptions(); -builder.Services.AddApplication(); +builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); builder.Services.AddInfrastructure(); builder.Services.AddScoped(); diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs index 380ac3c..3d2b424 100644 --- a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs +++ b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.UnitTests.Features.Projects.Tasks; +namespace CleanApiStarter.Application.UnitTests.Features.Projects.Tasks; public sealed class CompleteTaskTests { From 8d7632b7ea5ca3a2081db9e512bcd3b237f9b4d6 Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 22:12:42 +0800 Subject: [PATCH 17/18] refactor: standardize extension class names on Extensions Rename InfrastructureServiceCollectionExtensions -> InfrastructureExtensions and the generic AspNetCoreDefaults 'Extensions' partial -> AspNetCoreDefaultsExtensions (incl. the typeof reference in MapDefaultEndpoints). Extension method names are unchanged. Now every holder is Extensions: Infrastructure, AspNetCoreDefaults, WebApplication, OpenApiDocumentation, OptionsRegistration, User. --- ...iceCollectionExtensions.cs => InfrastructureExtensions.cs} | 2 +- .../AspNetCoreDefaultServices.cs | 2 +- .../{Extensions.cs => AspNetCoreDefaultsExtensions.cs} | 4 ++-- .../OpenTelemetryDefaults.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/CleanApiStarter.Api/Infrastructure/{InfrastructureServiceCollectionExtensions.cs => InfrastructureExtensions.cs} (92%) rename src/CleanApiStarter.AspNetCoreDefaults/{Extensions.cs => AspNetCoreDefaultsExtensions.cs} (95%) diff --git a/src/CleanApiStarter.Api/Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs similarity index 92% rename from src/CleanApiStarter.Api/Infrastructure/InfrastructureServiceCollectionExtensions.cs rename to src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs index a23d439..df3c8f9 100644 --- a/src/CleanApiStarter.Api/Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs @@ -1,6 +1,6 @@ namespace CleanApiStarter.Api.Infrastructure; -public static class InfrastructureServiceCollectionExtensions +public static class InfrastructureExtensions { public static IServiceCollection AddInfrastructure(this IServiceCollection services) { diff --git a/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs index 09b47fd..12a2a39 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs @@ -2,7 +2,7 @@ namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { private static void AddSecurityDefaults(this IHostApplicationBuilder builder) { diff --git a/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs similarity index 95% rename from src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs index e00e1a0..144f138 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/Extensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs @@ -1,6 +1,6 @@ namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder) { @@ -46,7 +46,7 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) { app.MapGet("/version", () => { - Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly; + Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(AspNetCoreDefaultsExtensions).Assembly; AssemblyInformationalVersionAttribute? informationalVersion = assembly .GetCustomAttribute(); diff --git a/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs index f9a770c..21c557c 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs @@ -1,6 +1,6 @@ namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { From c5f8522efbcbf0c8554f66c6fa9ebadba293e9eb Mon Sep 17 00:00:00 2001 From: cbjpdev Date: Sun, 21 Jun 2026 22:47:00 +0800 Subject: [PATCH 18/18] refactor(tests): rename test projects by type, not layer - CleanApiStarter.Application.UnitTests -> CleanApiStarter.UnitTests - CleanApiStarter.Api.IntegrationTests -> CleanApiStarter.IntegrationTests - CleanApiStarter.Tests (shared) -> CleanApiStarter.TestUtilities No more layer qualifier now that the app is a single project (matches the type-based convention: UnitTests / IntegrationTests / shared utilities). Updates csproj names, namespaces, project references, the .slnx, README, and CONTRIBUTING. The JWT issuer/audience string literals are unchanged. --- CONTRIBUTING.md | 6 +++--- CleanApiStarter.slnx | 6 +++--- README.md | 6 +++--- .../CleanApiStarter.IntegrationTests.csproj} | 6 +++--- .../Features/Projects/ProjectsTests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../appsettings.Testing.json | 0 .../CleanApiStarter.TestUtilities.csproj} | 4 ++-- .../Common/ApiApplicationFactory.cs | 2 +- .../Common/AutoNSubstituteDataAttribute.cs | 2 +- .../GlobalUsings.cs | 0 .../CleanApiStarter.UnitTests.csproj} | 6 +++--- .../Features/Projects/Tasks/CompleteTaskTests.cs | 2 +- .../GlobalUsings.cs | 2 +- 14 files changed, 23 insertions(+), 23 deletions(-) rename tests/{CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj => CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj} (77%) rename tests/{CleanApiStarter.Api.IntegrationTests => CleanApiStarter.IntegrationTests}/Features/Projects/ProjectsTests.cs (95%) rename tests/{CleanApiStarter.Api.IntegrationTests => CleanApiStarter.IntegrationTests}/GlobalUsings.cs (73%) rename tests/{CleanApiStarter.Api.IntegrationTests => CleanApiStarter.IntegrationTests}/appsettings.Testing.json (100%) rename tests/{CleanApiStarter.Tests/CleanApiStarter.Tests.csproj => CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj} (85%) rename tests/{CleanApiStarter.Tests => CleanApiStarter.TestUtilities}/Common/ApiApplicationFactory.cs (98%) rename tests/{CleanApiStarter.Tests => CleanApiStarter.TestUtilities}/Common/AutoNSubstituteDataAttribute.cs (84%) rename tests/{CleanApiStarter.Tests => CleanApiStarter.TestUtilities}/GlobalUsings.cs (100%) rename tests/{CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj => CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj} (80%) rename tests/{CleanApiStarter.Application.UnitTests => CleanApiStarter.UnitTests}/Features/Projects/Tasks/CompleteTaskTests.cs (94%) rename tests/{CleanApiStarter.Application.UnitTests => CleanApiStarter.UnitTests}/GlobalUsings.cs (88%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2406786..109158d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,12 +61,12 @@ The same checks run on every PR ([build.yml](.github/workflows/build.yml), ## Tests -- Unit tests: `tests/CleanApiStarter.Application.UnitTests` (xUnit v3, AutoFixture, +- Unit tests: `tests/CleanApiStarter.UnitTests` (xUnit v3, AutoFixture, NSubstitute, Shouldly). -- Integration tests: `tests/CleanApiStarter.Api.IntegrationTests` (xUnit v3 +- Integration tests: `tests/CleanApiStarter.IntegrationTests` (xUnit v3 against a real PostgreSQL container; Docker must be running). - Shared test infrastructure (the `ApiApplicationFactory` class fixture) lives in - `tests/CleanApiStarter.Tests`. + `tests/CleanApiStarter.TestUtilities`. New behavior needs tests. The integration test factory is an `IClassFixture`, so all tests in a class share one database — isolate through unique user ids or diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index 25ba3a9..668c2dd 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -38,8 +38,8 @@ - - - + + + diff --git a/README.md b/README.md index baa45a5..72cdd28 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,9 @@ CleanApiStarter │ ├── CleanApiStarter.AppHost ← Aspire orchestration │ └── CleanApiStarter.AspNetCoreDefaults ← reusable, app-agnostic web/runtime defaults └── tests - ├── CleanApiStarter.Api.IntegrationTests - ├── CleanApiStarter.Application.UnitTests - └── CleanApiStarter.Tests + ├── CleanApiStarter.UnitTests + ├── CleanApiStarter.IntegrationTests + └── CleanApiStarter.TestUtilities ← shared test factory + attributes ``` Dependency direction (enforced by `config.nsdepcop`): diff --git a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj similarity index 77% rename from tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj rename to tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj index 275bbf0..2bd0893 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +++ b/tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj @@ -1,8 +1,8 @@  - CleanApiStarter.Api.IntegrationTests - CleanApiStarter.Api.IntegrationTests + CleanApiStarter.IntegrationTests + CleanApiStarter.IntegrationTests @@ -15,7 +15,7 @@ - + diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs similarity index 95% rename from tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs rename to tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs index bc17707..43e8a74 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs +++ b/tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; +namespace CleanApiStarter.IntegrationTests.Features.Projects; public sealed class ProjectsTests : IClassFixture> { diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs similarity index 73% rename from tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs rename to tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs index d1837d5..a576aa4 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs @@ -2,7 +2,7 @@ global using System.Net.Http.Headers; global using System.Net.Http.Json; -global using CleanApiStarter.Tests.Common; +global using CleanApiStarter.TestUtilities.Common; global using Shouldly; diff --git a/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/tests/CleanApiStarter.IntegrationTests/appsettings.Testing.json similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json rename to tests/CleanApiStarter.IntegrationTests/appsettings.Testing.json diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj similarity index 85% rename from tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj rename to tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj index 3167dbc..ff57965 100644 --- a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj +++ b/tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj @@ -1,8 +1,8 @@  - CleanApiStarter.Tests - CleanApiStarter.Tests + CleanApiStarter.TestUtilities + CleanApiStarter.TestUtilities diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs similarity index 98% rename from tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs rename to tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs index 01f01a0..1494598 100644 --- a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs +++ b/tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Tests.Common; +namespace CleanApiStarter.TestUtilities.Common; public sealed class ApiApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class diff --git a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs similarity index 84% rename from tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs rename to tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs index 1ff103c..3befd72 100644 --- a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs +++ b/tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Tests.Common; +namespace CleanApiStarter.TestUtilities.Common; public sealed class AutoNSubstituteDataAttribute() : AutoDataAttribute(CreateFixture) { diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.TestUtilities/GlobalUsings.cs similarity index 100% rename from tests/CleanApiStarter.Tests/GlobalUsings.cs rename to tests/CleanApiStarter.TestUtilities/GlobalUsings.cs diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj similarity index 80% rename from tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj rename to tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj index 2e6a957..a8c0ef0 100644 --- a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj @@ -1,8 +1,8 @@ - CleanApiStarter.Application.UnitTests - CleanApiStarter.Application.UnitTests + CleanApiStarter.UnitTests + CleanApiStarter.UnitTests @@ -22,7 +22,7 @@ - + diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs b/tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs similarity index 94% rename from tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs rename to tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs index 3d2b424..8209347 100644 --- a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs +++ b/tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.UnitTests.Features.Projects.Tasks; +namespace CleanApiStarter.UnitTests.Features.Projects.Tasks; public sealed class CompleteTaskTests { diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.UnitTests/GlobalUsings.cs similarity index 88% rename from tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs rename to tests/CleanApiStarter.UnitTests/GlobalUsings.cs index 06bc60d..b0a244a 100644 --- a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.UnitTests/GlobalUsings.cs @@ -4,7 +4,7 @@ global using CleanApiStarter.Api.Domain.Entities; global using CleanApiStarter.Api.Features.Projects; global using CleanApiStarter.Api.Features.Projects.Tasks; -global using CleanApiStarter.Tests.Common; +global using CleanApiStarter.TestUtilities.Common; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Http.HttpResults;