From e83d593e63eaaee09c565dce41f7863c12a4eb0a Mon Sep 17 00:00:00 2001 From: Ashikul Islam Date: Mon, 7 Sep 2026 09:16:16 +0600 Subject: [PATCH] feat: added aspnet core scaffolding and templates --- app/package.json | 1 + docs/guide/aspnet.mdx | 134 +++++++ eslint.config.js | 9 + package.json | 5 +- packages/create-servest/__tests__/cli.test.ts | 260 ++++++++++++- .../src/generators/aspnet/features/auth.ts | 269 +++++++++++++ .../aspnet/features/authorization.ts | 92 +++++ .../generators/aspnet/features/database.ts | 203 ++++++++++ .../src/generators/aspnet/features/docker.ts | 102 +++++ .../src/generators/aspnet/features/example.ts | 172 +++++++++ .../src/generators/aspnet/features/git.ts | 16 + .../src/generators/aspnet/features/testing.ts | 69 ++++ .../src/generators/aspnet/generator.ts | 75 ++++ .../src/generators/aspnet/index.ts | 4 + .../src/generators/aspnet/prompts.ts | 237 ++++++++++++ .../src/generators/aspnet/types.ts | 33 ++ .../src/generators/aspnet/validator.ts | 88 +++++ packages/create-servest/src/index.ts | 356 ++++++++++++++---- packages/create-servest/src/types/index.d.ts | 18 + packages/create-servest/src/utils/helper.ts | 125 ++++++ packages/create-servest/src/utils/index.ts | 48 ++- .../.dockerignore | 12 + .../.env.example | 3 + .../Directory.Build.props | 5 + .../aspnet-enterprise-architecture/Dockerfile | 27 ++ .../aspnet-enterprise-architecture/README.md | 41 ++ .../Template.sln | 67 ++++ .../aspnet-enterprise-architecture/_gitignore | 22 ++ .../Controllers/BaseApiController.cs | 12 + .../Controllers/SampleItemsController.cs | 27 ++ .../Middleware/GlobalExceptionMiddleware.cs | 80 ++++ .../src/Template.API/Program.cs | 51 +++ .../Properties/launchSettings.json | 25 ++ .../src/Template.API/Template.API.csproj | 24 ++ .../Template.API/appsettings.Development.json | 9 + .../src/Template.API/appsettings.json | 12 + .../Common/Behaviors/LoggingBehavior.cs | 28 ++ .../Common/Behaviors/ValidationBehavior.cs | 38 ++ .../Interfaces/IApplicationDbContext.cs | 10 + .../Common/Interfaces/IDateTimeProvider.cs | 6 + .../Common/Models/Result.cs | 37 ++ .../DependencyInjection.cs | 26 ++ .../CreateSampleItemCommand.cs | 34 ++ .../CreateSampleItemCommandValidator.cs | 13 + .../SampleItems/DTOs/SampleItemDto.cs | 11 + .../GetSampleItems/GetSampleItemsQuery.cs | 30 ++ .../Template.Application.csproj | 21 ++ .../Common/BaseAuditableEntity.cs | 9 + .../src/Template.Domain/Common/BaseEntity.cs | 6 + .../Template.Domain/Entities/SampleItem.cs | 11 + .../src/Template.Domain/Enums/SampleStatus.cs | 9 + .../Exceptions/DomainException.cs | 12 + .../Template.Domain/Template.Domain.csproj | 10 + .../DependencyInjection.cs | 14 + .../Services/DateTimeProvider.cs | 8 + .../Template.Infrastructure.csproj | 20 + .../Configurations/SampleItemConfiguration.cs | 20 + .../Context/ApplicationDbContext.cs | 20 + .../DependencyInjection.cs | 23 ++ .../Template.Persistence.csproj | 20 + .../Controllers/SampleItemsControllerTests.cs | 40 ++ .../CustomWebApplicationFactory.cs | 41 ++ .../Template.IntegrationTests.csproj | 27 ++ .../CreateSampleItemCommandHandlerTests.cs | 35 ++ .../Template.UnitTests.csproj | 27 ++ .../aspnet/aspnet-minimal-api/.dockerignore | 11 + .../aspnet/aspnet-minimal-api/.env.example | 3 + .../aspnet-minimal-api/DTOs/SampleItemDto.cs | 4 + .../Data/ApplicationDbContext.cs | 13 + .../aspnet/aspnet-minimal-api/Dockerfile | 21 ++ .../Endpoints/SampleEndpoints.cs | 53 +++ .../Middleware/GlobalExceptionMiddleware.cs | 38 ++ .../aspnet-minimal-api/Models/SampleItem.cs | 9 + .../aspnet/aspnet-minimal-api/Program.cs | 45 +++ .../Properties/launchSettings.json | 25 ++ .../aspnet/aspnet-minimal-api/README.md | 33 ++ .../aspnet/aspnet-minimal-api/Template.csproj | 25 ++ .../aspnet/aspnet-minimal-api/_gitignore | 22 ++ .../appsettings.Development.json | 9 + .../aspnet-minimal-api/appsettings.json | 12 + .../templates/aspnet/aspnet-mvc/.dockerignore | 11 + .../templates/aspnet/aspnet-mvc/.env.example | 3 + .../Controllers/BaseApiController.cs | 9 + .../Controllers/SampleItemsController.cs | 34 ++ .../aspnet/aspnet-mvc/DTOs/SampleItemDto.cs | 4 + .../aspnet-mvc/Data/ApplicationDbContext.cs | 13 + .../templates/aspnet/aspnet-mvc/Dockerfile | 21 ++ .../Middleware/GlobalExceptionMiddleware.cs | 38 ++ .../aspnet/aspnet-mvc/Models/SampleItem.cs | 9 + .../templates/aspnet/aspnet-mvc/Program.cs | 48 +++ .../aspnet-mvc/Properties/launchSettings.json | 25 ++ .../templates/aspnet/aspnet-mvc/README.md | 34 ++ .../aspnet-mvc/Services/ISampleItemService.cs | 9 + .../aspnet-mvc/Services/SampleItemService.cs | 39 ++ .../aspnet/aspnet-mvc/Template.csproj | 24 ++ .../templates/aspnet/aspnet-mvc/_gitignore | 22 ++ .../aspnet-mvc/appsettings.Development.json | 9 + .../aspnet/aspnet-mvc/appsettings.json | 12 + packages/create-servest/tsconfig.json | 2 +- tsconfig.base.json | 5 +- 100 files changed, 3931 insertions(+), 102 deletions(-) create mode 100644 docs/guide/aspnet.mdx create mode 100644 packages/create-servest/src/generators/aspnet/features/auth.ts create mode 100644 packages/create-servest/src/generators/aspnet/features/authorization.ts create mode 100644 packages/create-servest/src/generators/aspnet/features/database.ts create mode 100644 packages/create-servest/src/generators/aspnet/features/docker.ts create mode 100644 packages/create-servest/src/generators/aspnet/features/example.ts create mode 100644 packages/create-servest/src/generators/aspnet/features/git.ts create mode 100644 packages/create-servest/src/generators/aspnet/features/testing.ts create mode 100644 packages/create-servest/src/generators/aspnet/generator.ts create mode 100644 packages/create-servest/src/generators/aspnet/index.ts create mode 100644 packages/create-servest/src/generators/aspnet/prompts.ts create mode 100644 packages/create-servest/src/generators/aspnet/types.ts create mode 100644 packages/create-servest/src/generators/aspnet/validator.ts create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.dockerignore create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.env.example create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Directory.Build.props create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Dockerfile create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/README.md create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Template.sln create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/_gitignore create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/BaseApiController.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/SampleItemsController.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Middleware/GlobalExceptionMiddleware.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Program.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Properties/launchSettings.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Template.API.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.Development.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/LoggingBehavior.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IApplicationDbContext.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IDateTimeProvider.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Models/Result.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/DependencyInjection.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommand.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommandValidator.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/DTOs/SampleItemDto.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Queries/GetSampleItems/GetSampleItemsQuery.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Template.Application.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseAuditableEntity.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseEntity.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Entities/SampleItem.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Enums/SampleStatus.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Exceptions/DomainException.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Template.Domain.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/DependencyInjection.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Services/DateTimeProvider.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Template.Infrastructure.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Configurations/SampleItemConfiguration.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Context/ApplicationDbContext.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/DependencyInjection.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Template.Persistence.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Controllers/SampleItemsControllerTests.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/CustomWebApplicationFactory.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Template.IntegrationTests.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Application/CreateSampleItemCommandHandlerTests.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Template.UnitTests.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/.dockerignore create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/.env.example create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/DTOs/SampleItemDto.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Data/ApplicationDbContext.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Dockerfile create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Endpoints/SampleEndpoints.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Middleware/GlobalExceptionMiddleware.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Models/SampleItem.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Program.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Properties/launchSettings.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/README.md create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/Template.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/_gitignore create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.Development.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/.dockerignore create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/.env.example create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/BaseApiController.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/SampleItemsController.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/DTOs/SampleItemDto.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Data/ApplicationDbContext.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Dockerfile create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Middleware/GlobalExceptionMiddleware.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Models/SampleItem.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Program.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Properties/launchSettings.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/README.md create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Services/ISampleItemService.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Services/SampleItemService.cs create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/Template.csproj create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/_gitignore create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.Development.json create mode 100644 packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.json diff --git a/app/package.json b/app/package.json index 48e2c5e..8d79e00 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", + "typecheck": "tsc --noEmit", "lint": "next lint", "lint:fix": "next lint --fix" }, diff --git a/docs/guide/aspnet.mdx b/docs/guide/aspnet.mdx new file mode 100644 index 0000000..0ccd832 --- /dev/null +++ b/docs/guide/aspnet.mdx @@ -0,0 +1,134 @@ +--- +title: ASP.NET Core Templates & Configuration +description: Learn about the ASP.NET Core templates and composable scaffolding features in Servest, from Clean Architecture to Minimal APIs. +--- + +# ASP.NET Core Templates & Scaffolding + +Servest offers production-ready, highly configurable **ASP.NET Core (.NET 8, 9 & 10)** templates. Instead of manually running 20+ `dotnet` commands to set up projects, dependencies, references, Docker setups, and test suites, Servest provides a complete, working backend tailored to your exact stack in seconds. + +--- + +## 🏛️ Architectures + +Servest provides three foundation architectures: + +| Architecture | Full Template Name | Short Alias | Ideal For | +| --------------------------- | -------------------------------- | ------------- | ---------------------------------------------------------------------------- | +| **Enterprise Architecture** | `aspnet-enterprise-architecture` | `enterprise` | Microservices, scalable enterprise APIs with Clean/Onion Architecture & CQRS | +| **MVC Layered Web API** | `aspnet-mvc` | `mvc` | Single-project controller-based Web APIs with service layers | +| **Minimal API** | `aspnet-minimal-api` | `minimal-api` | High-performance, lightweight micro-endpoints with Route Groups | + +### 1. Enterprise Architecture (`enterprise`) + +``` +/ +├── src/ +│ ├── .Domain/ # Entities, ValueObjects, Domain Exceptions (Zero dependencies) +│ ├── .Application/ # CQRS (MediatR), FluentValidation, Interfaces, DTOs +│ ├── .Persistence/ # EF Core DbContext, Entity Configurations, Database Providers +│ ├── .Infrastructure/ # External services, JWT Identity, Date/Time providers +│ └── .API/ # REST Controllers, Middlewares, Swagger UI, DI Setup +├── tests/ +│ ├── .UnitTests/ # xUnit, Moq, FluentAssertions +│ └── .IntegrationTests/ # xUnit, CustomWebApplicationFactory (isolated in-memory EF) +└── .sln +``` + +### 2. MVC Layered Web API (`mvc`) + +- `Controllers/`: Inheriting from `BaseApiController`. +- `Services/`: Business logic interfaces and implementations. +- `Models/`: Database domain entities. +- `DTOs/`: Request/Response data models. +- `Data/`: `ApplicationDbContext` with EF Core. +- `Middleware/`: Centralized Global Exception Handling. + +### 3. Minimal API (`minimal-api`) + +- `Endpoints/`: Route groups using `MapGroup("/api/...")`. +- `Models/`: Entity models. +- `DTOs/`: Request & response schemas. +- `Data/`: EF Core `ApplicationDbContext`. +- `Middleware/`: Global Exception Handling middleware. + +--- + +## ⚙️ Composable Features & CLI Flags + +Every architecture can be customized interactively or via CLI flags: + +### Database & ORM + +- `--database `: Configures provider-specific packages, connection strings in `.env` and `appsettings.json`, and registers DI (`UseSqlite`, `UseNpgsql`, `UseSqlServer`, `UseMySql`). +- `--orm `: Defaults to Entity Framework Core. + +### Authentication & Authorization + +- `--auth `: Installs `JwtBearer` packages, generates `TokenService` and `JwtSettings`, and configures authentication middleware with Swagger Bearer security schemes. +- `--authorization `: Generates `Roles.cs` and `Policies.cs`, registering role-based authorization policies. + +### Docker & Infrastructure + +- `--docker` / `--no-docker`: Includes or skips `Dockerfile`, `.dockerignore`, and multi-container `docker-compose.yml` (e.g. API + PostgreSQL/MySQL). + +### Testing Suites + +- `--tests `: + - `unit-integration`: Includes both unit and integration test suites. + - `unit`: Unlinks and removes integration tests. + - `none`: Completely removes all test projects and unlinks them from `.sln`. + +### Example Endpoints + +- `--example `: + - `sample-crud`: Includes sample entities, endpoints, CQRS handlers, and validation. + - `none`: Strips all sample controllers/endpoints/handlers for a clean production foundation. + +### Additional Automation + +- `--git` / `--no-git`: Initializes a Git repository and stages initial commit. +- `--dry-run`: Previews the project generation plan without writing any files to disk. +- `--non-interactive`: Runs in CI/scripting mode, using defaults or provided flags without prompting. + +--- + +## 🚀 Quickstart Examples + +### 1. Interactive Mode + +Run without flags to trigger the interactive setup wizard: + +```bash +npx create-servest my-backend +``` + +### 2. Full Enterprise Stack with PostgreSQL, JWT & Docker + +```bash +npx create-servest enterprise-api \ + --template enterprise \ + --database postgres \ + --auth jwt \ + --authorization rbac \ + --docker \ + --non-interactive +``` + +### 3. Clean-Slate Minimal API with SQLite + +```bash +npx create-servest microservice \ + --template minimal-api \ + --database sqlite \ + --example none \ + --non-interactive +``` + +### 4. Running Your New Project + +```bash +cd enterprise-api +dotnet restore +dotnet watch --project src/EnterpriseApi.API +``` diff --git a/eslint.config.js b/eslint.config.js index ec57471..343ef24 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -147,4 +147,13 @@ export default tseslint.config( }, }, }, + { + name: 'app-frontend', + files: ['app/**/*.{js,jsx,ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + }, + }, + }, ); diff --git a/package.json b/package.json index b796a09..7d33fb3 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,10 @@ "lint:packages": "eslint --cache packages", "lint:app": "eslint --cache app", "lint": "pnpm lint:packages && pnpm lint:app", - "typecheck": "tsc -p tsconfig.base.json && pnpm -r --parallel run typecheck", + "typecheck": "pnpm -r --parallel run typecheck", "test": "vitest run", - "build": "pnpm -r --filter='./packages/*' run build", + "dev": "pnpm --filter servest-frontend run dev", + "build": "pnpm -r --filter \"./packages/*\" run build", "publish:create-servest": "pnpm --filter create-servest publish --access public", "publish:servest": "pnpm --filter servest publish --access public" }, diff --git a/packages/create-servest/__tests__/cli.test.ts b/packages/create-servest/__tests__/cli.test.ts index a551dc7..87859f3 100644 --- a/packages/create-servest/__tests__/cli.test.ts +++ b/packages/create-servest/__tests__/cli.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { SyncOptions, SyncResult } from 'execa'; -import { execaCommandSync } from 'execa'; +import { execaSync } from 'execa'; import { afterEach, beforeAll, expect, test } from 'vitest'; const CLI_PATH = path.resolve(__dirname, '..'); @@ -13,7 +13,7 @@ const genPath = path.join(__dirname, projectName); const genPathWithSubfolder = path.join(__dirname, 'subfolder', projectName); const run = (args: string[], options?: SO): SyncResult => { - return execaCommandSync(`node ${CLI_PATH} ${args.join(' ')}`, options); + return execaSync('node', [CLI_PATH, ...args], options); }; // Helper to create a non-empty directory @@ -29,31 +29,60 @@ const createNonEmptyDir = (overrideFolder?: string) => { const getTemplateFiles = (framework: string) => { const templatesRoot = path.join(CLI_PATH, 'templates'); - // Find the first template folder that starts with the given framework name - const matchedTemplate = fs + const categories = fs .readdirSync(templatesRoot, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - .find((name) => name.startsWith(framework)); - - if (!matchedTemplate) { - throw new Error(`No template folder found for framework: ${framework}`); + .map((dirent) => dirent.name); + + for (const category of categories) { + const categoryPath = path.join(templatesRoot, category); + const matched = fs + .readdirSync(categoryPath, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name) + .find((name) => name.startsWith(framework)); + + if (matched) { + const templateDir = path.join(categoryPath, matched); + console.log('Using Template Dir:', templateDir); + + return fs + .readdirSync(templateDir) + .filter((filePath) => filePath !== '.gitkeep' && filePath !== '.stackblitz') + .map((filePath) => { + if (filePath === '_gitignore') return '.gitignore'; + if (filePath === '.env.example') return '.env'; + return filePath; + }) + .sort(); + } } - const templateDir = path.join(templatesRoot, matchedTemplate); - console.log('Using Template Dir:', templateDir); - - return fs - .readdirSync(templateDir) - .map((filePath) => (filePath === '_gitignore' ? '.gitignore' : filePath)) - .sort(); + throw new Error(`No template folder found for framework: ${framework}`); }; -// Clear generated test folders +// Clear generated test folders safely on Windows const clearAnyPreviousFolders = () => { - if (fs.existsSync(genPath)) fs.rmSync(genPath, { recursive: true, force: true }); - if (fs.existsSync(genPathWithSubfolder)) - fs.rmSync(genPathWithSubfolder, { recursive: true, force: true }); + const safeRm = (target: string) => { + if (!fs.existsSync(target)) return; + for (let attempt = 0; attempt < 10; attempt++) { + try { + fs.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + return; + } catch (err: any) { + if (err.code === 'EBUSY' || err.code === 'EPERM') { + const waitTill = Date.now() + 100; + while (Date.now() < waitTill) { + // spin-wait for Windows file unlock + } + } else { + break; + } + } + } + }; + safeRm(genPath); + safeRm(genPathWithSubfolder); }; beforeAll(() => clearAnyPreviousFolders()); @@ -161,3 +190,194 @@ test('return help usage how to use create-servest with -h alias', () => { const message = 'Usage: create-servest [OPTION]... [DIRECTORY]'; expect(stdout).toContain(message); }); + +test('successfully scaffolds an ASP.NET Core Enterprise Architecture solution', () => { + const framework = 'aspnet-enterprise-architecture'; + const { stdout } = run([projectName, '--template', framework], { cwd: __dirname }); + + expect(stdout).toContain(`Scaffolding project in ${genPath}`); + expect(fs.existsSync(path.join(genPath, 'TestApp.sln'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.env'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Dockerfile'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.dockerignore'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.gitignore'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'src', 'TestApp.API', 'TestApp.API.csproj'))).toBe(true); + expect( + fs.existsSync(path.join(genPath, 'src', 'TestApp.Application', 'TestApp.Application.csproj')), + ).toBe(true); + expect(fs.existsSync(path.join(genPath, 'src', 'TestApp.Domain', 'TestApp.Domain.csproj'))).toBe( + true, + ); + expect( + fs.existsSync( + path.join(genPath, 'src', 'TestApp.Infrastructure', 'TestApp.Infrastructure.csproj'), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(genPath, 'src', 'TestApp.Persistence', 'TestApp.Persistence.csproj')), + ).toBe(true); + expect( + fs.existsSync(path.join(genPath, 'tests', 'TestApp.UnitTests', 'TestApp.UnitTests.csproj')), + ).toBe(true); + expect( + fs.existsSync( + path.join(genPath, 'tests', 'TestApp.IntegrationTests', 'TestApp.IntegrationTests.csproj'), + ), + ).toBe(true); + + const dockerfileContent = fs.readFileSync(path.join(genPath, 'Dockerfile'), 'utf-8'); + expect(dockerfileContent).toContain('TestApp.API.csproj'); + expect(dockerfileContent).toContain('TestApp.API.dll'); +}); + +test('successfully scaffolds an ASP.NET Core MVC Web API project', () => { + const framework = 'aspnet-mvc'; + const { stdout } = run([projectName, '--template', framework], { cwd: __dirname }); + + expect(stdout).toContain(`Scaffolding project in ${genPath}`); + expect(fs.existsSync(path.join(genPath, 'TestApp.csproj'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.env'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Dockerfile'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.dockerignore'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.gitignore'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Controllers', 'SampleItemsController.cs'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Services', 'SampleItemService.cs'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Data', 'ApplicationDbContext.cs'))).toBe(true); + + const dockerfileContent = fs.readFileSync(path.join(genPath, 'Dockerfile'), 'utf-8'); + expect(dockerfileContent).toContain('TestApp.csproj'); + expect(dockerfileContent).toContain('TestApp.dll'); +}); + +test('successfully scaffolds an ASP.NET Core Minimal API project', () => { + const framework = 'aspnet-minimal-api'; + const { stdout } = run([projectName, '--template', framework], { cwd: __dirname }); + + expect(stdout).toContain(`Scaffolding project in ${genPath}`); + expect(fs.existsSync(path.join(genPath, 'TestApp.csproj'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.env'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Dockerfile'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.dockerignore'))).toBe(true); + expect(fs.existsSync(path.join(genPath, '.gitignore'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Endpoints', 'SampleEndpoints.cs'))).toBe(true); + expect(fs.existsSync(path.join(genPath, 'Data', 'ApplicationDbContext.cs'))).toBe(true); + + const dockerfileContent = fs.readFileSync(path.join(genPath, 'Dockerfile'), 'utf-8'); + expect(dockerfileContent).toContain('TestApp.csproj'); + expect(dockerfileContent).toContain('TestApp.dll'); +}); + +test('returns version number on --version or -v', () => { + const { stdout } = run(['--version'], { cwd: __dirname }); + expect(stdout).toMatch(/v\d+\.\d+\.\d+/); +}); + +test('supports --dry-run without creating files on disk', () => { + const { stdout } = run([projectName, '--template', 'enterprise', '--dry-run'], { + cwd: __dirname, + }); + expect(stdout).toContain('Dry Run - Proposed Project Configuration:'); + expect(stdout).toContain('✨ Dry run complete. No files were written.'); + expect(fs.existsSync(genPath)).toBe(false); +}); + +test('supports short template alias "enterprise"', () => { + const { stdout } = run([projectName, '--template', 'enterprise'], { cwd: __dirname }); + expect(stdout).toContain(`Scaffolding project in ${genPath}`); + expect(fs.existsSync(path.join(genPath, 'TestApp.sln'))).toBe(true); +}); + +test('successfully configures PostgreSQL database provider', () => { + run([projectName, '--template', 'enterprise', '--database', 'postgres'], { cwd: __dirname }); + + const persistenceCsproj = fs.readFileSync( + path.join(genPath, 'src', 'TestApp.Persistence', 'TestApp.Persistence.csproj'), + 'utf-8', + ); + expect(persistenceCsproj).toContain('Npgsql.EntityFrameworkCore.PostgreSQL'); + + const diCs = fs.readFileSync( + path.join(genPath, 'src', 'TestApp.Persistence', 'DependencyInjection.cs'), + 'utf-8', + ); + expect(diCs).toContain('options.UseNpgsql(connectionString)'); + + const appsettings = fs.readFileSync( + path.join(genPath, 'src', 'TestApp.API', 'appsettings.json'), + 'utf-8', + ); + expect(appsettings).toContain('Host=localhost;Port=5432;Database=TestAppDb'); +}); + +test('successfully configures JWT authentication and TokenService', () => { + run([projectName, '--template', 'enterprise', '--auth', 'jwt'], { cwd: __dirname }); + + const apiCsproj = fs.readFileSync( + path.join(genPath, 'src', 'TestApp.API', 'TestApp.API.csproj'), + 'utf-8', + ); + expect(apiCsproj).toContain('Microsoft.AspNetCore.Authentication.JwtBearer'); + + const tokenService = path.join( + genPath, + 'src', + 'TestApp.Infrastructure', + 'Services', + 'TokenService.cs', + ); + expect(fs.existsSync(tokenService)).toBe(true); + + const programCs = fs.readFileSync( + path.join(genPath, 'src', 'TestApp.API', 'Program.cs'), + 'utf-8', + ); + expect(programCs).toContain('builder.Services.AddAuthentication('); + expect(programCs).toContain('app.UseAuthentication();'); +}); + +test('successfully configures RBAC authorization policies', () => { + run([projectName, '--template', 'enterprise', '--auth', 'jwt', '--authorization', 'rbac'], { + cwd: __dirname, + }); + + const rolesFile = path.join(genPath, 'src', 'TestApp.Domain', 'Constants', 'Roles.cs'); + const policiesFile = path.join(genPath, 'src', 'TestApp.Domain', 'Constants', 'Policies.cs'); + expect(fs.existsSync(rolesFile)).toBe(true); + expect(fs.existsSync(policiesFile)).toBe(true); + + const programCs = fs.readFileSync( + path.join(genPath, 'src', 'TestApp.API', 'Program.cs'), + 'utf-8', + ); + expect(programCs).toContain('options.AddPolicy("RequireAdmin"'); +}); + +test('omits Docker files when --no-docker is specified', () => { + run([projectName, '--template', 'enterprise', '--no-docker'], { cwd: __dirname }); + expect(fs.existsSync(path.join(genPath, 'Dockerfile'))).toBe(false); + expect(fs.existsSync(path.join(genPath, '.dockerignore'))).toBe(false); +}); + +test('removes tests directory and unlinks from .sln when --tests none', () => { + run([projectName, '--template', 'enterprise', '--tests', 'none'], { cwd: __dirname }); + expect(fs.existsSync(path.join(genPath, 'tests'))).toBe(false); + + const slnContent = fs.readFileSync(path.join(genPath, 'TestApp.sln'), 'utf-8'); + expect(slnContent).not.toContain('TestApp.UnitTests'); + expect(slnContent).not.toContain('TestApp.IntegrationTests'); +}); + +test('scaffolds a clean slate project without SampleItem when --example none', () => { + run([projectName, '--template', 'enterprise', '--example', 'none'], { cwd: __dirname }); + + const sampleEntity = path.join(genPath, 'src', 'TestApp.Domain', 'Entities', 'SampleItem.cs'); + const sampleController = path.join( + genPath, + 'src', + 'TestApp.API', + 'Controllers', + 'SampleItemsController.cs', + ); + expect(fs.existsSync(sampleEntity)).toBe(false); + expect(fs.existsSync(sampleController)).toBe(false); +}); diff --git a/packages/create-servest/src/generators/aspnet/features/auth.ts b/packages/create-servest/src/generators/aspnet/features/auth.ts new file mode 100644 index 0000000..8178469 --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/auth.ts @@ -0,0 +1,269 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IAspNetOptions } from '../types'; + +export function applyAuthFeature(root: string, appName: string, options: IAspNetOptions): void { + const { auth, architecture } = options; + + if (auth !== 'jwt') return; + + // 1. Add JwtBearer package to the API project + addJwtPackage(root, architecture); + + // 2. Add JwtSettings and TokenService + generateTokenService(root, appName, architecture); + + // 3. Update appsettings.json and .env + updateJwtSettings(root, architecture); + + // 4. Register Authentication in Program.cs + registerAuthenticationInPipeline(root, architecture, appName); +} + +function addJwtPackage(root: string, architecture: string): void { + const jwtPackage = + ' \n'; + const tokensPackage = + ' \n'; + + if (architecture === 'enterprise') { + const srcDir = path.join(root, 'src'); + if (fs.existsSync(srcDir)) { + for (const dir of fs.readdirSync(srcDir)) { + if (dir.endsWith('.API')) { + const csproj = path.join(srcDir, dir, `${dir}.csproj`); + if (fs.existsSync(csproj)) { + let content = fs.readFileSync(csproj, 'utf-8'); + if (!content.includes('Microsoft.AspNetCore.Authentication.JwtBearer')) { + content = content.replace('', `${jwtPackage} `); + fs.writeFileSync(csproj, content, 'utf-8'); + } + } + } + if (dir.endsWith('.Infrastructure')) { + const csproj = path.join(srcDir, dir, `${dir}.csproj`); + if (fs.existsSync(csproj)) { + let content = fs.readFileSync(csproj, 'utf-8'); + if (!content.includes('System.IdentityModel.Tokens.Jwt')) { + if (content.includes('')) { + content = content.replace('', `${tokensPackage} `); + } else { + content = content.replace( + '', + ` \n${tokensPackage} \n`, + ); + } + fs.writeFileSync(csproj, content, 'utf-8'); + } + } + + // Register in Infrastructure/DependencyInjection.cs + const diPath = path.join(srcDir, dir, 'DependencyInjection.cs'); + if (fs.existsSync(diPath)) { + let diContent = fs.readFileSync(diPath, 'utf-8'); + if (!diContent.includes('ITokenService')) { + diContent = diContent.replace( + 'return services;', + 'services.AddScoped();\n return services;', + ); + fs.writeFileSync(diPath, diContent, 'utf-8'); + } + } + } + } + } + } else { + for (const file of fs.readdirSync(root)) { + if (file.endsWith('.csproj')) { + const csproj = path.join(root, file); + let content = fs.readFileSync(csproj, 'utf-8'); + if (!content.includes('Microsoft.AspNetCore.Authentication.JwtBearer')) { + content = content.replace('', `${jwtPackage}${tokensPackage} `); + fs.writeFileSync(csproj, content, 'utf-8'); + } + } + } + } +} + +function generateTokenService(root: string, appName: string, architecture: string): void { + const tokenServiceContent = `using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace ${appName}.${architecture === 'enterprise' ? 'Infrastructure.Services' : 'Services'}; + +public interface ITokenService +{ + string GenerateToken(string userId, string userName, IEnumerable? roles = null); +} + +public class TokenService : ITokenService +{ + private readonly IConfiguration _configuration; + + public TokenService(IConfiguration configuration) + { + _configuration = configuration; + } + + public string GenerateToken(string userId, string userName, IEnumerable? roles = null) + { + var secret = _configuration["JwtSettings:Secret"] ?? "YourSuperSecretKeyMustBeAtLeast32BytesLong!"; + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId), + new(JwtRegisteredClaimNames.UniqueName, userName), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + if (roles != null) + { + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + } + + var token = new JwtSecurityToken( + issuer: _configuration["JwtSettings:Issuer"] ?? "${appName}Api", + audience: _configuration["JwtSettings:Audience"] ?? "${appName}Client", + claims: claims, + expires: DateTime.UtcNow.AddMinutes(double.TryParse(_configuration["JwtSettings:ExpiryMinutes"], out var exp) ? exp : 60), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} +`; + + if (architecture === 'enterprise') { + const infraServicesDir = path.join(root, 'src', `${appName}.Infrastructure`, 'Services'); + if (fs.existsSync(infraServicesDir)) { + fs.writeFileSync( + path.join(infraServicesDir, 'TokenService.cs'), + tokenServiceContent, + 'utf-8', + ); + } + } else { + const servicesDir = path.join(root, 'Services'); + fs.mkdirSync(servicesDir, { recursive: true }); + fs.writeFileSync(path.join(servicesDir, 'TokenService.cs'), tokenServiceContent, 'utf-8'); + } +} + +function updateJwtSettings(root: string, architecture: string): void { + const jwtConfig = { + Secret: 'YourSuperSecretKeyMustBeAtLeast32BytesLongForSecurity!', + Issuer: 'ServestApi', + Audience: 'ServestClient', + ExpiryMinutes: 60, + }; + + const updateJson = (jsonPath: string) => { + if (!fs.existsSync(jsonPath)) return; + try { + const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + data.JwtSettings = jwtConfig; + fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + } catch { + // ignore + } + }; + + if (architecture === 'enterprise') { + const srcDir = path.join(root, 'src'); + if (fs.existsSync(srcDir)) { + for (const dir of fs.readdirSync(srcDir)) { + if (dir.endsWith('.API')) { + updateJson(path.join(srcDir, dir, 'appsettings.json')); + updateJson(path.join(srcDir, dir, 'appsettings.Development.json')); + } + } + } + } else { + updateJson(path.join(root, 'appsettings.json')); + updateJson(path.join(root, 'appsettings.Development.json')); + } + + // Update .env + for (const name of ['.env', '.env.example']) { + const envPath = path.join(root, name); + if (fs.existsSync(envPath)) { + let content = fs.readFileSync(envPath, 'utf-8'); + content += + '\n# JWT Authentication\nJWT_SECRET=YourSuperSecretKeyMustBeAtLeast32BytesLongForSecurity!\nJWT_ISSUER=ServestApi\nJWT_AUDIENCE=ServestClient\n'; + fs.writeFileSync(envPath, content, 'utf-8'); + } + } +} + +function registerAuthenticationInPipeline( + root: string, + architecture: string, + appName: string, +): void { + const findProgramCs = (): string | null => { + if (architecture === 'enterprise') { + const apiDir = path.join(root, 'src', `${appName}.API`); + const p = path.join(apiDir, 'Program.cs'); + return fs.existsSync(p) ? p : null; + } + const p = path.join(root, 'Program.cs'); + return fs.existsSync(p) ? p : null; + }; + + const programCs = findProgramCs(); + if (!programCs) return; + + let content = fs.readFileSync(programCs, 'utf-8'); + + // 1. Add using directives + if (!content.includes('Microsoft.AspNetCore.Authentication.JwtBearer')) { + content = `using Microsoft.AspNetCore.Authentication.JwtBearer;\nusing Microsoft.IdentityModel.Tokens;\nusing System.Text;\n${content}`; + } + + // 2. Add service registration + const jwtRegistration = ` +// JWT Authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + var secret = builder.Configuration["JwtSettings:Secret"] ?? "YourSuperSecretKeyMustBeAtLeast32BytesLongForSecurity!"; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["JwtSettings:Issuer"] ?? "ServestApi", + ValidAudience = builder.Configuration["JwtSettings:Audience"] ?? "ServestClient", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)) + }; +}); +`; + + if (content.includes('builder.Services.AddCors(')) { + content = content.replace( + 'builder.Services.AddCors(', + `${jwtRegistration}\nbuilder.Services.AddCors(`, + ); + } + + // 3. Add app.UseAuthentication() before app.UseAuthorization() + if (!content.includes('app.UseAuthentication()') && content.includes('app.UseAuthorization()')) { + content = content.replace( + 'app.UseAuthorization();', + 'app.UseAuthentication();\napp.UseAuthorization();', + ); + } + + fs.writeFileSync(programCs, content, 'utf-8'); +} diff --git a/packages/create-servest/src/generators/aspnet/features/authorization.ts b/packages/create-servest/src/generators/aspnet/features/authorization.ts new file mode 100644 index 0000000..34c513e --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/authorization.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IAspNetOptions } from '../types'; + +export function applyAuthorizationFeature( + root: string, + appName: string, + options: IAspNetOptions, +): void { + const { authorization, architecture } = options; + + if (authorization !== 'rbac') return; + + // 1. Generate Roles and Policies files + generateRolesAndPolicies(root, appName, architecture); + + // 2. Register policies in Program.cs + registerPoliciesInProgram(root, architecture, appName); +} + +function generateRolesAndPolicies(root: string, appName: string, architecture: string): void { + const rolesContent = `namespace ${appName}.${architecture === 'enterprise' ? 'Domain.Constants' : 'Common'}; + +public static class Roles +{ + public const string Admin = "Admin"; + public const string Manager = "Manager"; + public const string User = "User"; +} +`; + + const policiesContent = `namespace ${appName}.${architecture === 'enterprise' ? 'Domain.Constants' : 'Common'}; + +public static class Policies +{ + public const string RequireAdmin = "RequireAdmin"; + public const string RequireManager = "RequireManager"; +} +`; + + if (architecture === 'enterprise') { + const constantsDir = path.join(root, 'src', `${appName}.Domain`, 'Constants'); + fs.mkdirSync(constantsDir, { recursive: true }); + fs.writeFileSync(path.join(constantsDir, 'Roles.cs'), rolesContent, 'utf-8'); + fs.writeFileSync(path.join(constantsDir, 'Policies.cs'), policiesContent, 'utf-8'); + } else { + const commonDir = path.join(root, 'Common'); + fs.mkdirSync(commonDir, { recursive: true }); + fs.writeFileSync(path.join(commonDir, 'Roles.cs'), rolesContent, 'utf-8'); + fs.writeFileSync(path.join(commonDir, 'Policies.cs'), policiesContent, 'utf-8'); + } +} + +function registerPoliciesInProgram(root: string, architecture: string, appName: string): void { + const findProgramCs = (): string | null => { + if (architecture === 'enterprise') { + const p = path.join(root, 'src', `${appName}.API`, 'Program.cs'); + return fs.existsSync(p) ? p : null; + } + const p = path.join(root, 'Program.cs'); + return fs.existsSync(p) ? p : null; + }; + + const programCs = findProgramCs(); + if (!programCs) return; + + let content = fs.readFileSync(programCs, 'utf-8'); + + const policyRegistration = ` +// RBAC Authorization Policies +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin")); + options.AddPolicy("RequireManager", policy => policy.RequireRole("Admin", "Manager")); +}); +`; + + if (!content.includes('RequireAdmin')) { + if (content.includes('builder.Services.AddCors(')) { + content = content.replace( + 'builder.Services.AddCors(', + `${policyRegistration}\nbuilder.Services.AddCors(`, + ); + } else if (content.includes('var app = builder.Build();')) { + content = content.replace( + 'var app = builder.Build();', + `${policyRegistration}\nvar app = builder.Build();`, + ); + } + fs.writeFileSync(programCs, content, 'utf-8'); + } +} diff --git a/packages/create-servest/src/generators/aspnet/features/database.ts b/packages/create-servest/src/generators/aspnet/features/database.ts new file mode 100644 index 0000000..1045e5b --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/database.ts @@ -0,0 +1,203 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IAspNetOptions } from '../types'; + +interface IDatabaseConfig { + packageName: string; + packageVersion: string; + connectionString: string; + envConnectionString: string; + useMethod: string; +} + +export const DATABASE_CONFIGS: Record = { + sqlite: { + packageName: 'Microsoft.EntityFrameworkCore.Sqlite', + packageVersion: '9.0.2', + connectionString: 'Data Source=app.db', + envConnectionString: 'Data Source=app.db', + useMethod: 'options.UseSqlite(connectionString)', + }, + postgres: { + packageName: 'Npgsql.EntityFrameworkCore.PostgreSQL', + packageVersion: '9.0.4', + connectionString: + 'Host=localhost;Port=5432;Database={APP_NAME}Db;Username=postgres;Password=postgres', + envConnectionString: + 'Host=localhost;Port=5432;Database={APP_NAME}Db;Username=postgres;Password=postgres', + useMethod: 'options.UseNpgsql(connectionString)', + }, + sqlserver: { + packageName: 'Microsoft.EntityFrameworkCore.SqlServer', + packageVersion: '9.0.2', + connectionString: + 'Server=localhost;Database={APP_NAME}Db;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True', + envConnectionString: + 'Server=localhost;Database={APP_NAME}Db;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True', + useMethod: 'options.UseSqlServer(connectionString)', + }, + mysql: { + packageName: 'Pomelo.EntityFrameworkCore.MySql', + packageVersion: '8.0.3', + connectionString: 'Server=localhost;Port=3306;Database={APP_NAME}Db;User=root;Password=root', + envConnectionString: 'Server=localhost;Port=3306;Database={APP_NAME}Db;User=root;Password=root', + useMethod: 'options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))', + }, +}; + +export function applyDatabaseFeature(root: string, appName: string, options: IAspNetOptions): void { + const { database, orm, architecture } = options; + + if (database === 'none' || orm === 'none') { + // Remove SQLite and DB registrations if no DB or no ORM + removeDatabaseReferences(root, architecture); + return; + } + + const config = DATABASE_CONFIGS[database]; + if (!config) return; + + const connStr = config.connectionString.replace('{APP_NAME}', appName); + const envConnStr = config.envConnectionString.replace('{APP_NAME}', appName); + + // 1. Update .csproj files + updateCsprojPackages(root, architecture, config.packageName, config.packageVersion); + + // 2. Update C# source files (DependencyInjection.cs or Program.cs) + updateDatabaseRegistration(root, architecture, config.useMethod, database); + + // 3. Update appsettings.json and appsettings.Development.json + updateAppSettings(root, architecture, connStr); + + // 4. Update .env and .env.example + updateEnvFiles(root, envConnStr); +} + +function updateCsprojPackages( + root: string, + architecture: string, + newPkg: string, + newVer: string, +): void { + const findCsprojFiles = (dir: string): string[] => { + let results: string[] = []; + for (const file of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, file.name); + if (file.isDirectory()) { + results = results.concat(findCsprojFiles(full)); + } else if (file.name.endsWith('.csproj')) { + results.push(full); + } + } + return results; + }; + + const csprojFiles = findCsprojFiles(root); + for (const file of csprojFiles) { + let content = fs.readFileSync(file, 'utf-8'); + if (content.includes('Microsoft.EntityFrameworkCore.Sqlite')) { + if (newPkg !== 'Microsoft.EntityFrameworkCore.Sqlite') { + content = content.replace( + //, + ``, + ); + fs.writeFileSync(file, content, 'utf-8'); + } + } + } +} + +function updateDatabaseRegistration( + root: string, + architecture: string, + useMethod: string, + database: string, +): void { + if (database === 'sqlite') return; // Default is already sqlite + + const replaceInFile = (filePath: string) => { + if (!fs.existsSync(filePath)) return; + let content = fs.readFileSync(filePath, 'utf-8'); + if (content.includes('options.UseSqlite(connectionString)')) { + content = content.replace('options.UseSqlite(connectionString)', useMethod); + fs.writeFileSync(filePath, content, 'utf-8'); + } + }; + + if (architecture === 'enterprise') { + // In src/.Persistence/DependencyInjection.cs + const srcDir = path.join(root, 'src'); + if (fs.existsSync(srcDir)) { + for (const dir of fs.readdirSync(srcDir)) { + if (dir.endsWith('.Persistence')) { + replaceInFile(path.join(srcDir, dir, 'DependencyInjection.cs')); + } + } + } + } else { + // In Program.cs + replaceInFile(path.join(root, 'Program.cs')); + } +} + +function updateAppSettings(root: string, architecture: string, connectionString: string): void { + const updateJson = (jsonPath: string) => { + if (!fs.existsSync(jsonPath)) return; + try { + const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + if (!data.ConnectionStrings) { + data.ConnectionStrings = {}; + } + data.ConnectionStrings.DefaultConnection = connectionString; + fs.writeFileSync(jsonPath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + } catch { + // ignore parsing issues + } + }; + + if (architecture === 'enterprise') { + const srcDir = path.join(root, 'src'); + if (fs.existsSync(srcDir)) { + for (const dir of fs.readdirSync(srcDir)) { + if (dir.endsWith('.API')) { + updateJson(path.join(srcDir, dir, 'appsettings.json')); + updateJson(path.join(srcDir, dir, 'appsettings.Development.json')); + } + } + } + } else { + updateJson(path.join(root, 'appsettings.json')); + updateJson(path.join(root, 'appsettings.Development.json')); + } +} + +function updateEnvFiles(root: string, envConnStr: string): void { + for (const name of ['.env', '.env.example']) { + const filePath = path.join(root, name); + if (fs.existsSync(filePath)) { + let content = fs.readFileSync(filePath, 'utf-8'); + content = content.replace(/CONNECTION_STRING=.*/, `CONNECTION_STRING=${envConnStr}`); + fs.writeFileSync(filePath, content, 'utf-8'); + } + } +} + +function removeDatabaseReferences(root: string, _architecture: string): void { + // Remove Sqlite package reference + const walk = (dir: string) => { + for (const file of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, file.name); + if (file.isDirectory()) { + walk(full); + } else if (file.name.endsWith('.csproj')) { + let content = fs.readFileSync(full, 'utf-8'); + content = content.replace( + /\r?\n?/, + '', + ); + fs.writeFileSync(full, content, 'utf-8'); + } + } + }; + walk(root); +} diff --git a/packages/create-servest/src/generators/aspnet/features/docker.ts b/packages/create-servest/src/generators/aspnet/features/docker.ts new file mode 100644 index 0000000..7541c8b --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/docker.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IAspNetOptions } from '../types'; + +export function applyDockerFeature(root: string, appName: string, options: IAspNetOptions): void { + const { docker, database } = options; + + if (!docker) { + // Remove Dockerfile and .dockerignore + const dockerfile = path.join(root, 'Dockerfile'); + const dockerignore = path.join(root, '.dockerignore'); + if (fs.existsSync(dockerfile)) fs.rmSync(dockerfile); + if (fs.existsSync(dockerignore)) fs.rmSync(dockerignore); + return; + } + + // If database is postgres or mysql, create a multi-service docker-compose.yml + if (['postgres', 'mysql', 'sqlserver'].includes(database)) { + generateDockerCompose(root, appName, database); + } +} + +function generateDockerCompose(root: string, appName: string, database: string): void { + const composePath = path.join(root, 'docker-compose.yml'); + + let dbService = ''; + + if (database === 'postgres') { + dbService = ` + db: + image: postgres:16-alpine + container_name: ${appName.toLowerCase()}-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: ${appName}Db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 +`; + } else if (database === 'mysql') { + dbService = ` + db: + image: mysql:8.0 + container_name: ${appName.toLowerCase()}-mysql + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: ${appName}Db + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql +`; + } else if (database === 'sqlserver') { + dbService = ` + db: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: ${appName.toLowerCase()}-sqlserver + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "YourStrong@Password123" + ports: + - "1433:1433" + volumes: + - sqlserver_data:/var/opt/mssql +`; + } + + const volumeName = + database === 'postgres' + ? 'postgres_data' + : database === 'mysql' + ? 'mysql_data' + : 'sqlserver_data'; + + const content = `version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: ${appName.toLowerCase()}-api + environment: + - ASPNETCORE_ENVIRONMENT=Development + ports: + - "5000:5000" + depends_on: + - db +${dbService} +volumes: + ${volumeName}: +`; + + fs.writeFileSync(composePath, content, 'utf-8'); +} diff --git a/packages/create-servest/src/generators/aspnet/features/example.ts b/packages/create-servest/src/generators/aspnet/features/example.ts new file mode 100644 index 0000000..1c8e958 --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/example.ts @@ -0,0 +1,172 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IAspNetOptions } from '../types'; + +export function applyExampleFeature(root: string, appName: string, options: IAspNetOptions): void { + const { example, architecture } = options; + + if (example !== 'none') return; // Keep sample + + if (architecture === 'enterprise') { + cleanEnterpriseExample(root, appName); + } else if (architecture === 'mvc') { + cleanMvcExample(root); + } else if (architecture === 'minimal-api') { + cleanMinimalApiExample(root, appName); + } +} + +function cleanEnterpriseExample(root: string, appName: string): void { + const src = path.join(root, 'src'); + + // 1. Remove Domain entities + const domainEntities = path.join(src, `${appName}.Domain`, 'Entities', 'SampleItem.cs'); + if (fs.existsSync(domainEntities)) fs.rmSync(domainEntities); + + // 2. Remove Application features + const appFeatures = path.join(src, `${appName}.Application`, 'Features', 'SampleItems'); + if (fs.existsSync(appFeatures)) fs.rmSync(appFeatures, { recursive: true, force: true }); + + // 3. Remove Persistence config and clean DbContext & Interface + const persistenceConfig = path.join( + src, + `${appName}.Persistence`, + 'Configurations', + 'SampleItemConfiguration.cs', + ); + if (fs.existsSync(persistenceConfig)) fs.rmSync(persistenceConfig); + + const dbContextFile = path.join( + src, + `${appName}.Persistence`, + 'Context', + 'ApplicationDbContext.cs', + ); + if (fs.existsSync(dbContextFile)) { + let content = fs.readFileSync(dbContextFile, 'utf-8'); + content = content.replace( + /^[ \t]*(public\s+)?DbSet\s+SampleItems\b[^\r\n]*\r?\n?/gm, + '', + ); + content = content.replace(/using [^;]*\.Domain\.Entities;\r?\n?/g, ''); + fs.writeFileSync(dbContextFile, content, 'utf-8'); + } + + const iDbContextFile = path.join( + src, + `${appName}.Application`, + 'Common', + 'Interfaces', + 'IApplicationDbContext.cs', + ); + if (fs.existsSync(iDbContextFile)) { + let content = fs.readFileSync(iDbContextFile, 'utf-8'); + content = content.replace( + /^[ \t]*(public\s+)?DbSet\s+SampleItems\b[^\r\n]*\r?\n?/gm, + '', + ); + content = content.replace(/using [^;]*\.Domain\.Entities;\r?\n?/g, ''); + fs.writeFileSync(iDbContextFile, content, 'utf-8'); + } + + // 4. Remove API controller + const controller = path.join(src, `${appName}.API`, 'Controllers', 'SampleItemsController.cs'); + if (fs.existsSync(controller)) fs.rmSync(controller); + + // 5. Remove tests + const unitTest = path.join( + root, + 'tests', + `${appName}.UnitTests`, + 'Application', + 'CreateSampleItemCommandHandlerTests.cs', + ); + if (fs.existsSync(unitTest)) fs.rmSync(unitTest); + + const integrationTest = path.join( + root, + 'tests', + `${appName}.IntegrationTests`, + 'Controllers', + 'SampleItemsControllerTests.cs', + ); + if (fs.existsSync(integrationTest)) fs.rmSync(integrationTest); +} + +function cleanMvcExample(root: string): void { + // 1. Remove Controller + const controller = path.join(root, 'Controllers', 'SampleItemsController.cs'); + if (fs.existsSync(controller)) fs.rmSync(controller); + + // 2. Remove Service + const service = path.join(root, 'Services', 'SampleItemService.cs'); + const iService = path.join(root, 'Services', 'ISampleItemService.cs'); + if (fs.existsSync(service)) fs.rmSync(service); + if (fs.existsSync(iService)) fs.rmSync(iService); + + // 3. Remove Models & DTOs + const model = path.join(root, 'Models', 'SampleItem.cs'); + if (fs.existsSync(model)) fs.rmSync(model); + const dtos = path.join(root, 'DTOs', 'SampleItemDto.cs'); + if (fs.existsSync(dtos)) fs.rmSync(dtos); + + // 4. Clean ApplicationDbContext.cs + const dbContext = path.join(root, 'Data', 'ApplicationDbContext.cs'); + if (fs.existsSync(dbContext)) { + let content = fs.readFileSync(dbContext, 'utf-8'); + content = content.replace( + /^[ \t]*(public\s+)?DbSet\s+SampleItems\b[^\r\n]*\r?\n?/gm, + '', + ); + content = content.replace(/using [^;]*\.Models;\r?\n?/g, ''); + fs.writeFileSync(dbContext, content, 'utf-8'); + } + + // 5. Clean Program.cs + const programCs = path.join(root, 'Program.cs'); + if (fs.existsSync(programCs)) { + let content = fs.readFileSync(programCs, 'utf-8'); + content = content.replace( + /builder\.Services\.AddScoped\(\);/g, + '', + ); + content = content.replace(/using [^;]*\.Services;\r?\n?/g, ''); + fs.writeFileSync(programCs, content, 'utf-8'); + } +} + +function cleanMinimalApiExample(root: string, appName: string): void { + // 1. Remove Endpoints + const endpoint = path.join(root, 'Endpoints', 'SampleEndpoints.cs'); + if (fs.existsSync(endpoint)) fs.rmSync(endpoint); + + // 2. Remove Models & DTOs + const model = path.join(root, 'Models', 'SampleItem.cs'); + if (fs.existsSync(model)) fs.rmSync(model); + const dtos = path.join(root, 'DTOs', 'SampleItemDto.cs'); + if (fs.existsSync(dtos)) fs.rmSync(dtos); + + // 3. Clean ApplicationDbContext.cs + const dbContext = path.join(root, 'Data', 'ApplicationDbContext.cs'); + if (fs.existsSync(dbContext)) { + let content = fs.readFileSync(dbContext, 'utf-8'); + content = content.replace( + /^[ \t]*(public\s+)?DbSet\s+SampleItems\b[^\r\n]*\r?\n?/gm, + '', + ); + content = content.replace(/using [^;]*\.Models;\r?\n?/g, ''); + fs.writeFileSync(dbContext, content, 'utf-8'); + } + + // 4. Clean Program.cs + const programCs = path.join(root, 'Program.cs'); + if (fs.existsSync(programCs)) { + let content = fs.readFileSync(programCs, 'utf-8'); + content = content.replace( + 'app.MapSampleEndpoints();', + `app.MapGet("/", () => Results.Ok(new { message = "Welcome to ${appName} API", status = "Healthy" }));`, + ); + content = content.replace(/using [^;]*\.Endpoints;\r?\n?/g, ''); + fs.writeFileSync(programCs, content, 'utf-8'); + } +} diff --git a/packages/create-servest/src/generators/aspnet/features/git.ts b/packages/create-servest/src/generators/aspnet/features/git.ts new file mode 100644 index 0000000..15f059b --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/git.ts @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import spawn from 'cross-spawn'; + +export function applyGitFeature(root: string, gitEnabled: boolean): void { + if (!gitEnabled) return; + + // Ensure .git does not already exist + if (fs.existsSync(path.join(root, '.git'))) return; + + try { + spawn.sync('git', ['init'], { cwd: root, stdio: 'ignore' }); + } catch { + // Git might not be installed or errored, ignore gracefully + } +} diff --git a/packages/create-servest/src/generators/aspnet/features/testing.ts b/packages/create-servest/src/generators/aspnet/features/testing.ts new file mode 100644 index 0000000..b7c6e6a --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/features/testing.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IAspNetOptions } from '../types'; + +export function applyTestingFeature(root: string, appName: string, options: IAspNetOptions): void { + const { testing, architecture } = options; + + if (architecture === 'enterprise') { + handleEnterpriseTests(root, appName, testing); + } +} + +function handleEnterpriseTests(root: string, appName: string, testing: string): void { + const testsDir = path.join(root, 'tests'); + const slnFiles = fs.readdirSync(root).filter((f) => f.endsWith('.sln')); + const slnPath = slnFiles.length > 0 ? path.join(root, slnFiles[0]) : null; + + if (testing === 'none') { + // 1. Remove tests directory + if (fs.existsSync(testsDir)) { + fs.rmSync(testsDir, { recursive: true, force: true }); + } + + // 2. Remove test references from .sln + if (slnPath && fs.existsSync(slnPath)) { + let slnContent = fs.readFileSync(slnPath, 'utf-8'); + slnContent = removeProjectFromSln(slnContent, `${appName}.UnitTests`); + slnContent = removeProjectFromSln(slnContent, `${appName}.IntegrationTests`); + fs.writeFileSync(slnPath, slnContent, 'utf-8'); + } + } else if (testing === 'unit') { + // Keep UnitTests, remove IntegrationTests + const integrationDir = path.join(testsDir, `${appName}.IntegrationTests`); + if (fs.existsSync(integrationDir)) { + fs.rmSync(integrationDir, { recursive: true, force: true }); + } + + if (slnPath && fs.existsSync(slnPath)) { + let slnContent = fs.readFileSync(slnPath, 'utf-8'); + slnContent = removeProjectFromSln(slnContent, `${appName}.IntegrationTests`); + fs.writeFileSync(slnPath, slnContent, 'utf-8'); + } + } +} + +function removeProjectFromSln(slnContent: string, projectName: string): string { + // Regex to match Project block + const projectRegex = new RegExp( + `Project\\("[^"]*"\\) = "${projectName}", "[^"]*", "{([^}]*)}"\\r?\\nEndProject\\r?\\n?`, + 'g', + ); + + let guid = ''; + const match = projectRegex.exec(slnContent); + if (match && match[1]) { + guid = match[1]; + } + + // Remove Project definition block + slnContent = slnContent.replace(projectRegex, ''); + + // Remove project configuration platforms if guid was found + if (guid) { + const guidRegex = new RegExp(`\\s*\\{${guid}\\}\\.[^\\r\\n]*\\r?\\n?`, 'gi'); + slnContent = slnContent.replace(guidRegex, ''); + } + + return slnContent; +} diff --git a/packages/create-servest/src/generators/aspnet/generator.ts b/packages/create-servest/src/generators/aspnet/generator.ts new file mode 100644 index 0000000..a63caa0 --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/generator.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { copyDir, toPascalCase, updateDotnetProject } from '../../utils/helper'; +import { applyAuthFeature } from './features/auth'; +import { applyAuthorizationFeature } from './features/authorization'; +import { applyDatabaseFeature } from './features/database'; +import { applyDockerFeature } from './features/docker'; +import { applyExampleFeature } from './features/example'; +import { applyGitFeature } from './features/git'; +import { applyTestingFeature } from './features/testing'; +import type { IAspNetOptions } from './types'; +import { validateAspNetOptions } from './validator'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export function generateAspNetProject(options: IAspNetOptions): { + success: boolean; + message?: string; +} { + // 1. Validation + const validation = validateAspNetOptions(options); + if (!validation.valid) { + throw new Error( + `Invalid ASP.NET options:\n${validation.errors.map((e) => ` - ${e}`).join('\n')}`, + ); + } + + // 2. Dry run check + if (options.dryRun) { + return { + success: true, + message: 'Dry run completed successfully. No files written.', + }; + } + + const appName = toPascalCase(options.projectName); + + // 3. Locate template + const templateMap: Record = { + enterprise: 'aspnet-enterprise-architecture', + mvc: 'aspnet-mvc', + 'minimal-api': 'aspnet-minimal-api', + }; + + const templateName = templateMap[options.architecture] || 'aspnet-enterprise-architecture'; + let templateDir = path.resolve(__dirname, '../templates/aspnet', templateName); + if (!fs.existsSync(templateDir)) { + templateDir = path.resolve(__dirname, '../../../templates/aspnet', templateName); + } + + if (!fs.existsSync(templateDir)) { + throw new Error(`Template directory not found: ${templateDir}`); + } + + fs.mkdirSync(options.root, { recursive: true }); + + // 4. Copy template files + copyDir(templateDir, options.root); + + // 5. Core PascalCase & TargetFramework update + updateDotnetProject(options.root, options.projectName); + + // 6. Apply composable features + applyDatabaseFeature(options.root, appName, options); + applyAuthFeature(options.root, appName, options); + applyAuthorizationFeature(options.root, appName, options); + applyTestingFeature(options.root, appName, options); + applyDockerFeature(options.root, appName, options); + applyExampleFeature(options.root, appName, options); + applyGitFeature(options.root, options.git); + + return { success: true }; +} diff --git a/packages/create-servest/src/generators/aspnet/index.ts b/packages/create-servest/src/generators/aspnet/index.ts new file mode 100644 index 0000000..d505753 --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './validator'; +export * from './prompts'; +export * from './generator'; diff --git a/packages/create-servest/src/generators/aspnet/prompts.ts b/packages/create-servest/src/generators/aspnet/prompts.ts new file mode 100644 index 0000000..7b7ec55 --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/prompts.ts @@ -0,0 +1,237 @@ +import { confirm, isCancel, select } from '@clack/prompts'; +import { cancelOperation } from '../../../../utils/sharedUtility'; +import type { + ArchitectureType, + AuthType, + AuthorizationType, + DatabaseType, + DotnetVersion, + ExampleType, + IAspNetOptions, + OrmType, + TestingType, +} from './types'; + +export async function promptAspNetOptions( + projectName: string, + targetDir: string, + root: string, + existingOptions?: Partial, +): Promise { + // 1. Architecture + let architecture = existingOptions?.architecture; + if (!architecture) { + const archResult = await select({ + message: 'Select an architecture:', + options: [ + { + value: 'enterprise', + label: 'Enterprise Architecture', + hint: 'Clean/Onion architecture with CQRS, MediatR, and separation of concerns', + }, + { + value: 'mvc', + label: 'MVC Layered Web API', + hint: 'Controller-based layered API (Controllers, Services, Models, DTOs)', + }, + { + value: 'minimal-api', + label: 'Minimal API', + hint: 'Modern high-performance minimal API with Route Groups', + }, + ], + }); + if (isCancel(archResult)) return cancelOperation() as never; + architecture = archResult as ArchitectureType; + } + + // 2. .NET Version + let dotnetVersion = existingOptions?.dotnetVersion; + if (!dotnetVersion) { + const dotnetResult = await select({ + message: 'Select .NET version:', + options: [ + { value: 'auto', label: 'Auto Detect', hint: 'Detect installed .NET SDK automatically' }, + { value: '10', label: '.NET 10', hint: 'Modern LTS / Preview' }, + { value: '9', label: '.NET 9', hint: 'Standard STS release' }, + { value: '8', label: '.NET 8', hint: 'LTS release' }, + ], + }); + if (isCancel(dotnetResult)) return cancelOperation() as never; + dotnetVersion = dotnetResult as DotnetVersion; + } + + // 3. Database + let database = existingOptions?.database; + if (!database) { + const dbResult = await select({ + message: 'Select a database:', + options: [ + { + value: 'sqlite', + label: 'SQLite', + hint: 'Zero setup, file-based database (recommended for fast start)', + }, + { + value: 'postgres', + label: 'PostgreSQL', + hint: 'Production-ready relational database via Npgsql', + }, + { value: 'sqlserver', label: 'SQL Server', hint: 'Microsoft SQL Server provider' }, + { value: 'mysql', label: 'MySQL', hint: 'MySQL relational database via Pomelo' }, + { value: 'none', label: 'None', hint: 'No database or persistent store' }, + ], + }); + if (isCancel(dbResult)) return cancelOperation() as never; + database = dbResult as DatabaseType; + } + + // 4. ORM + let orm = existingOptions?.orm; + if (!orm) { + if (database === 'none') { + orm = 'none'; + } else { + const ormResult = await select({ + message: 'Select an ORM:', + options: [ + { + value: 'ef', + label: 'Entity Framework Core', + hint: 'Full-featured ORM with migrations', + }, + { value: 'none', label: 'None', hint: 'Raw ADO.NET or custom data access' }, + ], + }); + if (isCancel(ormResult)) return cancelOperation() as never; + orm = ormResult as OrmType; + } + } + + // 5. Authentication + let auth = existingOptions?.auth; + if (!auth) { + const authOptions = [ + { value: 'none', label: 'None', hint: 'No authentication' }, + { + value: 'jwt', + label: 'JWT (JSON Web Token)', + hint: 'Bearer token authentication with TokenService', + }, + ]; + if (database !== 'none') { + authOptions.push({ + value: 'identity', + label: 'ASP.NET Core Identity', + hint: 'Built-in Identity system with EF Core stores', + }); + } + const authResult = await select({ + message: 'Select authentication:', + options: authOptions, + }); + if (isCancel(authResult)) return cancelOperation() as never; + auth = authResult as AuthType; + } + + // 6. Authorization + let authorization = existingOptions?.authorization; + if (!authorization) { + if (auth === 'none') { + authorization = 'none'; + } else { + const authzResult = await select({ + message: 'Select authorization:', + options: [ + { value: 'none', label: 'None', hint: 'Standard authentication only' }, + { + value: 'rbac', + label: 'Role-Based Access Control (RBAC)', + hint: 'Roles, Permissions & Policies', + }, + ], + }); + if (isCancel(authzResult)) return cancelOperation() as never; + authorization = authzResult as AuthorizationType; + } + } + + // 7. Docker + let docker = existingOptions?.docker; + if (docker === undefined) { + const dockerResult = await confirm({ + message: 'Include Docker containerization (Dockerfile & .dockerignore)?', + initialValue: true, + }); + if (isCancel(dockerResult)) return cancelOperation() as never; + docker = Boolean(dockerResult); + } + + // 8. Testing + let testing = existingOptions?.testing; + if (!testing) { + const testingResult = await select({ + message: 'Select testing suite:', + options: [ + { + value: 'unit-integration', + label: 'Unit + Integration Tests', + hint: 'xUnit, Moq, FluentAssertions, WebApplicationFactory', + }, + { value: 'unit', label: 'Unit Tests Only', hint: 'xUnit, Moq, FluentAssertions' }, + { value: 'none', label: 'None', hint: 'No test projects' }, + ], + }); + if (isCancel(testingResult)) return cancelOperation() as never; + testing = testingResult as TestingType; + } + + // 9. Git + let git = existingOptions?.git; + if (git === undefined) { + const gitResult = await confirm({ + message: 'Initialize a new Git repository?', + initialValue: true, + }); + if (isCancel(gitResult)) return cancelOperation() as never; + git = Boolean(gitResult); + } + + // 10. Example feature + let example = existingOptions?.example; + if (!example) { + const exampleResult = await select({ + message: 'Include example feature?', + options: [ + { + value: 'sample', + label: 'Sample Item (CRUD)', + hint: 'Pre-built working entity, controller/endpoint, and tests', + }, + { + value: 'none', + label: 'None (Clean Slate)', + hint: 'Pristine project structure with zero sample entities', + }, + ], + }); + if (isCancel(exampleResult)) return cancelOperation() as never; + example = exampleResult as ExampleType; + } + + return { + projectName, + targetDir, + root, + architecture, + dotnetVersion, + database, + orm, + auth, + authorization, + docker, + testing, + git, + example, + }; +} diff --git a/packages/create-servest/src/generators/aspnet/types.ts b/packages/create-servest/src/generators/aspnet/types.ts new file mode 100644 index 0000000..6fe257b --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/types.ts @@ -0,0 +1,33 @@ +export type ArchitectureType = 'enterprise' | 'mvc' | 'minimal-api'; +export type DotnetVersion = 'auto' | '8' | '9' | '10'; +export type DatabaseType = 'sqlite' | 'postgres' | 'sqlserver' | 'mysql' | 'none'; +export type OrmType = 'ef' | 'dapper' | 'none'; +export type AuthType = 'none' | 'jwt' | 'identity'; +export type AuthorizationType = 'none' | 'rbac'; +export type TestingType = 'none' | 'unit' | 'unit-integration'; +export type ExampleType = 'sample' | 'none'; + +export interface IAspNetOptions { + projectName: string; + targetDir: string; + root: string; + architecture: ArchitectureType; + dotnetVersion: DotnetVersion; + targetFramework?: string; + database: DatabaseType; + orm: OrmType; + auth: AuthType; + authorization: AuthorizationType; + docker: boolean; + testing: TestingType; + git: boolean; + example: ExampleType; + dryRun?: boolean; + nonInteractive?: boolean; +} + +export interface IValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/packages/create-servest/src/generators/aspnet/validator.ts b/packages/create-servest/src/generators/aspnet/validator.ts new file mode 100644 index 0000000..c457e1b --- /dev/null +++ b/packages/create-servest/src/generators/aspnet/validator.ts @@ -0,0 +1,88 @@ +import spawn from 'cross-spawn'; +import type { IAspNetOptions, IValidationResult } from './types'; + +export function checkInstalledDotnetVersions(): number[] { + try { + const result = spawn.sync('dotnet', ['--list-sdks'], { encoding: 'utf-8' }); + if (result.status === 0 && result.stdout) { + const versions: number[] = []; + const lines = result.stdout.split('\n'); + for (const line of lines) { + const match = /^(\d+)\./.exec(line.trim()); + if (match && match[1]) { + const major = parseInt(match[1], 10); + if (!versions.includes(major)) { + versions.push(major); + } + } + } + return versions; + } + } catch { + // fallback + } + return [9]; +} + +export function validateAspNetOptions(options: IAspNetOptions): IValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // 1. Architecture validation + if (!['enterprise', 'mvc', 'minimal-api'].includes(options.architecture)) { + errors.push( + `Invalid architecture: "${options.architecture}". Must be 'enterprise', 'mvc', or 'minimal-api'.`, + ); + } + + // 2. ORM & Database validation + if (options.orm === 'dapper') { + errors.push( + 'Dapper ORM support is currently in development. Please use Entity Framework Core (--orm ef) or --orm none.', + ); + } + + if (options.database === 'none' && options.orm === 'ef') { + errors.push( + "Database is set to 'none', but ORM is set to 'ef'. Please set ORM to 'none' or choose a database (sqlite, postgres, sqlserver, mysql).", + ); + } + + // 3. Auth & Database validation + if (options.auth === 'identity') { + if (options.database === 'none') { + errors.push( + 'ASP.NET Core Identity requires a database provider. Please select a database (sqlite, postgres, sqlserver, mysql) or choose JWT authentication.', + ); + } + if (options.architecture === 'minimal-api') { + warnings.push( + 'ASP.NET Core Identity with Minimal API uses standard endpoints. Enterprise or MVC is recommended for Identity.', + ); + } + } + + // 4. Authorization validation + if (options.authorization === 'rbac' && options.auth === 'none') { + errors.push( + 'RBAC authorization requires authentication. Please select JWT (--auth jwt) or Identity (--auth identity), or set authorization to none.', + ); + } + + // 5. .NET Version validation + if (options.dotnetVersion !== 'auto') { + const requested = parseInt(options.dotnetVersion, 10); + const installed = checkInstalledDotnetVersions(); + if (installed.length > 0 && !installed.includes(requested)) { + warnings.push( + `Requested .NET ${requested}, but installed SDKs found: [${installed.join(', ')}]. .NET will target net${requested}.0.`, + ); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} diff --git a/packages/create-servest/src/index.ts b/packages/create-servest/src/index.ts index e5f4f81..5be791d 100644 --- a/packages/create-servest/src/index.ts +++ b/packages/create-servest/src/index.ts @@ -10,17 +10,32 @@ import mri from 'mri'; // Local utilities import { getIServestConfig } from '../../servest-addons/src/utils/createFile'; -import { green, red, yellow } from '../../utils/colors'; +import { cyan, green, red, yellow } from '../../utils/colors'; import { cancelOperation, detectPkgManager } from '../../utils/sharedUtility'; +import { + type ArchitectureType, + type AuthType, + type AuthorizationType, + type DatabaseType, + type DotnetVersion, + type ExampleType, + type IAspNetOptions, + type OrmType, + type TestingType, + generateAspNetProject, + promptAspNetOptions, +} from './generators/aspnet'; import { ALL_TEMPLATES, FRAMEWORKS, helpMessage } from './utils'; import { copyDir, emptyDir, formatTargetDir, + getDotnetTargetFramework, getFullCustomCommand, isEmpty, isValidPackageName, pkgFromUserAgent, + toPascalCase, toValidPackageName, updatePackageName, } from './utils/helper'; @@ -30,32 +45,92 @@ const defaultTargetDir = 'servest-project'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// CLI args -const argv = mri(process.argv.slice(2), { - alias: { h: 'help', t: 'template', a: 'addons' }, - boolean: ['help', 'overwrite', 'h'], - string: ['template', 'addons'], +// CLI raw args & parsed +const rawArgs = process.argv.slice(2); +const argv = mri(rawArgs, { + alias: { h: 'help', t: 'template', a: 'addons', v: 'version' }, + boolean: [ + 'help', + 'overwrite', + 'h', + 'version', + 'v', + 'dryRun', + 'dry-run', + 'nonInteractive', + 'non-interactive', + 'docker', + 'git', + ], + string: [ + 'template', + 'addons', + 'framework', + 'dotnet', + 'database', + 'orm', + 'auth', + 'authorization', + 'tests', + 'example', + 'config', + ], }); +function normalizeTemplate(name?: string): string | undefined { + if (!name) return undefined; + const lower = name.trim().toLowerCase(); + if (lower === 'enterprise' || lower === 'aspnet-enterprise') + return 'aspnet-enterprise-architecture'; + if (lower === 'mvc' || lower === 'aspnet-mvc') return 'aspnet-mvc'; + if (lower === 'minimal' || lower === 'minimal-api' || lower === 'aspnet-minimal') + return 'aspnet-minimal-api'; + return name.trim(); +} + async function init() { - intro('Create Servest project'); + const isHelp = Boolean(argv.help || argv.h); + if (isHelp) { + console.log(helpMessage); + return; + } + + const isVersion = Boolean(argv.version || argv.v); + if (isVersion) { + try { + const pkgPath = path.resolve(__dirname, '../package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + console.log(`v${pkg.version}`); + } catch { + console.log('v2.1.0'); + } + return; + } const argTargetDir = argv._[0] ? formatTargetDir(String(argv._[0])) : undefined; - const argTemplate = argv.template; + let template = normalizeTemplate(argv.template); const argOverwrite = argv.overwrite; const addonsArg = argv.a || argv.addons; - const help = argv.help || argv.h; - if (help) { - console.log(helpMessage); - return; - } + const hasExplicitNonInteractive = Boolean(argv.nonInteractive || argv['non-interactive']); + const isNonInteractive = Boolean( + hasExplicitNonInteractive || + (!process.stdin.isTTY && Boolean(template) && ALL_TEMPLATES.includes(template)), + ); + const isDryRun = Boolean(argv.dryRun || argv['dry-run']); - const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); + if (!isNonInteractive) { + intro('Create Servest project'); + } // 1️⃣ Targeting directory let targetDir = argTargetDir; if (!targetDir) { + if (isNonInteractive) { + return cancelOperation( + 'ERROR: Project directory name is required in non-interactive mode. (e.g. npx create-servest my-api --non-interactive)', + ); + } const projectName = await text({ message: 'Project name:', defaultValue: defaultTargetDir, @@ -74,62 +149,87 @@ async function init() { // 2️⃣ Handling existing directory if (fs.existsSync(root) && !isEmpty(root)) { - const overwrite = argOverwrite - ? 'yes' - : await select({ - message: `${ - targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"` - } is not empty. Choose how to proceed:`, - options: [ - { value: 'no', label: red('Cancel operation') }, - { value: 'yes', label: yellow('Remove existing files and continue') }, - { value: 'ignore', label: green('Ignore files and continue') }, - ], - }); - if (isCancel(overwrite)) return cancelOperation(); - switch (overwrite) { - case 'no': - return cancelOperation(); - case 'yes': - emptyDir(root); - break; - case 'ignore': - break; + if (isNonInteractive) { + if (!argOverwrite) { + return cancelOperation( + `ERROR: Target directory "${targetDir}" already exists and is not empty. Use --overwrite to replace existing files.`, + ); + } + emptyDir(root); + } else { + const overwrite = argOverwrite + ? 'yes' + : await select({ + message: `${ + targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"` + } is not empty. Choose how to proceed:`, + options: [ + { value: 'no', label: red('Cancel operation') }, + { value: 'yes', label: yellow('Remove existing files and continue') }, + { value: 'ignore', label: green('Ignore files and continue') }, + ], + }); + if (isCancel(overwrite)) return cancelOperation(); + switch (overwrite) { + case 'no': + return cancelOperation(); + case 'yes': + emptyDir(root); + break; + case 'ignore': + break; + } } } - fs.mkdirSync(root, { recursive: true }); + if (!isDryRun) { + fs.mkdirSync(root, { recursive: true }); + } - // 3️⃣ validating package name + // 3️⃣ Validating package name let packageName = path.basename(path.resolve(root)); if (!isValidPackageName(packageName)) { const suggestedName = toValidPackageName(packageName); - const packageNameResult = await text({ - message: 'Package name:', - defaultValue: suggestedName, - placeholder: suggestedName, - validate(dir) { - if (!isValidPackageName(dir)) - return 'Invalid package name: only lowercase letters, numbers, hyphens (-), and underscores (_) are allowed'; - }, - }); - if (isCancel(packageNameResult)) return cancelOperation(); - packageName = packageNameResult; + if (isNonInteractive) { + packageName = suggestedName; + } else { + const packageNameResult = await text({ + message: 'Package name:', + defaultValue: suggestedName, + placeholder: suggestedName, + validate(dir) { + if (!isValidPackageName(dir)) + return 'Invalid package name: only lowercase letters, numbers, hyphens (-), and underscores (_) are allowed'; + }, + }); + if (isCancel(packageNameResult)) return cancelOperation(); + packageName = packageNameResult; + } } + const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); + // 4️⃣ Determine template - let template = argv.template?.trim(); let hasInvalidArgTemplate = false; if (template && !ALL_TEMPLATES.includes(template)) { + if (hasExplicitNonInteractive) { + return cancelOperation( + `ERROR: "${argv.template}" is not a valid template. Valid templates: ${ALL_TEMPLATES.join(', ')}`, + ); + } template = undefined; hasInvalidArgTemplate = true; } if (!template) { + if (hasExplicitNonInteractive) { + return cancelOperation('ERROR: --template is required when using --non-interactive mode.'); + } + // Selecting a framework const framework = await select({ message: hasInvalidArgTemplate - ? `"${argTemplate}" isn't a valid template. Please choose from below: ` + ? `"${argv.template}" isn't a valid template. Please choose from below: ` : 'Select a framework:', options: FRAMEWORKS.map((f) => ({ label: f.color(f.name), @@ -159,11 +259,126 @@ async function init() { template = variant as string; } - // 5️⃣ Running custom command if exists - const pkgManager = detectPkgManager(); + // 5️⃣ ASP.NET Core Composable Workflow + if (template.startsWith('aspnet')) { + const arch: ArchitectureType = + template === 'aspnet-mvc' + ? 'mvc' + : template === 'aspnet-minimal-api' + ? 'minimal-api' + : 'enterprise'; + + // Parse options from CLI flags or fallback + const cliOptions: Partial = { + architecture: arch, + dotnetVersion: (argv.dotnet as DotnetVersion) || undefined, + database: (argv.database as DatabaseType) || undefined, + orm: (argv.orm as OrmType) || undefined, + auth: (argv.auth as AuthType) || undefined, + authorization: (argv.authorization as AuthorizationType) || undefined, + docker: rawArgs.includes('--no-docker') + ? false + : rawArgs.includes('--docker') + ? true + : undefined, + testing: (argv.tests as TestingType) || undefined, + git: rawArgs.includes('--no-git') ? false : rawArgs.includes('--git') ? true : undefined, + example: (argv.example as ExampleType) || undefined, + }; + + let aspNetOptions: IAspNetOptions; + + if (isNonInteractive) { + aspNetOptions = { + projectName: packageName, + targetDir, + root, + architecture: arch, + dotnetVersion: cliOptions.dotnetVersion || 'auto', + database: cliOptions.database || 'sqlite', + orm: cliOptions.orm || (cliOptions.database === 'none' ? 'none' : 'ef'), + auth: cliOptions.auth || 'none', + authorization: cliOptions.authorization || 'none', + docker: cliOptions.docker !== undefined ? cliOptions.docker : true, + testing: cliOptions.testing || 'unit-integration', + git: cliOptions.git !== undefined ? cliOptions.git : true, + example: cliOptions.example || 'sample', + dryRun: isDryRun, + nonInteractive: true, + }; + } else { + aspNetOptions = await promptAspNetOptions(packageName, targetDir, root, cliOptions); + aspNetOptions.dryRun = isDryRun; + } - console.log(pkgManager); + if (isDryRun) { + log.step(cyan('ℹ️ Dry Run - Proposed Project Configuration:')); + console.log(` + Project: ${toPascalCase(packageName)} + Framework: ASP.NET Core + Architecture: ${aspNetOptions.architecture} + .NET Version: ${aspNetOptions.dotnetVersion} + Database: ${aspNetOptions.database} + ORM: ${aspNetOptions.orm} + Authentication: ${aspNetOptions.auth} + Authorization: ${aspNetOptions.authorization} + Docker: ${aspNetOptions.docker ? 'Enabled' : 'Disabled'} + Tests: ${aspNetOptions.testing} + Git: ${aspNetOptions.git ? 'Initialized' : 'Skipped'} + Example: ${aspNetOptions.example} +`); + outro(green('✨ Dry run complete. No files were written.')); + return; + } + log.step(green(`ℹ️ Scaffolding project in ${root}...`)); + + try { + generateAspNetProject(aspNetOptions); + } catch (err: any) { + return cancelOperation(err.message || String(err)); + } + + const appName = toPascalCase(packageName); + const targetFw = getDotnetTargetFramework(); + const cdProjectName = path.relative(cwd, root); + const cdCommand = + root !== cwd + ? `cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}` + : ''; + const runCmd = + aspNetOptions.architecture === 'enterprise' + ? `dotnet watch --project src/${appName}.API` + : 'dotnet watch'; + + console.log(` + ╭─────────────────────────────────────────────╮ + │ 🎉 Servest Project Created │ + ╰─────────────────────────────────────────────╯ + + Project: ${appName} + Framework: ASP.NET Core + Architecture: ${aspNetOptions.architecture} + .NET Version: ${targetFw} + Database: ${aspNetOptions.database} + ORM: ${aspNetOptions.orm} + Authentication: ${aspNetOptions.auth} + Authorization: ${aspNetOptions.authorization} + Docker: ${aspNetOptions.docker ? 'Enabled' : 'Disabled'} + Tests: ${aspNetOptions.testing} + Git: ${aspNetOptions.git ? 'Initialized' : 'Skipped'} + + Next steps: + ${cdCommand} + dotnet restore + ${runCmd} +`); + outro(green('Done. Happy coding!')); + return; + } + + // 6️⃣ Running custom command if exists (for external frameworks) + const pkgManager = detectPkgManager(); const { customCommand } = FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.value === template) ?? {}; @@ -185,7 +400,7 @@ async function init() { log.step(green(`ℹ️ Scaffolding project in ${root}...`)); - // 6️⃣ Copy template files + // 7️⃣ Copy template files (for Express, Fastify, Django) const variantEntry = FRAMEWORKS.flatMap((f) => f.variants.map((v) => ({ ...v, framework: f.value })), ).find((v) => v.value === template); @@ -202,23 +417,21 @@ async function init() { ); } - copyDir(templateDir, root); + if (!isDryRun) { + copyDir(templateDir, root); - if (template.includes('express') || template.includes('fastify')) { - updatePackageName(path.join(root, 'package.json'), packageName); + if (template.includes('express') || template.includes('fastify')) { + updatePackageName(path.join(root, 'package.json'), packageName); + } } - // 7️⃣ Running addons if specified - // Start with the -a/-addons value (if any) + // 8️⃣ Running addons if specified let addons: string[] = addonsArg ? addonsArg.split(/\s+/).filter(Boolean) : []; - - // Include any extra positional arguments after the project name addons = addons.concat(argv._.slice(1)); let inStallCommand = false; - if (addons.length > 0) { - // Installing dependencies + if (addons.length > 0 && !isDryRun) { const installResult = spawn.sync(pkgManager, ['install'], { cwd: root, stdio: 'inherit' }); if (installResult.status !== 0) { log.warn(red(`🚨 'npm install' failed. Please run '${pkgManager} install' manually.`)); @@ -228,7 +441,6 @@ async function init() { log.success(green(`✅ Dependencies installed successfully!`)); } - // Initializing servest const initResult = spawn.sync('npx', ['servest@latest', 'init'], { cwd: root, stdio: 'inherit', @@ -242,11 +454,8 @@ async function init() { process.exit(initResult.status ?? 1); } - // Checking if config exists const servestConfig = getIServestConfig(root); - if (servestConfig) { - // Running all addons in a single command const { status } = spawn.sync('npx', ['servest@latest', 'add', ...addons], { cwd: root, stdio: 'inherit', @@ -264,7 +473,7 @@ async function init() { } } - // 8️⃣ Displaying outro message + // 9️⃣ Outro for other templates const cdProjectName = path.relative(cwd, root); const cdCommand = root !== cwd ? `cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}` : ''; @@ -282,14 +491,11 @@ async function init() { ? ['npm run start'] : ['npm install', 'npm run start']; - let finalMessage = `'🎉 Done. Now run:', ${cdCommand}`; + const finalMessage = ['Done. Now run:', cdCommand, ...installCommands] + .filter(Boolean) + .map((line) => ` ${line}`) + .join('\n'); - if (template.includes('express') || template.includes('fastify')) { - finalMessage = ['Done. Now run:', cdCommand, ...installCommands] - .filter(Boolean) - .map((line) => ` ${line}`) - .join('\n'); - } outro(green(finalMessage)); } diff --git a/packages/create-servest/src/types/index.d.ts b/packages/create-servest/src/types/index.d.ts index 85c33d8..c2c1208 100644 --- a/packages/create-servest/src/types/index.d.ts +++ b/packages/create-servest/src/types/index.d.ts @@ -1,10 +1,28 @@ declare interface IArgv { template?: string; + t?: string; help?: boolean; h?: boolean; + version?: boolean; + v?: boolean; overwrite?: boolean; addons?: string; a?: string; + framework?: string; + dotnet?: string; + database?: string; + orm?: string; + auth?: string; + authorization?: string; + docker?: boolean; + tests?: string; + git?: boolean; + example?: string; + dryRun?: boolean; + 'dry-run'?: boolean; + nonInteractive?: boolean; + 'non-interactive'?: boolean; + config?: string; } declare interface IVariant { diff --git a/packages/create-servest/src/utils/helper.ts b/packages/create-servest/src/utils/helper.ts index 37f7cbb..8dda8fd 100644 --- a/packages/create-servest/src/utils/helper.ts +++ b/packages/create-servest/src/utils/helper.ts @@ -1,10 +1,29 @@ import fs from 'node:fs'; import path from 'node:path'; +import spawn from 'cross-spawn'; export function formatTargetDir(targetDir: string): string { return targetDir.trim().replace(/[/\\]+$/g, ''); } +export function getDotnetTargetFramework(): string { + try { + const result = spawn.sync('dotnet', ['--version'], { encoding: 'utf-8' }); + if (result.status === 0 && result.stdout) { + const match = /^(\d+)\./.exec(result.stdout.trim()); + if (match && match[1]) { + const major = parseInt(match[1], 10); + if (major >= 8) { + return `net${major}.0`; + } + } + } + } catch { + // fallback + } + return 'net9.0'; +} + export function copy(src: string, dest: string): void { const stat = fs.statSync(src); if (stat.isDirectory()) { @@ -145,3 +164,109 @@ export function getFullCustomCommand(customCommand: string, pkgInfo?: IPkgInfo): }) ); } + +export function toPascalCase(str: string): string { + return str + .replace(/[^a-z0-9]+(.)/gi, (_, chr) => chr.toUpperCase()) + .replace(/^[a-z]/, (chr) => chr.toUpperCase()) + .replace(/[^a-z0-9]/gi, ''); +} + +export function updateDotnetProject(root: string, projectName: string): void { + const appName = toPascalCase(projectName); + const targetFramework = getDotnetTargetFramework(); + + // 1. Replace placeholder 'Template' and TargetFramework inside all text files + const replaceContent = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + replaceContent(fullPath); + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if ( + ['.cs', '.csproj', '.sln', '.json', '.md', '.env'].includes(ext) || + entry.name === 'Dockerfile' || + entry.name === '.dockerignore' || + entry.name === '.env' + ) { + let content = fs.readFileSync(fullPath, 'utf-8'); + content = content.replaceAll('Template', appName); + if (ext === '.csproj' && targetFramework !== 'net9.0') { + content = content.replaceAll( + 'net9.0', + `${targetFramework}`, + ); + } + fs.writeFileSync(fullPath, content, 'utf-8'); + } + } + } + }; + + replaceContent(root); + + // 2. Rename files and folders bottom-up safely + const collectRenames = (dir: string): { files: string[]; dirs: string[] } => { + const files: string[] = []; + const dirs: string[] = []; + + const walk = (current: string) => { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + if (entry.name.includes('Template')) { + dirs.push(fullPath); + } + } else if (entry.isFile()) { + if (entry.name.includes('Template')) { + files.push(fullPath); + } + } + } + }; + + walk(dir); + return { files, dirs }; + }; + + const safeRename = (oldPath: string, newPath: string) => { + for (let i = 0; i < 10; i++) { + try { + fs.renameSync(oldPath, newPath); + return; + } catch (err: any) { + if ((err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES') && i < 9) { + const waitTill = Date.now() + 50; + while (Date.now() < waitTill) { + // spin-wait for Windows file unlock + } + } else { + throw err; + } + } + } + }; + + const { files, dirs } = collectRenames(root); + + for (const filePath of files) { + const parent = path.dirname(filePath); + const base = path.basename(filePath); + const newPath = path.join(parent, base.replaceAll('Template', appName)); + if (filePath !== newPath && fs.existsSync(filePath)) { + safeRename(filePath, newPath); + } + } + + for (const dirPath of dirs) { + const parent = path.dirname(dirPath); + const base = path.basename(dirPath); + const newPath = path.join(parent, base.replaceAll('Template', appName)); + if (dirPath !== newPath && fs.existsSync(dirPath)) { + safeRename(dirPath, newPath); + } + } +} diff --git a/packages/create-servest/src/utils/index.ts b/packages/create-servest/src/utils/index.ts index 2d1b056..8a8bacf 100644 --- a/packages/create-servest/src/utils/index.ts +++ b/packages/create-servest/src/utils/index.ts @@ -1,4 +1,4 @@ -import { boldGreen, boldYellow, cyan, green, yellow } from '../../../utils/colors'; +import { boldGreen, boldYellow, cyan, green, magenta, yellow } from '../../../utils/colors'; export const FRAMEWORKS: IFramework[] = [ { @@ -104,6 +104,31 @@ export const FRAMEWORKS: IFramework[] = [ }, ], }, + { + name: 'ASP.NET Core', + value: 'aspnet', + color: magenta, + variants: [ + { + name: 'Enterprise Architecture', + value: 'aspnet-enterprise-architecture', + color: cyan, + framework: 'aspnet', + }, + { + name: 'MVC Layered Web API', + value: 'aspnet-mvc', + color: yellow, + framework: 'aspnet', + }, + { + name: 'Minimal API', + value: 'aspnet-minimal-api', + color: green, + framework: 'aspnet', + }, + ], + }, { name: 'Django', value: 'django', @@ -138,9 +163,25 @@ Usage: create-servest [OPTION]... [DIRECTORY] Create a new Servest backend project. With no arguments, start the CLI in interactive mode. -Options: - -t, --template NAME use a specific template +General Options: + -t, --template NAME use a specific template (e.g. enterprise, mvc, minimal-api) -h, --help show this help message + -v, --version show version number + --overwrite overwrite target directory if not empty + --non-interactive run without interactive prompts + --dry-run simulate project creation without writing files + -a, --addons NAME... add addons to the project + +ASP.NET Core Composable Options: + --dotnet VERSION target .NET SDK: auto, 10, 9, 8 (default: auto) + --database DB database provider: sqlite, postgres, sqlserver, mysql, none (default: sqlite) + --orm ORM data access ORM: ef, none (default: ef) + --auth AUTH authentication: none, jwt, identity (default: none) + --authorization AUTHZ authorization model: none, rbac (default: none) + --docker, --no-docker include Dockerfile & container setup (default: true) + --tests SUITE testing suite: unit-integration, unit, none (default: unit-integration) + --git, --no-git initialize git repository (default: true) + --example TYPE example feature: sample, none (default: sample) Available templates: ${yellow('express-basic-js express-basic-ts express-modular-esm')} @@ -149,6 +190,7 @@ ${yellow('express-modular-cjs express-modular-esm express-modular-ts')} ${yellow('fastify-basic-js fastify-basic-ts fastify-mvc-cjs')} ${yellow('fastify-mvc-esm fastify-mvc-ts fastify-plugin-cjs')} ${yellow('fastify-plugin-esm fastify-plugin-ts')} +${magenta('aspnet-enterprise-architecture (enterprise) aspnet-mvc (mvc) aspnet-minimal-api (minimal-api)')} `; // ${green('django-basic django-api django-channels django-celery')} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.dockerignore b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.dockerignore new file mode 100644 index 0000000..df2b981 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.dockerignore @@ -0,0 +1,12 @@ +**/.git +**/.vs +**/.idea +**/.vscode +**/bin +**/obj +**/TestResults +*.user +*.suo +*.db +*.db-shm +*.db-wal diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.env.example b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.env.example new file mode 100644 index 0000000..c438b80 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/.env.example @@ -0,0 +1,3 @@ +ASPNETCORE_ENVIRONMENT=Development +CONNECTION_STRING=Data Source=app.db +PORT=5000 diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Directory.Build.props b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Directory.Build.props new file mode 100644 index 0000000..7f8ac71 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Directory.Build.props @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Dockerfile b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Dockerfile new file mode 100644 index 0000000..bcab0fd --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project files and restore dependencies +COPY ["src/Template.API/Template.API.csproj", "src/Template.API/"] +COPY ["src/Template.Application/Template.Application.csproj", "src/Template.Application/"] +COPY ["src/Template.Domain/Template.Domain.csproj", "src/Template.Domain/"] +COPY ["src/Template.Infrastructure/Template.Infrastructure.csproj", "src/Template.Infrastructure/"] +COPY ["src/Template.Persistence/Template.Persistence.csproj", "src/Template.Persistence/"] + +RUN dotnet restore "src/Template.API/Template.API.csproj" + +# Copy source and publish +COPY . . +WORKDIR "/src/src/Template.API" +RUN dotnet publish "Template.API.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:5000 +EXPOSE 5000 + +ENTRYPOINT ["dotnet", "Template.API.dll"] diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/README.md b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/README.md new file mode 100644 index 0000000..0ad7b7b --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/README.md @@ -0,0 +1,41 @@ +# Template - Enterprise Clean Architecture + +Production-ready ASP.NET Core Clean Architecture backend with MediatR, FluentValidation, Entity Framework Core, and xUnit test suites. + +## 🏗️ Architecture Layers + +- **`src/Template.Domain`**: Business entities, Enums, Value Objects, Domain Exceptions (Zero external dependencies). +- **`src/Template.Application`**: Use cases, CQRS Commands/Queries, Behaviors, DTOs, and Service Interfaces. +- **`src/Template.Persistence`**: EF Core `ApplicationDbContext`, Entity Configurations, Migrations, and Repositories. +- **`src/Template.Infrastructure`**: External integrations, Identity/Authentication, and Date/Time providers. +- **`src/Template.API`**: REST Controllers, Middleware, Swagger UI, and Dependency Injection container. +- **`tests/Template.UnitTests`**: xUnit unit testing with Moq & FluentAssertions. +- **`tests/Template.IntegrationTests`**: xUnit integration testing with `WebApplicationFactory`. + +## 🚀 Getting Started + +### Prerequisites + +- [.NET 8.0 or .NET 9.0 SDK](https://dotnet.microsoft.com/download) + +### 1. Restore & Build + +```bash +dotnet restore +dotnet build +``` + +### 2. Run Automated Tests + +```bash +dotnet test +``` + +### 3. Run the API (Development Mode) + +```bash +dotnet watch --project src/Template.API +``` + +Once running, access Swagger UI at: +👉 **[http://localhost:5000/swagger](http://localhost:5000/swagger)** diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Template.sln b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Template.sln new file mode 100644 index 0000000..95a8f8e --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/Template.sln @@ -0,0 +1,67 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B2C3D4E5-F6A7-8B9C-0D1E-2F3A4B5C6D7E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.Domain", "src\Template.Domain\Template.Domain.csproj", "{C3D4E5F6-A7B8-9C0D-1E2F-3A4B5C6D7E8F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.Application", "src\Template.Application\Template.Application.csproj", "{D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F9A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.Infrastructure", "src\Template.Infrastructure\Template.Infrastructure.csproj", "{E5F6A7B8-C9D0-1E2F-3A4B-5C6D7E8F9A0B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.Persistence", "src\Template.Persistence\Template.Persistence.csproj", "{F6A7B8C9-D0E1-2F3A-4B5C-6D7E8F9A0B1C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.API", "src\Template.API\Template.API.csproj", "{A7B8C9D0-E1F2-3A4B-5C6D-7E8F9A0B1C2D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.UnitTests", "tests\Template.UnitTests\Template.UnitTests.csproj", "{B8C9D0E1-F2A3-4B5C-6D7E-8F9A0B1C2D3E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Template.IntegrationTests", "tests\Template.IntegrationTests\Template.IntegrationTests.csproj", "{C9D0E1F2-A3B4-5C6D-7E8F-9A0B1C2D3E4F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C3D4E5F6-A7B8-9C0D-1E2F-3A4B5C6D7E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9C0D-1E2F-3A4B5C6D7E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9C0D-1E2F-3A4B5C6D7E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9C0D-1E2F-3A4B5C6D7E8F}.Release|Any CPU.Build.0 = Release|Any CPU + {D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F9A}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F6A7B8-C9D0-1E2F-3A4B-5C6D7E8F9A0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C9D0-1E2F-3A4B-5C6D7E8F9A0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F6A7B8-C9D0-1E2F-3A4B-5C6D7E8F9A0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F6A7B8-C9D0-1E2F-3A4B-5C6D7E8F9A0B}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A7B8C9-D0E1-2F3A-4B5C-6D7E8F9A0B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A7B8C9-D0E1-2F3A-4B5C-6D7E8F9A0B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A7B8C9-D0E1-2F3A-4B5C-6D7E8F9A0B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A7B8C9-D0E1-2F3A-4B5C-6D7E8F9A0B1C}.Release|Any CPU.Build.0 = Release|Any CPU + {A7B8C9D0-E1F2-3A4B-5C6D-7E8F9A0B1C2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7B8C9D0-E1F2-3A4B-5C6D-7E8F9A0B1C2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7B8C9D0-E1F2-3A4B-5C6D-7E8F9A0B1C2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7B8C9D0-E1F2-3A4B-5C6D-7E8F9A0B1C2D}.Release|Any CPU.Build.0 = Release|Any CPU + {B8C9D0E1-F2A3-4B5C-6D7E-8F9A0B1C2D3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8C9D0E1-F2A3-4B5C-6D7E-8F9A0B1C2D3E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8C9D0E1-F2A3-4B5C-6D7E-8F9A0B1C2D3E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8C9D0E1-F2A3-4B5C-6D7E-8F9A0B1C2D3E}.Release|Any CPU.Build.0 = Release|Any CPU + {C9D0E1F2-A3B4-5C6D-7E8F-9A0B1C2D3E4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9D0E1F2-A3B4-5C6D-7E8F-9A0B1C2D3E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9D0E1F2-A3B4-5C6D-7E8F-9A0B1C2D3E4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9D0E1F2-A3B4-5C6D-7E8F-9A0B1C2D3E4F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C3D4E5F6-A7B8-9C0D-1E2F-3A4B5C6D7E8F} = {A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D} + {D4E5F6A7-B8C9-0D1E-2F3A-4B5C6D7E8F9A} = {A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D} + {E5F6A7B8-C9D0-1E2F-3A4B-5C6D7E8F9A0B} = {A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D} + {F6A7B8C9-D0E1-2F3A-4B5C-6D7E8F9A0B1C} = {A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D} + {A7B8C9D0-E1F2-3A4B-5C6D-7E8F9A0B1C2D} = {A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D} + {B8C9D0E1-F2A3-4B5C-6D7E-8F9A0B1C2D3E} = {B2C3D4E5-F6A7-8B9C-0D1E-2F3A4B5C6D7E} + {C9D0E1F2-A3B4-5C6D-7E8F-9A0B1C2D3E4F} = {B2C3D4E5-F6A7-8B9C-0D1E-2F3A4B5C6D7E} + EndGlobalSection +EndGlobal diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/_gitignore b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/_gitignore new file mode 100644 index 0000000..aa65093 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/_gitignore @@ -0,0 +1,22 @@ +## Build and IDE outputs +[Bb]in/ +[Oo]bj/ +.vs/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates + +## Database & Local Storage +*.db +*.db-shm +*.db-wal + +## Logs & Environment Secrets +*.log +appsettings.Production.json +appsettings.local.json +.env +.env.* +!.env.example diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/BaseApiController.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/BaseApiController.cs new file mode 100644 index 0000000..cb917e9 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/BaseApiController.cs @@ -0,0 +1,12 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Template.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public abstract class BaseApiController : ControllerBase +{ + private ISender? _mediator; + protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/SampleItemsController.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/SampleItemsController.cs new file mode 100644 index 0000000..5854016 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Controllers/SampleItemsController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Template.Application.Common.Models; +using Template.Application.Features.SampleItems.Commands.CreateSampleItem; +using Template.Application.Features.SampleItems.DTOs; +using Template.Application.Features.SampleItems.Queries.GetSampleItems; + +namespace Template.API.Controllers; + +public class SampleItemsController : BaseApiController +{ + [HttpGet] + [ProducesResponseType(typeof(Result>), StatusCodes.Status200OK)] + public async Task GetAll(CancellationToken cancellationToken) + { + var result = await Mediator.Send(new GetSampleItemsQuery(), cancellationToken); + return Ok(result); + } + + [HttpPost] + [ProducesResponseType(typeof(Result), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Create([FromBody] CreateSampleItemCommand command, CancellationToken cancellationToken) + { + var result = await Mediator.Send(command, cancellationToken); + return Ok(result); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Middleware/GlobalExceptionMiddleware.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..055041b --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Middleware/GlobalExceptionMiddleware.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Text.Json; +using FluentValidation; +using Template.Domain.Exceptions; + +namespace Template.API.Middleware; + +public class GlobalExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unhandled exception occurred: {Message}", ex.Message); + await HandleExceptionAsync(context, ex); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + + var response = new + { + Success = false, + Message = exception.Message, + Errors = (object?)null + }; + + switch (exception) + { + case ValidationException validationException: + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + var errors = validationException.Errors + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + response = new + { + Success = false, + Message = "Validation failed.", + Errors = (object?)errors + }; + break; + + case DomainException: + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + break; + + case KeyNotFoundException: + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + break; + + default: + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + response = new + { + Success = false, + Message = "An unexpected error occurred. Please try again later.", + Errors = (object?)null + }; + break; + } + + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + await context.Response.WriteAsync(json); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Program.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Program.cs new file mode 100644 index 0000000..ab0b42b --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Program.cs @@ -0,0 +1,51 @@ +using Template.API.Middleware; +using Template.Application; +using Template.Infrastructure; +using Template.Persistence; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Register Layers +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); +builder.Services.AddPersistence(builder.Configuration); + +// Add CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +app.UseMiddleware(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1"); + }); +} + +app.UseHttpsRedirection(); +app.UseCors("AllowAll"); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); + +// Required for Integration Tests +public partial class Program { } diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Properties/launchSettings.json b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Properties/launchSettings.json new file mode 100644 index 0000000..e51c43d --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Template.API.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Template.API.csproj new file mode 100644 index 0000000..a5b706f --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/Template.API.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + Template.API + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.Development.json b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.Development.json new file mode 100644 index 0000000..e203e94 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.json b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.json new file mode 100644 index 0000000..8acfb55 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.API/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Data Source=app.db" + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/LoggingBehavior.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..57acc47 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,28 @@ +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Template.Application.Common.Behaviors; + +public class LoggingBehavior : IPipelineBehavior + where TRequest : notnull +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation("Handling {RequestName}", requestName); + + var response = await next(); + + _logger.LogInformation("Handled {RequestName}", requestName); + + return response; + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/ValidationBehavior.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..58a03aa --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using MediatR; + +namespace Template.Application.Common.Behaviors; + +public class ValidationBehavior : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Any()) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Any()) + { + throw new ValidationException(failures); + } + } + + return await next(); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IApplicationDbContext.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IApplicationDbContext.cs new file mode 100644 index 0000000..d92db91 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IApplicationDbContext.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; +using Template.Domain.Entities; + +namespace Template.Application.Common.Interfaces; + +public interface IApplicationDbContext +{ + DbSet SampleItems { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IDateTimeProvider.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IDateTimeProvider.cs new file mode 100644 index 0000000..752f3b6 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Interfaces/IDateTimeProvider.cs @@ -0,0 +1,6 @@ +namespace Template.Application.Common.Interfaces; + +public interface IDateTimeProvider +{ + DateTime UtcNow { get; } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Models/Result.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Models/Result.cs new file mode 100644 index 0000000..d433a82 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Common/Models/Result.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Template.Application.Common.Models; + +public class Result +{ + public bool Succeeded { get; } + public T? Data { get; } + public string? Error { get; } + + [JsonConstructor] + public Result(bool succeeded, T? data, string? error) + { + Succeeded = succeeded; + Data = data; + Error = error; + } + + public static Result Success(T data) => new(true, data, null); + public static Result Failure(string error) => new(false, default, error); +} + +public class Result +{ + public bool Succeeded { get; } + public string? Error { get; } + + [JsonConstructor] + public Result(bool succeeded, string? error) + { + Succeeded = succeeded; + Error = error; + } + + public static Result Success() => new(true, null); + public static Result Failure(string error) => new(false, error); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/DependencyInjection.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/DependencyInjection.cs new file mode 100644 index 0000000..384e74b --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/DependencyInjection.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Template.Application.Common.Behaviors; + +namespace Template.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = Assembly.GetExecutingAssembly(); + + services.AddValidatorsFromAssembly(assembly); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(assembly); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + }); + + return services; + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommand.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommand.cs new file mode 100644 index 0000000..9c0172e --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommand.cs @@ -0,0 +1,34 @@ +using MediatR; +using Template.Application.Common.Interfaces; +using Template.Application.Common.Models; +using Template.Application.Features.SampleItems.DTOs; +using Template.Domain.Entities; + +namespace Template.Application.Features.SampleItems.Commands.CreateSampleItem; + +public record CreateSampleItemCommand(string Title, string? Description) : IRequest>; + +public class CreateSampleItemCommandHandler : IRequestHandler> +{ + private readonly IApplicationDbContext _context; + + public CreateSampleItemCommandHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task> Handle(CreateSampleItemCommand request, CancellationToken cancellationToken) + { + var entity = new SampleItem + { + Title = request.Title, + Description = request.Description + }; + + _context.SampleItems.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + var dto = new SampleItemDto(entity.Id, entity.Title, entity.Description, entity.Status, entity.CreatedAt); + return Result.Success(dto); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommandValidator.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommandValidator.cs new file mode 100644 index 0000000..961f7d1 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Commands/CreateSampleItem/CreateSampleItemCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Template.Application.Features.SampleItems.Commands.CreateSampleItem; + +public class CreateSampleItemCommandValidator : AbstractValidator +{ + public CreateSampleItemCommandValidator() + { + RuleFor(v => v.Title) + .NotEmpty().WithMessage("Title is required.") + .MaximumLength(200).WithMessage("Title must not exceed 200 characters."); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/DTOs/SampleItemDto.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/DTOs/SampleItemDto.cs new file mode 100644 index 0000000..7a3ef38 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/DTOs/SampleItemDto.cs @@ -0,0 +1,11 @@ +using Template.Domain.Enums; + +namespace Template.Application.Features.SampleItems.DTOs; + +public record SampleItemDto( + Guid Id, + string Title, + string? Description, + SampleStatus Status, + DateTime CreatedAt +); diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Queries/GetSampleItems/GetSampleItemsQuery.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Queries/GetSampleItems/GetSampleItemsQuery.cs new file mode 100644 index 0000000..1571012 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Features/SampleItems/Queries/GetSampleItems/GetSampleItemsQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Template.Application.Common.Interfaces; +using Template.Application.Common.Models; +using Template.Application.Features.SampleItems.DTOs; + +namespace Template.Application.Features.SampleItems.Queries.GetSampleItems; + +public record GetSampleItemsQuery : IRequest>>; + +public class GetSampleItemsQueryHandler : IRequestHandler>> +{ + private readonly IApplicationDbContext _context; + + public GetSampleItemsQueryHandler(IApplicationDbContext context) + { + _context = context; + } + + public async Task>> Handle(GetSampleItemsQuery request, CancellationToken cancellationToken) + { + var items = await _context.SampleItems + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new SampleItemDto(x.Id, x.Title, x.Description, x.Status, x.CreatedAt)) + .ToListAsync(cancellationToken); + + return Result>.Success(items); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Template.Application.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Template.Application.csproj new file mode 100644 index 0000000..bd3fef8 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Application/Template.Application.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + Template.Application + + + + + + + + + + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseAuditableEntity.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseAuditableEntity.cs new file mode 100644 index 0000000..00755f3 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseAuditableEntity.cs @@ -0,0 +1,9 @@ +namespace Template.Domain.Common; + +public abstract class BaseAuditableEntity : BaseEntity +{ + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? CreatedBy { get; set; } + public DateTime? LastModifiedAt { get; set; } + public string? LastModifiedBy { get; set; } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseEntity.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..3408f38 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Common/BaseEntity.cs @@ -0,0 +1,6 @@ +namespace Template.Domain.Common; + +public abstract class BaseEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Entities/SampleItem.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Entities/SampleItem.cs new file mode 100644 index 0000000..80d6639 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Entities/SampleItem.cs @@ -0,0 +1,11 @@ +using Template.Domain.Common; +using Template.Domain.Enums; + +namespace Template.Domain.Entities; + +public class SampleItem : BaseAuditableEntity +{ + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public SampleStatus Status { get; set; } = SampleStatus.Pending; +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Enums/SampleStatus.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Enums/SampleStatus.cs new file mode 100644 index 0000000..5cf182e --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Enums/SampleStatus.cs @@ -0,0 +1,9 @@ +namespace Template.Domain.Enums; + +public enum SampleStatus +{ + Pending = 0, + Active = 1, + Completed = 2, + Archived = 3 +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Exceptions/DomainException.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..14c063a --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Exceptions/DomainException.cs @@ -0,0 +1,12 @@ +namespace Template.Domain.Exceptions; + +public class DomainException : Exception +{ + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Template.Domain.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Template.Domain.csproj new file mode 100644 index 0000000..60a49ad --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Domain/Template.Domain.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + Template.Domain + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/DependencyInjection.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..454a23a --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/DependencyInjection.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Template.Application.Common.Interfaces; +using Template.Infrastructure.Services; + +namespace Template.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddTransient(); + return services; + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Services/DateTimeProvider.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Services/DateTimeProvider.cs new file mode 100644 index 0000000..61248fd --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Services/DateTimeProvider.cs @@ -0,0 +1,8 @@ +using Template.Application.Common.Interfaces; + +namespace Template.Infrastructure.Services; + +public class DateTimeProvider : IDateTimeProvider +{ + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Template.Infrastructure.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Template.Infrastructure.csproj new file mode 100644 index 0000000..ae06bfb --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Infrastructure/Template.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + Template.Infrastructure + + + + + + + + + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Configurations/SampleItemConfiguration.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Configurations/SampleItemConfiguration.cs new file mode 100644 index 0000000..a247c45 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Configurations/SampleItemConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Template.Domain.Entities; + +namespace Template.Persistence.Configurations; + +public class SampleItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Title) + .IsRequired() + .HasMaxLength(200); + + builder.Property(x => x.Description) + .HasMaxLength(1000); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Context/ApplicationDbContext.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Context/ApplicationDbContext.cs new file mode 100644 index 0000000..ecbf6f0 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Context/ApplicationDbContext.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Template.Application.Common.Interfaces; +using Template.Domain.Entities; + +namespace Template.Persistence.Context; + +public class ApplicationDbContext : DbContext, IApplicationDbContext +{ + public ApplicationDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet SampleItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/DependencyInjection.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/DependencyInjection.cs new file mode 100644 index 0000000..1d6d3c7 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/DependencyInjection.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Template.Application.Common.Interfaces; +using Template.Persistence.Context; + +namespace Template.Persistence; + +public static class DependencyInjection +{ + public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection") ?? "Data Source=app.db"; + + services.AddDbContext(options => + options.UseSqlite(connectionString)); + + services.AddScoped(provider => + provider.GetRequiredService()); + + return services; + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Template.Persistence.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Template.Persistence.csproj new file mode 100644 index 0000000..774c4f3 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/src/Template.Persistence/Template.Persistence.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + Template.Persistence + + + + + + + + + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Controllers/SampleItemsControllerTests.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Controllers/SampleItemsControllerTests.cs new file mode 100644 index 0000000..7e9ec64 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Controllers/SampleItemsControllerTests.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Template.Application.Common.Models; +using Template.Application.Features.SampleItems.Commands.CreateSampleItem; +using Template.Application.Features.SampleItems.DTOs; +using Xunit; + +namespace Template.IntegrationTests.Controllers; + +public class SampleItemsControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SampleItemsControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetAll_ShouldReturnSuccessStatusCode() + { + var response = await _client.GetAsync("/api/sampleitems"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Create_ValidCommand_ShouldReturnSuccess() + { + var command = new CreateSampleItemCommand("Integration Test Item", "Description from test"); + var response = await _client.PostAsJsonAsync("/api/sampleitems", command); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync>(); + result.Should().NotBeNull(); + result!.Succeeded.Should().BeTrue(); + result.Data!.Title.Should().Be("Integration Test Item"); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/CustomWebApplicationFactory.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..26b2263 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/CustomWebApplicationFactory.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Template.Persistence.Context; + +namespace Template.IntegrationTests; + +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var descriptors = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(ApplicationDbContext)).ToList(); + + foreach (var descriptor in descriptors) + { + services.Remove(descriptor); + } + + var inMemoryProvider = new ServiceCollection() + .AddEntityFrameworkInMemoryDatabase() + .BuildServiceProvider(); + + services.AddDbContext(options => + { + options.UseInMemoryDatabase("IntegrationTestsDb"); + options.UseInternalServiceProvider(inMemoryProvider); + }); + + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Template.IntegrationTests.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Template.IntegrationTests.csproj new file mode 100644 index 0000000..7b2cf14 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.IntegrationTests/Template.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + Template.IntegrationTests + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Application/CreateSampleItemCommandHandlerTests.cs b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Application/CreateSampleItemCommandHandlerTests.cs new file mode 100644 index 0000000..ddc66ce --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Application/CreateSampleItemCommandHandlerTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; +using Template.Application.Common.Interfaces; +using Template.Application.Features.SampleItems.Commands.CreateSampleItem; +using Template.Domain.Entities; +using Xunit; + +namespace Template.UnitTests.Application; + +public class CreateSampleItemCommandHandlerTests +{ + [Fact] + public async Task Handle_ValidCommand_ShouldCreateSampleItem() + { + // Arrange + var mockSet = new Mock>(); + var mockContext = new Mock(); + mockContext.Setup(c => c.SampleItems).Returns(mockSet.Object); + mockContext.Setup(c => c.SaveChangesAsync(It.IsAny())).ReturnsAsync(1); + + var handler = new CreateSampleItemCommandHandler(mockContext.Object); + var command = new CreateSampleItemCommand("Test Title", "Test Description"); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Title.Should().Be("Test Title"); + mockSet.Verify(m => m.Add(It.IsAny()), Times.Once); + mockContext.Verify(m => m.SaveChangesAsync(It.IsAny()), Times.Once); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Template.UnitTests.csproj b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Template.UnitTests.csproj new file mode 100644 index 0000000..90a1264 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-enterprise-architecture/tests/Template.UnitTests/Template.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + Template.UnitTests + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/.dockerignore b/packages/create-servest/templates/aspnet/aspnet-minimal-api/.dockerignore new file mode 100644 index 0000000..7e1b719 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/.dockerignore @@ -0,0 +1,11 @@ +**/.git +**/.vs +**/.idea +**/.vscode +**/bin +**/obj +*.user +*.suo +*.db +*.db-shm +*.db-wal diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/.env.example b/packages/create-servest/templates/aspnet/aspnet-minimal-api/.env.example new file mode 100644 index 0000000..c438b80 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/.env.example @@ -0,0 +1,3 @@ +ASPNETCORE_ENVIRONMENT=Development +CONNECTION_STRING=Data Source=app.db +PORT=5000 diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/DTOs/SampleItemDto.cs b/packages/create-servest/templates/aspnet/aspnet-minimal-api/DTOs/SampleItemDto.cs new file mode 100644 index 0000000..ded136c --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/DTOs/SampleItemDto.cs @@ -0,0 +1,4 @@ +namespace Template.DTOs; + +public record SampleItemDto(Guid Id, string Title, string? Description, DateTime CreatedAt); +public record CreateSampleItemDto(string Title, string? Description); diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Data/ApplicationDbContext.cs b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..672af7e --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Data/ApplicationDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Template.Models; + +namespace Template.Data; + +public class ApplicationDbContext : DbContext +{ + public ApplicationDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet SampleItems => Set(); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Dockerfile b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Dockerfile new file mode 100644 index 0000000..518e84f --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project file and restore dependencies +COPY ["Template.csproj", "./"] +RUN dotnet restore "Template.csproj" + +# Copy source and publish +COPY . . +RUN dotnet publish "Template.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:5000 +EXPOSE 5000 + +ENTRYPOINT ["dotnet", "Template.dll"] diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Endpoints/SampleEndpoints.cs b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Endpoints/SampleEndpoints.cs new file mode 100644 index 0000000..23c7df9 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Endpoints/SampleEndpoints.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Template.Data; +using Template.DTOs; +using Template.Models; + +namespace Template.Endpoints; + +public static class SampleEndpoints +{ + public static RouteGroupBuilder MapSampleEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/api/sampleitems") + .WithTags("SampleItems"); + + group.MapGet("/", async (ApplicationDbContext db, CancellationToken ct) => + { + var items = await db.SampleItems + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new SampleItemDto(x.Id, x.Title, x.Description, x.CreatedAt)) + .ToListAsync(ct); + + return Results.Ok(items); + }) + .WithName("GetSampleItems") + .Produces>(StatusCodes.Status200OK); + + group.MapPost("/", async (CreateSampleItemDto dto, ApplicationDbContext db, CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(dto.Title)) + { + return Results.BadRequest(new { Message = "Title is required." }); + } + + var entity = new SampleItem + { + Title = dto.Title, + Description = dto.Description + }; + + db.SampleItems.Add(entity); + await db.SaveChangesAsync(ct); + + var result = new SampleItemDto(entity.Id, entity.Title, entity.Description, entity.CreatedAt); + return Results.Created($"/api/sampleitems/{entity.Id}", result); + }) + .WithName("CreateSampleItem") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest); + + return group; + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Middleware/GlobalExceptionMiddleware.cs b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..832f698 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Middleware/GlobalExceptionMiddleware.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Text.Json; + +namespace Template.Middleware; + +public class GlobalExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception: {Message}", ex.Message); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + + var response = new + { + Success = false, + Message = "An unexpected error occurred." + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Models/SampleItem.cs b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Models/SampleItem.cs new file mode 100644 index 0000000..7bd78ab --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Models/SampleItem.cs @@ -0,0 +1,9 @@ +namespace Template.Models; + +public class SampleItem +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Program.cs b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Program.cs new file mode 100644 index 0000000..e743273 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Program.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Template.Data; +using Template.Endpoints; +using Template.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Database +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=app.db"; +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + +// CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Middleware +app.UseMiddleware(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseCors("AllowAll"); + +// Map Endpoints +app.MapSampleEndpoints(); + +app.Run(); diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Properties/launchSettings.json b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Properties/launchSettings.json new file mode 100644 index 0000000..e51c43d --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/README.md b/packages/create-servest/templates/aspnet/aspnet-minimal-api/README.md new file mode 100644 index 0000000..f1f183f --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/README.md @@ -0,0 +1,33 @@ +# Template - ASP.NET Core Minimal API + +Lightweight and high-performance Minimal API backend using ASP.NET Core, Entity Framework Core, and OpenAPI/Swagger. + +## 📁 Project Structure + +- `Endpoints/`: Route group definitions using `MapGroup()`. +- `Models/`: Database domain entities. +- `DTOs/`: Request/Response Data Transfer Objects. +- `Data/`: `ApplicationDbContext` with EF Core Sqlite. +- `Middleware/`: Global Exception Handling middleware. + +## 🚀 Getting Started + +### Prerequisites + +- [.NET 8.0 or .NET 9.0 SDK](https://dotnet.microsoft.com/download) + +### 1. Restore & Build + +```bash +dotnet restore +dotnet build +``` + +### 2. Run with Hot-Reload + +```bash +dotnet watch +``` + +Once running, access Swagger UI at: +👉 **[http://localhost:5000/swagger](http://localhost:5000/swagger)** diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/Template.csproj b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Template.csproj new file mode 100644 index 0000000..86bbc16 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/Template.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + Template + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/_gitignore b/packages/create-servest/templates/aspnet/aspnet-minimal-api/_gitignore new file mode 100644 index 0000000..aa65093 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/_gitignore @@ -0,0 +1,22 @@ +## Build and IDE outputs +[Bb]in/ +[Oo]bj/ +.vs/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates + +## Database & Local Storage +*.db +*.db-shm +*.db-wal + +## Logs & Environment Secrets +*.log +appsettings.Production.json +appsettings.local.json +.env +.env.* +!.env.example diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.Development.json b/packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.Development.json new file mode 100644 index 0000000..e203e94 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.json b/packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.json new file mode 100644 index 0000000..8acfb55 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-minimal-api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Data Source=app.db" + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/.dockerignore b/packages/create-servest/templates/aspnet/aspnet-mvc/.dockerignore new file mode 100644 index 0000000..7e1b719 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/.dockerignore @@ -0,0 +1,11 @@ +**/.git +**/.vs +**/.idea +**/.vscode +**/bin +**/obj +*.user +*.suo +*.db +*.db-shm +*.db-wal diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/.env.example b/packages/create-servest/templates/aspnet/aspnet-mvc/.env.example new file mode 100644 index 0000000..c438b80 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/.env.example @@ -0,0 +1,3 @@ +ASPNETCORE_ENVIRONMENT=Development +CONNECTION_STRING=Data Source=app.db +PORT=5000 diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/BaseApiController.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/BaseApiController.cs new file mode 100644 index 0000000..c9f5190 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/BaseApiController.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Template.Controllers; + +[ApiController] +[Route("api/[controller]")] +public abstract class BaseApiController : ControllerBase +{ +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/SampleItemsController.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/SampleItemsController.cs new file mode 100644 index 0000000..b99dc4a --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Controllers/SampleItemsController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using Template.DTOs; +using Template.Services; + +namespace Template.Controllers; + +public class SampleItemsController : BaseApiController +{ + private readonly ISampleItemService _service; + + public SampleItemsController(ISampleItemService service) + { + _service = service; + } + + [HttpGet] + public async Task>> GetAll(CancellationToken cancellationToken) + { + var items = await _service.GetAllAsync(cancellationToken); + return Ok(items); + } + + [HttpPost] + public async Task> Create([FromBody] CreateSampleItemDto dto, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(dto.Title)) + { + return BadRequest("Title is required."); + } + + var item = await _service.CreateAsync(dto, cancellationToken); + return Ok(item); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/DTOs/SampleItemDto.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/DTOs/SampleItemDto.cs new file mode 100644 index 0000000..ded136c --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/DTOs/SampleItemDto.cs @@ -0,0 +1,4 @@ +namespace Template.DTOs; + +public record SampleItemDto(Guid Id, string Title, string? Description, DateTime CreatedAt); +public record CreateSampleItemDto(string Title, string? Description); diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Data/ApplicationDbContext.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..672af7e --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Data/ApplicationDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Template.Models; + +namespace Template.Data; + +public class ApplicationDbContext : DbContext +{ + public ApplicationDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet SampleItems => Set(); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Dockerfile b/packages/create-servest/templates/aspnet/aspnet-mvc/Dockerfile new file mode 100644 index 0000000..518e84f --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy project file and restore dependencies +COPY ["Template.csproj", "./"] +RUN dotnet restore "Template.csproj" + +# Copy source and publish +COPY . . +RUN dotnet publish "Template.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Runtime stage +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:5000 +EXPOSE 5000 + +ENTRYPOINT ["dotnet", "Template.dll"] diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Middleware/GlobalExceptionMiddleware.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..832f698 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Middleware/GlobalExceptionMiddleware.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Text.Json; + +namespace Template.Middleware; + +public class GlobalExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception: {Message}", ex.Message); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + + var response = new + { + Success = false, + Message = "An unexpected error occurred." + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Models/SampleItem.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Models/SampleItem.cs new file mode 100644 index 0000000..7bd78ab --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Models/SampleItem.cs @@ -0,0 +1,9 @@ +namespace Template.Models; + +public class SampleItem +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Program.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Program.cs new file mode 100644 index 0000000..a465468 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Program.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Template.Data; +using Template.Middleware; +using Template.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Database +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=app.db"; +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + +// Application Services +builder.Services.AddScoped(); + +// CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +app.UseMiddleware(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseCors("AllowAll"); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Properties/launchSettings.json b/packages/create-servest/templates/aspnet/aspnet-mvc/Properties/launchSettings.json new file mode 100644 index 0000000..e51c43d --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/README.md b/packages/create-servest/templates/aspnet/aspnet-mvc/README.md new file mode 100644 index 0000000..e288e14 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/README.md @@ -0,0 +1,34 @@ +# Template - ASP.NET Core MVC / Layered Web API + +Layered controller-based Web API backend using ASP.NET Core, Entity Framework Core, and Swagger UI. + +## 📁 Project Structure + +- `Controllers/`: REST API Controllers inheriting from `BaseApiController`. +- `Services/`: Business logic services & interfaces. +- `Models/`: Database domain entities. +- `DTOs/`: Request/Response Data Transfer Objects. +- `Data/`: `ApplicationDbContext` with EF Core Sqlite. +- `Middleware/`: Global Exception Handling middleware. + +## 🚀 Getting Started + +### Prerequisites + +- [.NET 8.0 or .NET 9.0 SDK](https://dotnet.microsoft.com/download) + +### 1. Restore & Build + +```bash +dotnet restore +dotnet build +``` + +### 2. Run with Hot-Reload + +```bash +dotnet watch +``` + +Once running, access Swagger UI at: +👉 **[http://localhost:5000/swagger](http://localhost:5000/swagger)** diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Services/ISampleItemService.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Services/ISampleItemService.cs new file mode 100644 index 0000000..49d680d --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Services/ISampleItemService.cs @@ -0,0 +1,9 @@ +using Template.DTOs; + +namespace Template.Services; + +public interface ISampleItemService +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task CreateAsync(CreateSampleItemDto dto, CancellationToken cancellationToken = default); +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Services/SampleItemService.cs b/packages/create-servest/templates/aspnet/aspnet-mvc/Services/SampleItemService.cs new file mode 100644 index 0000000..8d189f3 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Services/SampleItemService.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Template.Data; +using Template.DTOs; +using Template.Models; + +namespace Template.Services; + +public class SampleItemService : ISampleItemService +{ + private readonly ApplicationDbContext _context; + + public SampleItemService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.SampleItems + .AsNoTracking() + .OrderByDescending(x => x.CreatedAt) + .Select(x => new SampleItemDto(x.Id, x.Title, x.Description, x.CreatedAt)) + .ToListAsync(cancellationToken); + } + + public async Task CreateAsync(CreateSampleItemDto dto, CancellationToken cancellationToken = default) + { + var entity = new SampleItem + { + Title = dto.Title, + Description = dto.Description + }; + + _context.SampleItems.Add(entity); + await _context.SaveChangesAsync(cancellationToken); + + return new SampleItemDto(entity.Id, entity.Title, entity.Description, entity.CreatedAt); + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/Template.csproj b/packages/create-servest/templates/aspnet/aspnet-mvc/Template.csproj new file mode 100644 index 0000000..117a498 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/Template.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + Template + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/_gitignore b/packages/create-servest/templates/aspnet/aspnet-mvc/_gitignore new file mode 100644 index 0000000..aa65093 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/_gitignore @@ -0,0 +1,22 @@ +## Build and IDE outputs +[Bb]in/ +[Oo]bj/ +.vs/ +.idea/ +*.user +*.suo +*.userosscache +*.sln.docstates + +## Database & Local Storage +*.db +*.db-shm +*.db-wal + +## Logs & Environment Secrets +*.log +appsettings.Production.json +appsettings.local.json +.env +.env.* +!.env.example diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.Development.json b/packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.Development.json new file mode 100644 index 0000000..e203e94 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.json b/packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.json new file mode 100644 index 0000000..8acfb55 --- /dev/null +++ b/packages/create-servest/templates/aspnet/aspnet-mvc/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Data Source=app.db" + } +} diff --git a/packages/create-servest/tsconfig.json b/packages/create-servest/tsconfig.json index 2f36267..8ed5655 100644 --- a/packages/create-servest/tsconfig.json +++ b/packages/create-servest/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../tsconfig.base.json", - "include": ["tsdown.config.ts", "src", "__tests__", "src/types"], + "include": ["tsdown.config.ts", "src", "__tests__", "src/types", "../servest-addons/src/types"], "compilerOptions": { "outDir": "dist", "target": "ES2023", diff --git a/tsconfig.base.json b/tsconfig.base.json index 25a0d00..dafc712 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,8 +9,11 @@ "forceConsistentCasingInFileNames": true, "types": ["node"], "allowImportingTsExtensions": true, + "noEmit": true, "noUnusedLocals": true, "sourceMap": true, "resolveJsonModule": true - } + }, + "files": [], + "exclude": ["node_modules", "dist", "**/templates/**", "**/.next/**"] }