diff --git a/.commitlintrc.json b/.commitlintrc.json index ed8f9fe..aa28d0b 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -24,6 +24,7 @@ "subject-empty": [2, "never"], "subject-full-stop": [2, "never", "."], "subject-max-length": [2, "always", 72], - "header-max-length": [2, "always", 100] + "header-max-length": [2, "always", 100], + "body-leading-blank": [0] } } diff --git a/.githooks/pre-push b/.githooks/pre-push index 1e54016..e603b90 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -2,16 +2,39 @@ # Optional pre-push hook — runs before every `git push`. # Activate with: git config core.hooksPath .githooks # -# Uncomment and adapt the checks below to match your project's tooling. -# Remove checks that don't apply. Keep this hook fast — slow hooks get bypassed. +# Strategy: Go unit tests + build always run (cheap). +# `make test-flows` only runs when files that can affect generated projects +# changed (it spins up pnpm install + vitest in temp dirs, which is slow). -echo "Running tests before push..." -make test || exit 1 +set -e -echo "Running test-flows before push..." -make test-flows || exit 1 +echo "Running tests before push..." +make test echo "Running build check..." -make build || exit 1 +make build + +# Determine the diff range against the upstream branch. Falls back to a full +# diff against origin/main when there is no upstream tracking branch. +upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || echo '') +if [ -n "$upstream" ]; then + range="$upstream...HEAD" +else + range="origin/main...HEAD" +fi + +# Files changed since the upstream branch. Empty output = no flow-relevant +# diff and we skip test-flows. +changed=$(git diff --name-only "$range" 2>/dev/null || true) + +flow_relevant=$(printf '%s\n' "$changed" | grep -E '^(generators/|flows/|tools/test-flow/|internal/|pkg/|plugins/)' || true) + +if [ -n "$flow_relevant" ]; then + echo "Flow-relevant files changed; running make test-flows..." + printf '%s\n' "$flow_relevant" | sed 's/^/ - /' + make test-flows +else + echo "No changes under generators/, flows/, tools/test-flow/, internal/, pkg/, or plugins/ — skipping make test-flows." +fi exit 0 diff --git a/.gitignore b/.gitignore index e16360a..fe58219 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ Thumbs.db tmp/ temp/ .cache/ + +.devfleet-worktrees/ + +# test-flow case-level cache (per-machine state; safe to delete to force a full re-run) +.test-flow-cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e407c..6e853e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Decorator-based API validation and OpenAPI documentation flow (#91). When + scaffolding an Express backend, dot now offers a `@Controller`/`@Get`/`@Body` + decorator API with strongly-typed Zod schemas, request/response validation + middleware, and an OpenAPI v3 spec served at `/docs`. Adapters ship for Clean + Architecture, MVC, and Hexagonal projects, and a `RouterAdapter` interface + keeps the system extensible to non-Express frameworks. See + [docs/user/decorators.md](docs/user/decorators.md) for the quickstart. +- Classic JSDoc-driven Swagger fallback (`express_swagger_jsdoc`). When the + decorator option is declined, dot still wires `swagger-ui-express` at + `/docs` and ships `@openapi` JSDoc blocks on every generated handler + (`/health`, `/auth/*`) so the spec is fully populated out of the box. + The Swagger UI is therefore always available on a generated Express app — + decorators only change *how* the spec is built. +- `auth_better_auth` no longer emits an unused `src/routes/auth.route.ts`; + the BetterAuth catch-all (`toNodeHandler(auth)`) is mounted directly in + `src/app.ts`. +- Case-level cache for `tools/test-flow`. A SHA-256 fingerprint over the + fixture, every involved generator's source tree, the entire `flows/` + directory, `pkg/dotapi/`, and `tools/test-flow/` itself is computed once + scaffolding has resolved. On a hit, post-gen + test commands are skipped + with a `cache: HIT` line in the report. Typical full warm runs go from + ~7 min to ~4 s. Failed runs intentionally leave no cache entry. Disable + with `-no-cache` or by removing `.test-flow-cache/`. +- `dotapi.Command.NoCache` field. Commands are **cacheable by default**; + generator authors opt out by setting `NoCache: true` on the relevant + `dotapi.Command{}`. The case-level cache only short-circuits a case when + no PostGen/Test command involved has `NoCache: true`. The Background + dev-server probe in `react_app` (`pnpm exec vite`) ships with + `NoCache: true` so a real boot is verified on every run. Cache schema + bumped to v2 — existing `.test-flow-cache/` entries are invalidated + automatically. +- `tools/test-flow` is now **fail-fast** by default — it stops at the first + failing case so the failure surfaces immediately. Pass `-keep-going` to + run every case (useful for triaging multiple unrelated failures or for + CI runs that want a complete report). The summary line distinguishes + total / failed / not run, e.g. `✗ 1/18 cases failed (10 not run)`. + ### Changed ### Deprecated ### Removed diff --git a/README.md b/README.md index a0ef680..a290f00 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ See [docs/README.md](docs/README.md) for the complete documentation index. | `microservices` | N independent services, each with its own name and port | | `plugin-template` | A publishable dot plugin repository | +The `init` flow also offers a decorator-based API option for Express backends: +class decorators (`@Controller`, `@Get`, `@Body`, `@Response`, `@Auth`), +request/response validation via Zod, and an OpenAPI v3 spec served at +`/docs`. See [docs/user/decorators.md](docs/user/decorators.md). + Run `dot flows` to see the up-to-date list with descriptions. --- diff --git a/bin/dot b/bin/dot index aa4e487..3acc19d 100755 Binary files a/bin/dot and b/bin/dot differ diff --git a/docs/README.md b/docs/README.md index 05a7e69..0abbb08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ The docs are split into two audiences. If you are **using** DOT to scaffold proj |------|----------------| | [user/getting-started.md](user/getting-started.md) | Install, first scaffold, plugin management | | [user/cli-reference.md](user/cli-reference.md) | Every command, flag, and exit code | +| [user/decorators.md](user/decorators.md) | Decorator-based validation and OpenAPI documentation for Express | --- @@ -38,6 +39,7 @@ One file per built-in flow. Each covers: question IDs, branching diagram, genera | File | Flow | |------|------| +| [contributor/flows/init.md](contributor/flows/init.md) | `init` — default project wizard (TypeScript / Express / decorators / DB / auth) | | [contributor/flows/monorepo.md](contributor/flows/monorepo.md) | `monorepo` — general-purpose project wizard | | [contributor/flows/fullstack.md](contributor/flows/fullstack.md) | `fullstack` — TypeScript + optional React + optional Go backend | | [contributor/flows/microservices.md](contributor/flows/microservices.md) | `microservices` — N services via LoopQuestion | @@ -58,6 +60,13 @@ One file per built-in generator. Each covers: answers consumed, files written, v | [contributor/generators/backend_architecture_mvc_architecture.md](contributor/generators/backend_architecture_mvc_architecture.md) | `backend_architecture_mvc` — MVC backend structure | | [contributor/generators/backend_architecture_clean_architecture.md](contributor/generators/backend_architecture_clean_architecture.md) | `backend_architecture_clean_architecture` — Clean Architecture backend structure | | [contributor/generators/backend_architecture_hexagonal_architecture.md](contributor/generators/backend_architecture_hexagonal_architecture.md) | `backend_architecture_hexagonal` — Hexagonal Architecture backend structure | +| [contributor/generators/zod_validation_deps.md](contributor/generators/zod_validation_deps.md) | `zod_validation_deps` — Zod / zod-to-openapi / reflect-metadata deps + decorator tsconfig flags | +| [contributor/generators/express_decorators_core.md](contributor/generators/express_decorators_core.md) | `express_decorators_core` — `@Controller`/`@Get`/`@Body`/… decorators + `RouterAdapter` (Express impl) | +| [contributor/generators/express_openapi_setup.md](contributor/generators/express_openapi_setup.md) | `express_openapi_setup` — OpenAPI v3 spec generator + Swagger UI mount (decorator path) | +| [contributor/generators/express_swagger_jsdoc.md](contributor/generators/express_swagger_jsdoc.md) | `express_swagger_jsdoc` — classic JSDoc-driven Swagger; scans `src/**/*.ts` for `@openapi` blocks | +| [contributor/generators/decorators_clean_arch_adapter.md](contributor/generators/decorators_clean_arch_adapter.md) | `decorators_clean_arch_adapter` — wires `DecoratorRouter` into a Clean Architecture project | +| [contributor/generators/decorators_mvc_adapter.md](contributor/generators/decorators_mvc_adapter.md) | `decorators_mvc_adapter` — wires `DecoratorRouter` into an MVC project | +| [contributor/generators/decorators_hexagonal_adapter.md](contributor/generators/decorators_hexagonal_adapter.md) | `decorators_hexagonal_adapter` — wires `DecoratorRouter` into a Hexagonal project | ### Plugin reference (`docs/contributor/plugins/`) diff --git a/docs/contributor/authoring-generators.md b/docs/contributor/authoring-generators.md index 5f40a0b..f452e4c 100644 --- a/docs/contributor/authoring-generators.md +++ b/docs/contributor/authoring-generators.md @@ -287,7 +287,7 @@ var Manifest = dotapi.Manifest{ }, }, PostGenerationCommands: []dotapi.Command{ - {Cmd: "pnpm install", WorkDir: ""}, + {Cmd: "pnpm install --dangerously-allow-all-builds", WorkDir: ""}, }, } ``` @@ -376,7 +376,7 @@ Run after the entire generator pipeline has finished and files have been persist ```go PostGenerationCommands: []dotapi.Command{ - {Cmd: "pnpm install"}, + {Cmd: "pnpm install --dangerously-allow-all-builds"}, {Cmd: "go mod tidy", WorkDir: "api"}, }, ``` @@ -404,6 +404,35 @@ TestCommands: []dotapi.Command{ Background commands are started, waited on for `ReadyDelay`, checked for crash, and then sent `SIGTERM`. This lets you test that a dev server starts without having to stop it manually. +### NoCache (caching is opt-out) + +`Command` has a `NoCache bool` field used by `test-flow`'s case-level cache. **The default is cacheable** — on a fingerprint match the test-flow runner can skip the command. Set `NoCache: true` only when the command must run every invocation regardless of cache state. + +```go +PostGenerationCommands: []dotapi.Command{ + // Cacheable by default — no extra field needed. + {Cmd: "pnpm install --dangerously-allow-all-builds"}, +}, +TestCommands: []dotapi.Command{ + {Cmd: "pnpm exec tsc --noEmit"}, + {Cmd: "pnpm exec vitest run unit"}, + {Cmd: "pnpm exec biome check ."}, + + // Opt out: smoke-start the real dev server on every run. + {Cmd: "pnpm exec vite", Background: true, ReadyDelay: 4 * time.Second, NoCache: true}, +}, +``` + +The case-level cache only short-circuits when **no** PostGen/Test command across the involved manifests has `NoCache: true`. A single opt-out anywhere in the resolved set forces the case to re-run from scratch. + +Set `NoCache: true` when: + +- the command starts a real Background process whose actual boot you want re-verified on every run (`pnpm exec vite` with `Background: true` in `react_app` is the canonical example), +- the command depends on remote state with no pinned snapshot (an unpinned network call against a live API, etc.), +- you simply aren't confident the outcome is deterministic. + +See [test-flow.md — Case-level cache](test-flow.md#case-level-cache) for invalidation rules and the `-no-cache` flag. + --- ## Registering a generator diff --git a/docs/contributor/flows/init.md b/docs/contributor/flows/init.md new file mode 100644 index 0000000..1470a67 --- /dev/null +++ b/docs/contributor/flows/init.md @@ -0,0 +1,179 @@ +# Flow: `init` + +The default project wizard. Walks the user from a project name through monorepo style, language stack, framework, architecture, decorator-based validation/OpenAPI, formatter/linter, optional database, and optional authentication. Currently focused on TypeScript + Express scaffolds. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| ID | `init` | +| Title | Init / Project Wizard | +| File | `flows/init.go` | +| Root question | `project_name` | + +--- + +## Questions + +| ID | Type | Label | Options / Default | +|----|------|-------|-------------------| +| `project_name` | Text | "Project name" | Default: `"my-project"` | +| `monorepo_type` | Option | "Monorepo style" | `single` (Turborepo currently disabled) | +| `stack` | Option | "Primary language stack" | `typescript` (Go disabled) | +| `ts-backend-framework` | Option | "Library / Framework" | `express` | +| `ts-backend-architecture` | Option | "Choose your architecture." | `clean-architecture`, `mvc-architecture` (Hexagonal disabled in UI but the generator is registered) | +| `ts-backend-decorators-validation` | Confirm | "Use decorator-based validation and OpenAPI documentation?" | Default: `true`. The description in `flows/init.go` clarifies that **OpenAPI/Swagger is always served at `/docs`** — the choice is between the decorator runtime (Yes) or classic JSDoc-driven `swagger-jsdoc` (No) | +| `ts-backend-validation-lib` | Option | "Validation library" | `zod` (only option today; the constant `ValidationLibZod` keeps the door open for more) — visited only when the decorator question is `true` | +| `ts-backend-formatter` | Option | "Choose a formatter." | `biome`, `prettier` | +| `ts-backend-linter` | Option | "Choose a linter." | `biome`, `prettier` | +| `enable-db` | Confirm | "Link a database to this project?" | Default: `false` | +| `ts-backend-db-type` | Option | "Choose a database." | `postgres` (visited only when `enable-db = true`) | +| `ts-backend-orm` | Option | "Choose an ORM." | `drizzle` (visited only when `enable-db = true`) | +| `enable-auth` | Confirm | "Enable authentication?" | Default: `false` | +| `ts-backend-auth-method` | Option | "Choose an auth method." | `jwt`, `better-auth` (visited only when `enable-auth = true`) | +| `confirm-generate` | Confirm | "Generate the project now?" | Default: `true` | + +--- + +## Question graph + +``` +project_name + └── monorepo_type + └── stack + └── ts-backend-framework + └── ts-backend-architecture + └── ts-backend-decorators-validation + ├── true → ts-backend-validation-lib → ts-backend-formatter + └── false → ts-backend-formatter + └── ts-backend-linter + └── enable-db + ├── true → ts-backend-db-type → ts-backend-orm → enable-auth + └── false → enable-auth + ├── true → ts-backend-auth-method → confirm-generate + └── false → confirm-generate +``` + +--- + +## Generator resolution + +`resolveMonorepoGenerators(spec)` in `flows/init.go` produces the ordered list of generator invocations. Order matters: dependents come after their dependencies. + +| Condition | Generators added | +|-----------|------------------| +| Always | `base_project` | +| `stack = typescript` | `typescript_base` | +| `ts-backend-framework = express` | `express_server_entrypoint`, `express_server_typescript_deps`, `express_node_tsconfig`, `express_shared_errors`, `express_error_middleware`, `express_rate_limit`, `express_test_setup` | +| `ts-backend-architecture = clean-architecture` | `backend_architecture_clean_architecture` | +| `ts-backend-architecture = mvc-architecture` | `backend_architecture_mvc` | +| `ts-backend-architecture = hexagonal-architecture` | `backend_architecture_hexagonal` | +| `ts-backend-decorators-validation = true` and `ts-backend-framework = express` | `zod_validation_deps`, `express_decorators_core`, `express_openapi_setup` | +| `ts-backend-decorators-validation = true` and `ts-backend-architecture = clean-architecture` | `decorators_clean_arch_adapter` | +| `ts-backend-decorators-validation = true` and `ts-backend-architecture = mvc-architecture` | `decorators_mvc_adapter` | +| `ts-backend-decorators-validation = true` and `ts-backend-architecture = hexagonal-architecture` | `decorators_hexagonal_adapter` | +| `ts-backend-decorators-validation = false` and `ts-backend-framework = express` | `express_swagger_jsdoc` (classic Swagger that scans JSDoc `@openapi` blocks — `/docs` is always served) | +| `ts-backend-formatter = prettier` | `prettier_config`, `prettier_typescript_deps`, `prettier_express_rules` | +| `ts-backend-formatter = biome` | `biome_config` | +| `enable-db = true` and `ts-backend-db-type = postgres` | `postgres_docker_compose`, `postgres_env_example` | +| `enable-db = true` and `ts-backend-orm = drizzle` | `drizzle_config_base`, `drizzle_typescript_deps`, `drizzle_postgres_adapter` | +| `enable-auth = true` | `express_auth_validators` | +| `enable-auth = true` and `ts-backend-auth-method = better-auth` | `auth_better_auth`, `auth_better_auth_schema` (auto-adds Postgres + Drizzle if not already enabled) | +| `enable-auth = true` and `ts-backend-auth-method = jwt` | `auth_jwt_vanilla` (+ `auth_jwt_users_schema` when DB present) | +| `enable-auth = true`, JWT, MVC | `auth_jwt_mvc_route` | +| `enable-auth = true`, JWT, Clean, with DB | `auth_jwt_clean_arch_module` | + +The decorator generators run **before** the formatter/db/auth steps so later generators can detect them via `ctx.PreviousGens` and adapt their templates. + +--- + +## Decorator-aware templating + +Several existing generators read `slices.Contains(ctx.PreviousGens, "express_decorators_core")` and switch their output: + +| Generator | Effect when decorators are on | +|-----------|------------------------------| +| `auth_jwt_vanilla` | Patches `app.ts` to pass `{ authMiddleware }` to `ExpressRouterAdapter` so `@Auth()` routes are gated | +| `auth_jwt_mvc_route` | Emits `AuthController` as a decorated class; route file becomes a no-op; `app.ts` chains `.registerController(new AuthController())` onto the `DecoratorRouter` | +| `auth_jwt_clean_arch_module` | Same pattern, controller in `src/modules/auth/application/controllers/` | +| `auth_better_auth` | Unchanged; BetterAuth keeps its catch-all `toNodeHandler(auth)` mounted directly in `app.ts` (the decorator router and BetterAuth coexist) | + +--- + +## Fixture examples + +Located under `tools/test-flow/testdata/`. Two illustrative ones: + +**Decorators on, Clean Architecture, no DB** (`202605070101_express_clean_arch_decorators_zod.json`): + +```json +{ + "name": "express_clean_arch_decorators_zod", + "flow_id": "init", + "answers": { + "project_name": "my-app", + "monorepo_type": "single", + "stack": "typescript", + "ts-backend-framework": "express", + "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": true, + "ts-backend-validation-lib": "zod", + "ts-backend-formatter": "prettier", + "ts-backend-linter": "prettier", + "enable-db": false, + "confirm-generate": true + }, + "skip_post_commands": false, + "skip_test_commands": false +} +``` + +**Decorators on, MVC + Postgres + JWT** (`202605070104_express_mvc_decorators_postgres_jwt.json`): + +```json +{ + "name": "express_mvc_decorators_postgres_jwt", + "flow_id": "init", + "answers": { + "project_name": "my-app", + "monorepo_type": "single", + "stack": "typescript", + "ts-backend-framework": "express", + "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": true, + "ts-backend-validation-lib": "zod", + "ts-backend-formatter": "biome", + "ts-backend-linter": "biome", + "enable-db": true, + "ts-backend-db-type": "postgres", + "ts-backend-orm": "drizzle", + "enable-auth": true, + "ts-backend-auth-method": "jwt", + "confirm-generate": true + } +} +``` + +Existing pre-decorator scenarios were migrated to set `ts-backend-decorators-validation: false` explicitly (the question is required, so the field is mandatory in every Express scenario). + +--- + +## Source + +`flows/init.go` — `InitFlow()` builds the question graph; `resolveMonorepoGenerators()` does the conditional generator selection. + +## See also + +- [generators/express_decorators_core.md](../generators/express_decorators_core.md) +- [generators/express_openapi_setup.md](../generators/express_openapi_setup.md) +- [generators/zod_validation_deps.md](../generators/zod_validation_deps.md) +- [generators/decorators_clean_arch_adapter.md](../generators/decorators_clean_arch_adapter.md) +- [generators/decorators_mvc_adapter.md](../generators/decorators_mvc_adapter.md) +- [generators/decorators_hexagonal_adapter.md](../generators/decorators_hexagonal_adapter.md) +- [generators/auth_jwt_vanilla.md](../generators/auth_jwt_vanilla.md) +- [generators/auth_jwt_mvc_route.md](../generators/auth_jwt_mvc_route.md) +- [generators/auth_jwt_clean_arch_module.md](../generators/auth_jwt_clean_arch_module.md) +- [generators/auth_better_auth.md](../generators/auth_better_auth.md) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/auth_better_auth.md b/docs/contributor/generators/auth_better_auth.md index 42a0a9a..09800dd 100644 --- a/docs/contributor/generators/auth_better_auth.md +++ b/docs/contributor/generators/auth_better_auth.md @@ -1,6 +1,6 @@ # Generator: `auth_better_auth` -BetterAuth session-based authentication setup. Creates `src/lib/auth.ts` (auth instance with Drizzle adapter) and `src/routes/auth.route.ts` (catch-all handler for `/api/auth/*`). Adds `better-auth` to dependencies and appends `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` to `.env.example`. +BetterAuth session-based authentication. Creates `src/lib/auth.ts` (auth instance with Drizzle adapter) and mounts the BetterAuth catch-all (`toNodeHandler(auth)`) at `/api/auth/*` directly inside `src/app.ts`. Also appends `cookie-parser`, `BETTER_AUTH_SECRET`, and `BETTER_AUTH_URL`. --- @@ -9,7 +9,7 @@ BetterAuth session-based authentication setup. Creates `src/lib/auth.ts` (auth i | Field | Value | |-------|-------| | Name | `auth_better_auth` | -| Version | `0.1.0` | +| Version | `0.2.0` | | Package | `generators/auth_better_auth` | --- @@ -24,7 +24,7 @@ BetterAuth session-based authentication setup. Creates `src/lib/auth.ts` (auth i ## Answers consumed -None. +None — selection is driven by `flows/init.go` (`ts-backend-auth-method = better-auth`). --- @@ -33,14 +33,14 @@ None. | Path | Description | |------|-------------| | `src/lib/auth.ts` | BetterAuth instance with Drizzle PG adapter and email/password enabled | -| `src/routes/auth.route.ts` | Express router that forwards all `/api/auth/*` requests to BetterAuth | | `.env.example` | Appends `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` | Also merges into: | Path | Keys added / updated | |------|---------------------| -| `package.json` | `dependencies.better-auth` | +| `package.json` | `dependencies.better-auth`, `dependencies.cookie-parser`, `devDependencies.@types/cookie-parser` | +| `src/app.ts` | Imports `cookieParser`, `toNodeHandler`, `auth`; adds `app.use(cookieParser())` and `app.all('/api/auth/*', toNodeHandler(auth))` directly (no intermediate route file) | --- @@ -55,9 +55,7 @@ Also merges into: ## Post-generation commands -| Command | WorkDir | Notes | -|---------|---------|-------| -| `pnpm install` | project root | Deduped | +No PostGenerationCommands. `pnpm install` is run by `typescript_base`. ## Test commands @@ -65,6 +63,20 @@ No TestCommands. --- +## Decorator interaction + +When `ts-backend-decorators-validation = true`, BetterAuth keeps its catch-all behaviour — `toNodeHandler` owns its routing under `/api/auth/*` and is **not** exposed through the decorator system. The decorator router and BetterAuth coexist on the same Express app; the rest of the API can use `@Controller`, `@Auth`, etc. The cookie-parser middleware injection works on the decorator-aware `app.ts` because it still contains `app.use(express.json());` as an anchor. + +--- + ## Conflicts -None. +None — but on the same scaffold you would not normally pair `auth_better_auth` with `auth_jwt_*` generators. + +--- + +## See also + +- [generators/auth_better_auth_schema.md](auth_better_auth_schema.md) +- [generators/auth_jwt_vanilla.md](auth_jwt_vanilla.md) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/auth_jwt_clean_arch_module.md b/docs/contributor/generators/auth_jwt_clean_arch_module.md index d79c528..dff0e85 100644 --- a/docs/contributor/generators/auth_jwt_clean_arch_module.md +++ b/docs/contributor/generators/auth_jwt_clean_arch_module.md @@ -25,7 +25,11 @@ Full JWT authentication module for Clean Architecture. Generates a complete `src ## Answers consumed -None. +None directly. The generator reads `ctx.PreviousGens` at runtime: + +| Probe | Effect on output | +|-------|------------------| +| Contains `express_decorators_core` (`HasDecorators`) | Controller is emitted as a `@Controller({ prefix: '/auth' })` class with `@Post`/`@Get`/`@Auth`/`@Body`/`@ApiResponse` decorators (use cases injected at module scope as before); the route file becomes a no-op `export {}`; `app.ts` is patched to chain `.registerController(new AuthController())` onto the existing `DecoratorRouter` | --- @@ -40,10 +44,10 @@ None. | `src/modules/auth/application/use-cases/register.use-case.ts` | `RegisterUseCase` | | `src/modules/auth/application/use-cases/refresh.use-case.ts` | `RefreshUseCase` | | `src/modules/auth/application/use-cases/logout.use-case.ts` | `LogoutUseCase` | -| `src/modules/auth/application/controllers/auth.controller.ts` | Express controller delegating to use-cases | +| `src/modules/auth/application/controllers/auth.controller.ts` | Functional handlers delegating to use-cases with JSDoc `@openapi` blocks on every endpoint (decorators OFF) — picked up by `express_swagger_jsdoc` so `/docs` is fully populated. With decorators ON: a `@Controller`-decorated `AuthController` class | | `src/modules/auth/infrastructure/database/repositories/user.repository.ts` | Drizzle `UserRepository` | | `src/modules/auth/infrastructure/database/repositories/refresh-token.repository.ts` | Drizzle `RefreshTokenRepository` | -| `src/routes/auth.route.ts` | Express router for /register, /login, /me, /refresh, /logout | +| `src/routes/auth.route.ts` | When decorators OFF: Express router for /register, /login, /me, /refresh, /logout. When decorators ON: empty `export {}` (routes are registered through the `DecoratorRouter` in `src/app.ts`) | Also merges into: diff --git a/docs/contributor/generators/auth_jwt_mvc_route.md b/docs/contributor/generators/auth_jwt_mvc_route.md index 2548e7b..726da88 100644 --- a/docs/contributor/generators/auth_jwt_mvc_route.md +++ b/docs/contributor/generators/auth_jwt_mvc_route.md @@ -24,7 +24,12 @@ JWT auth route and controller for MVC architecture. Generates `src/routes/auth.r ## Answers consumed -None (reads `ctx.PreviousGens` at runtime to detect Drizzle). +None directly. The generator reads `ctx.PreviousGens` at runtime: + +| Probe | Effect on output | +|-------|------------------| +| Contains `drizzle_postgres_adapter` (`HasDB`) | Controller emits a real bcrypt + Drizzle implementation; otherwise emits 501 stubs | +| Contains `express_decorators_core` (`HasDecorators`) | Controller is emitted as a `@Controller({ prefix: '/auth' })` class with `@Post`/`@Get`/`@Auth`/`@Body`/`@ApiResponse` decorators; the route file becomes a no-op `export {}`; `app.ts` is patched to chain `.registerController(new AuthController())` onto the existing `DecoratorRouter` | --- @@ -32,8 +37,9 @@ None (reads `ctx.PreviousGens` at runtime to detect Drizzle). | Path | Description | |------|-------------| -| `src/routes/auth.route.ts` | Express router wiring POST /register, POST /login, GET /me, POST /refresh, POST /logout | -| `src/controllers/auth.controller.ts` | Full controller with bcrypt + DB operations when `drizzle_postgres_adapter` ran; 501 stubs otherwise | +| `src/routes/auth.route.ts` | When decorators OFF: Express router wiring POST /register, /login, GET /me, POST /refresh, /logout. When decorators ON: empty `export {}` (routes are registered via `DecoratorRouter` in `src/app.ts`) | +| `src/controllers/auth.controller.ts` | Functional handlers with JSDoc `@openapi` blocks on every endpoint (decorators OFF) — picked up by `express_swagger_jsdoc` so `/docs` is fully populated. With decorators ON: a `@Controller`-decorated `AuthController` class. When `drizzle_postgres_adapter` is present: real implementation; otherwise: 501 stubs | +| `src/__tests__/auth.db.test.ts` | Supertest DB-tests covering register/login/me/refresh/logout (only emitted when Drizzle is present) | Also merges into (when Drizzle is present): diff --git a/docs/contributor/generators/auth_jwt_vanilla.md b/docs/contributor/generators/auth_jwt_vanilla.md index 8be6020..8605945 100644 --- a/docs/contributor/generators/auth_jwt_vanilla.md +++ b/docs/contributor/generators/auth_jwt_vanilla.md @@ -66,6 +66,20 @@ No TestCommands. --- +## Decorator interaction + +When `express_decorators_core` ran earlier in the pipeline, this generator additionally patches `src/app.ts` to wire the JWT middleware into `ExpressRouterAdapter`: + +```ts +import { authMiddleware } from './shared/middlewares/auth.middleware'; +// ... +new ExpressRouterAdapter({ authMiddleware }) +``` + +That makes every `@Auth()`-decorated route gated by JWT verification automatically. Detection is done via `slices.Contains(ctx.PreviousGens, "express_decorators_core")` — there is no extra answer to set. + +--- + ## Conflicts None. diff --git a/docs/contributor/generators/decorators_clean_arch_adapter.md b/docs/contributor/generators/decorators_clean_arch_adapter.md new file mode 100644 index 0000000..8775b6c --- /dev/null +++ b/docs/contributor/generators/decorators_clean_arch_adapter.md @@ -0,0 +1,76 @@ +# Generator: `decorators_clean_arch_adapter` + +Wires the decorator runtime into a Clean Architecture project. Emits a sample `ExampleController` in the application layer, a Zod schema module in `application/validators/`, and overwrites `src/app.ts` to bootstrap the `DecoratorRouter` + Swagger UI. Ships an end-to-end Vitest test that boots the real app. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `decorators_clean_arch_adapter` | +| Version | `0.1.0` | +| Package | `generators/decorators_clean_arch_adapter` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `backend_architecture_clean_architecture` | Module skeleton must exist (`src/modules/example/application/...`) | +| `express_decorators_core` | Provides the decorators and the Express adapter | +| `express_openapi_setup` | Provides the registry / spec generator / Swagger mount | + +--- + +## Answers consumed + +None directly — `flows/init.go` selects this generator when both `ts-backend-architecture = clean-architecture` and `ts-backend-decorators-validation = true`. + +--- + +## Files written + +| Path | Description | +|------|-------------| +| `src/app.ts` | Overwritten with a decorator-aware bootstrap (imports `corsOptions` from `./shared/cors`, mounts `DecoratorRouter` at the root, builds the OpenAPI spec, mounts Swagger at `/docs`). The `src/shared/cors.ts` helper itself is provided by `express_server_entrypoint`; this generator reuses it as-is. | +| `src/modules/example/application/controllers/example.controller.ts` | `@Controller({ prefix: '/api/example' })` sample controller demonstrating `@Get`, `@Post`, `@Body`, `@Params`, `@ApiResponse` | +| `src/modules/example/application/validators/example.schemas.ts` | Zod request/response schemas for the example controller | +| `src/__tests__/decorators-clean.e2e.test.ts` | Supertest E2E covering 200/400 paths and `/docs/openapi.json` | + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `src/modules/example/application/controllers/example.controller.ts` | `file_exists` | — | +| `src/modules/example/application/validators/example.schemas.ts` | `file_exists` | — | +| `src/__tests__/decorators-clean.e2e.test.ts` | `file_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. Tooling is set up by upstream generators. + +## Test commands + +The embedded E2E test is matched by `pnpm exec vitest run e2e` (declared by `express_test_setup`). + +--- + +## Conflicts + +None — but the init flow only includes one of the three architecture-specific decorator adapters at a time. + +--- + +## See also + +- [generators/express_decorators_core.md](express_decorators_core.md) +- [generators/express_openapi_setup.md](express_openapi_setup.md) +- [generators/backend_architecture_clean_architecture.md](backend_architecture_clean_architecture.md) +- [generators/express_server_entrypoint.md](express_server_entrypoint.md) — owner of `src/shared/cors.ts` (the CORS helper this generator imports) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/decorators_hexagonal_adapter.md b/docs/contributor/generators/decorators_hexagonal_adapter.md new file mode 100644 index 0000000..4a90f59 --- /dev/null +++ b/docs/contributor/generators/decorators_hexagonal_adapter.md @@ -0,0 +1,76 @@ +# Generator: `decorators_hexagonal_adapter` + +Wires the decorator runtime into a Hexagonal project. Emits a sample `ExampleController` in `src/adapters/primary/http/controllers/`, schemas in `src/adapters/primary/http/schemas/`, and overwrites `src/app.ts` to bootstrap the `DecoratorRouter` + Swagger UI. Ships an end-to-end Vitest test. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `decorators_hexagonal_adapter` | +| Version | `0.1.0` | +| Package | `generators/decorators_hexagonal_adapter` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `backend_architecture_hexagonal` | Hexagonal skeleton must exist (`src/adapters/primary/http/...`, `src/core/...`) | +| `express_decorators_core` | Provides the decorators and the Express adapter | +| `express_openapi_setup` | Provides the registry / spec generator / Swagger mount | + +--- + +## Answers consumed + +None directly — `flows/init.go` selects this generator when both `ts-backend-architecture = hexagonal-architecture` and `ts-backend-decorators-validation = true`. (Hexagonal is currently disabled in the user-facing flow but the adapter is registered for future activation.) + +--- + +## Files written + +| Path | Description | +|------|-------------| +| `src/app.ts` | Decorator-aware bootstrap (imports `corsOptions` from `./shared/cors`, mounts `DecoratorRouter` at root, Swagger at `/docs`). The `src/shared/cors.ts` helper is provided by `express_server_entrypoint`; this generator reuses it as-is. | +| `src/adapters/primary/http/controllers/example.controller.ts` | `@Controller({ prefix: '/api/example' })` sample | +| `src/adapters/primary/http/schemas/example.schemas.ts` | Zod schemas | +| `src/__tests__/decorators-hexagonal.e2e.test.ts` | Supertest E2E | + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `src/adapters/primary/http/controllers/example.controller.ts` | `file_exists` | — | +| `src/adapters/primary/http/schemas/example.schemas.ts` | `file_exists` | — | +| `src/__tests__/decorators-hexagonal.e2e.test.ts` | `file_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. + +## Test commands + +The embedded E2E test runs via `pnpm exec vitest run e2e`. + +--- + +## Conflicts + +None. + +--- + +## See also + +- [generators/express_decorators_core.md](express_decorators_core.md) +- [generators/express_openapi_setup.md](express_openapi_setup.md) +- [generators/backend_architecture_hexagonal_architecture.md](backend_architecture_hexagonal_architecture.md) +- [generators/express_server_entrypoint.md](express_server_entrypoint.md) — owner of `src/shared/cors.ts` +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/decorators_mvc_adapter.md b/docs/contributor/generators/decorators_mvc_adapter.md new file mode 100644 index 0000000..b298f19 --- /dev/null +++ b/docs/contributor/generators/decorators_mvc_adapter.md @@ -0,0 +1,76 @@ +# Generator: `decorators_mvc_adapter` + +Wires the decorator runtime into an MVC project. Emits a sample `ExampleController` in `src/controllers/`, schemas in `src/shared/validators/`, and overwrites `src/app.ts` to bootstrap the `DecoratorRouter` + Swagger UI. Ships an end-to-end Vitest test. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `decorators_mvc_adapter` | +| Version | `0.1.0` | +| Package | `generators/decorators_mvc_adapter` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `backend_architecture_mvc` | MVC skeleton must exist (`src/controllers/`, `src/shared/validators/`) | +| `express_decorators_core` | Provides the decorators and the Express adapter | +| `express_openapi_setup` | Provides the registry / spec generator / Swagger mount | + +--- + +## Answers consumed + +None directly — `flows/init.go` selects this generator when both `ts-backend-architecture = mvc-architecture` and `ts-backend-decorators-validation = true`. + +--- + +## Files written + +| Path | Description | +|------|-------------| +| `src/app.ts` | Decorator-aware bootstrap (imports `corsOptions` from `./shared/cors`, mounts `DecoratorRouter` at root, Swagger at `/docs`). The `src/shared/cors.ts` helper is provided by `express_server_entrypoint`; this generator reuses it as-is. | +| `src/controllers/example.controller.ts` | `@Controller({ prefix: '/api/example' })` sample | +| `src/shared/validators/example.schemas.ts` | Zod schemas | +| `src/__tests__/decorators-mvc.e2e.test.ts` | Supertest E2E | + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `src/controllers/example.controller.ts` | `file_exists` | — | +| `src/shared/validators/example.schemas.ts` | `file_exists` | — | +| `src/__tests__/decorators-mvc.e2e.test.ts` | `file_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. + +## Test commands + +The embedded E2E test runs via `pnpm exec vitest run e2e`. + +--- + +## Conflicts + +None — only one architecture-specific decorator adapter runs per scaffold. + +--- + +## See also + +- [generators/express_decorators_core.md](express_decorators_core.md) +- [generators/express_openapi_setup.md](express_openapi_setup.md) +- [generators/backend_architecture_mvc_architecture.md](backend_architecture_mvc_architecture.md) +- [generators/express_server_entrypoint.md](express_server_entrypoint.md) — owner of `src/shared/cors.ts` +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/express_decorators_core.md b/docs/contributor/generators/express_decorators_core.md new file mode 100644 index 0000000..008f43a --- /dev/null +++ b/docs/contributor/generators/express_decorators_core.md @@ -0,0 +1,86 @@ +# Generator: `express_decorators_core` + +Framework-agnostic API decorators (`@Controller`, `@Get`/`@Post`/…, `@Body`, `@Query`, `@Params`, `@ApiResponse`, `@Auth`, `@RequiredHeaders`) plus the `RouterAdapter` interface and an Express implementation. Ships unit tests covering route registration, validation, auth, and required headers. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `express_decorators_core` | +| Version | `0.1.0` | +| Package | `generators/express_decorators_core` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `express_server_entrypoint` | `src/app.ts` must exist; the Express adapter relies on `express` being installed | +| `zod_validation_deps` | Decorators import Zod schemas and need `experimentalDecorators` + `emitDecoratorMetadata` | + +--- + +## Answers consumed + +None — selection is driven by `flows/init.go` based on `ts-backend-decorators-validation`. + +--- + +## Files written + +| Path | Description | +|------|-------------| +| `src/shared/decorators/metadata.ts` | Reflect-metadata helpers (`getRoutes`, `setController`, `setProtected`, …) | +| `src/shared/decorators/controller.decorator.ts` | `@Controller({ tag, prefix?, description? })` class decorator | +| `src/shared/decorators/route.decorators.ts` | `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`, plus `@Summary` and `@Description` overrides | +| `src/shared/decorators/validation.decorators.ts` | `@Body`/`@Query`/`@Params` method decorators | +| `src/shared/decorators/response.decorator.ts` | Stackable `@ApiResponse(status, description, schema?)` | +| `src/shared/decorators/auth.decorator.ts` | `@Auth()` marker (gates the route via `ExpressRouterAdapter`'s `authMiddleware` and adds `BearerAuth` to OpenAPI) | +| `src/shared/decorators/header.decorator.ts` | `@RequiredHeaders([...])` | +| `src/shared/decorators/router-adapter.ts` | `RouterAdapter` interface + `RouteRegistration` type | +| `src/shared/decorators/express-router-adapter.ts` | Express implementation; wraps async handlers via `next(err)` | +| `src/shared/decorators/decorator-router.ts` | Reads metadata, calls `adapter.register(...)`, exposes `routes()` for the OpenAPI generator | +| `src/shared/decorators/index.ts` | Barrel re-exporting the public API | +| `src/shared/middlewares/validate-request.ts` | Express middleware that runs a Zod schema against `req.body` / `params` / `query` | +| `src/shared/decorators/__tests__/decorators.unit.test.ts` | Vitest unit tests (route registration, validation, auth, headers) | + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `src/shared/decorators/index.ts` | `file_exists` | — | +| `src/shared/decorators/decorator-router.ts` | `file_exists` | — | +| `src/shared/decorators/router-adapter.ts` | `file_exists` | — | +| `src/shared/middlewares/validate-request.ts` | `file_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. Installation is handled by `typescript_base` / `express_test_setup`. + +## Test commands + +The embedded `decorators.unit.test.ts` runs as part of the `pnpm exec vitest run unit` command from `express_test_setup`. + +--- + +## Conflicts + +None — but the decorator system only works when `zod_validation_deps` and `express_openapi_setup` are also generated. The init flow ensures they are paired. + +--- + +## See also + +- [generators/zod_validation_deps.md](zod_validation_deps.md) +- [generators/express_openapi_setup.md](express_openapi_setup.md) +- [generators/decorators_clean_arch_adapter.md](decorators_clean_arch_adapter.md) +- [generators/decorators_mvc_adapter.md](decorators_mvc_adapter.md) +- [generators/decorators_hexagonal_adapter.md](decorators_hexagonal_adapter.md) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/express_openapi_setup.md b/docs/contributor/generators/express_openapi_setup.md new file mode 100644 index 0000000..d63a46e --- /dev/null +++ b/docs/contributor/generators/express_openapi_setup.md @@ -0,0 +1,72 @@ +# Generator: `express_openapi_setup` + +OpenAPI v3 spec generation plus a Swagger UI mount that consume the route metadata produced by `DecoratorRouter`. Provides a registry helper, the spec aggregator, the Swagger mount, and unit tests. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `express_openapi_setup` | +| Version | `0.1.0` | +| Package | `generators/express_openapi_setup` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `express_decorators_core` | The spec aggregator imports `RegisteredRoute` from `../decorators` | + +--- + +## Answers consumed + +None. + +--- + +## Files written + +| Path | Description | +|------|-------------| +| `src/shared/openapi/registry.ts` | `createRegistry()` (fresh, isolated for tests) and `getSharedRegistry()` (lazy singleton); also re-exports `z` extended with `@asteasolutions/zod-to-openapi` | +| `src/shared/openapi/spec-generator.ts` | `buildOpenApiSpec({ info, servers?, routes, registry? })` — converts decorator metadata to OpenAPI v3 | +| `src/shared/openapi/swagger.ts` | `mountSwagger(app, spec, opts?)` — serves `/docs` (UI) and `/docs/openapi.json` (raw) | +| `src/shared/openapi/index.ts` | Barrel re-exporting the public API | +| `src/shared/openapi/__tests__/spec.unit.test.ts` | Vitest tests for paths, tags, BearerAuth, default 200 fallback, served `/docs/openapi.json` | + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `src/shared/openapi/spec-generator.ts` | `file_exists` | — | +| `src/shared/openapi/swagger.ts` | `file_exists` | — | +| `src/shared/openapi/registry.ts` | `file_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. + +## Test commands + +The embedded `spec.unit.test.ts` runs as part of `pnpm exec vitest run unit` from `express_test_setup`. + +--- + +## Conflicts + +None. + +--- + +## See also + +- [generators/express_decorators_core.md](express_decorators_core.md) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/express_server_entrypoint.md b/docs/contributor/generators/express_server_entrypoint.md index b39381c..d2a0d61 100644 --- a/docs/contributor/generators/express_server_entrypoint.md +++ b/docs/contributor/generators/express_server_entrypoint.md @@ -1,6 +1,6 @@ # Generator: `express_server_entrypoint` -Creates the Express TypeScript source files: `src/index.ts` (server bootstrap with `PORT`) and `src/app.ts` (Express app with `/health` route). Also writes the base `.env.example` that downstream generators append to. +Creates the Express TypeScript source files: `src/index.ts` (server bootstrap with `PORT`), `src/app.ts` (Express app with `/health` route), and `src/shared/cors.ts` (env-driven CORS configuration helper). Also writes the base `.env.example` that downstream generators append to — seeded with `PORT` and `CORS_ORIGIN`. --- @@ -33,8 +33,21 @@ None. Uses `Spec.Metadata.ProjectName` for documentation only. | Path | Description | |------|-------------| | `src/index.ts` | Bootstraps HTTP server, reads `PORT` from env | -| `src/app.ts` | Express app: CORS, JSON body parser, `GET /health` | -| `.env.example` | Seed file with `PORT=3000`; downstream generators append to this file | +| `src/app.ts` | Express app: CORS (via `corsOptions()` — see below), JSON body parser, `GET /health` (with JSDoc `@openapi` annotation so `swagger-jsdoc` picks it up automatically when `express_swagger_jsdoc` is part of the scaffold) | +| `src/shared/cors.ts` | `corsOptions(): CorsOptions` helper that reads `CORS_ORIGIN` from env and returns a `cors` package configuration. Reused by the decorator architecture adapters that overwrite `app.ts`. | +| `.env.example` | Seed file with `PORT=3000` and `CORS_ORIGIN=http://localhost:3000`; downstream generators append to this file | + +### CORS configuration (`src/shared/cors.ts`) + +`corsOptions()` resolves the runtime CORS policy from the `CORS_ORIGIN` environment variable so the generated app passes SonarQube/static-analysis rules that flag a bare `app.use(cors())`: + +| `CORS_ORIGIN` value | Resolved options | +|---------------------|-------------------| +| unset | `{ origin: 'http://localhost:3000', credentials: true }` | +| `*` | `{ origin: '*' }` (no credentials — browsers reject `*` + credentials anyway) | +| `https://a.com,https://b.com` | `{ origin: ['https://a.com', 'https://b.com'], credentials: true }` | + +Production deployments must set `CORS_ORIGIN` to the explicit list of trusted origins. The decorator architecture adapters (`decorators_clean_arch_adapter`, `decorators_mvc_adapter`, `decorators_hexagonal_adapter`) overwrite `src/app.ts` but reuse this helper unchanged. --- @@ -44,6 +57,7 @@ None. Uses `Spec.Metadata.ProjectName` for documentation only. |-------|------|-------------| | `src/index.ts` | `file_exists` | — | | `src/app.ts` | `file_exists` | — | +| `src/shared/cors.ts` | `file_exists` | — | --- @@ -67,3 +81,4 @@ None. - [`express_server_typescript_deps`](express_server_typescript_deps.md) — npm deps + run scripts - [`express_node_tsconfig`](express_node_tsconfig.md) — tsconfig overrides for Node/CommonJS +- [`decorators_clean_arch_adapter`](decorators_clean_arch_adapter.md), [`decorators_mvc_adapter`](decorators_mvc_adapter.md), [`decorators_hexagonal_adapter`](decorators_hexagonal_adapter.md) — overwrite `src/app.ts` but keep importing `corsOptions` from `./shared/cors` diff --git a/docs/contributor/generators/express_swagger_jsdoc.md b/docs/contributor/generators/express_swagger_jsdoc.md new file mode 100644 index 0000000..1fa8ad1 --- /dev/null +++ b/docs/contributor/generators/express_swagger_jsdoc.md @@ -0,0 +1,96 @@ +# Generator: `express_swagger_jsdoc` + +Classic JSDoc-driven Swagger / OpenAPI for Express scaffolds that opted **out** of the decorator-based stack. Adds `swagger-jsdoc`, builds a spec at boot by scanning `src/**/*.ts` for `@openapi` JSDoc blocks, and mounts `swagger-ui-express` at `/docs`. + +OpenAPI is therefore **always** available on a generated Express app: either via the decorator runtime (`express_openapi_setup`) or via this generator — never both at the same time. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `express_swagger_jsdoc` | +| Version | `0.1.0` | +| Package | `generators/express_swagger_jsdoc` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `express_server_entrypoint` | `src/app.ts` must exist so the generator can inject the `mountSwagger(app)` call | + +--- + +## Answers consumed + +None directly. `flows/init.go` selects this generator when `ts-backend-framework = express` and `ts-backend-decorators-validation = false`. + +--- + +## Files written + +| Path | Description | +|------|-------------| +| `src/shared/swagger/swagger.config.ts` | `swagger-jsdoc` options and `swaggerSpec` (calls `swaggerJSDoc(...)` once at module load) | +| `src/shared/swagger/index.ts` | `mountSwagger(app, opts?)` — serves `/docs` (UI) and `/docs/openapi.json` (raw) | +| `src/shared/swagger/__tests__/swagger.unit.test.ts` | Vitest test that hits `/docs/openapi.json` and asserts the `/health` path is present (proves JSDoc scanning works end to end) | + +Also merges into: + +| Path | Keys added / updated | +|------|---------------------| +| `package.json` | `dependencies.swagger-jsdoc`, `dependencies.swagger-ui-express`, `devDependencies.@types/swagger-jsdoc`, `devDependencies.@types/swagger-ui-express` | +| `src/app.ts` | Imports `mountSwagger` and calls `mountSwagger(app)` before `export default app;` | + +--- + +## How the spec is populated + +`swagger-jsdoc` reads every `.ts`/`.js` file under `src/` looking for `/** @openapi … */` blocks. The dot generators that produce route handlers ship JSDoc OpenAPI annotations on every endpoint: + +| Generator | Endpoint(s) documented | +|-----------|-----------------------| +| `express_server_entrypoint` | `GET /health` | +| `auth_jwt_mvc_route` (DB branch, decorators OFF) | `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /auth/me` | +| `auth_jwt_clean_arch_module` (decorators OFF) | Same five endpoints | + +When you add new routes, drop a `@openapi` JSDoc block above the handler and `/docs` picks it up at the next boot — no codegen step. + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `src/shared/swagger/swagger.config.ts` | `file_exists` | — | +| `src/shared/swagger/index.ts` | `file_exists` | — | +| `dependencies.swagger-jsdoc` in `package.json` | `json_key_exists` | — | +| `dependencies.swagger-ui-express` in `package.json` | `json_key_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. + +## Test commands + +The embedded `swagger.unit.test.ts` runs as part of `pnpm exec vitest run unit` (declared by `express_test_setup`). + +--- + +## Conflicts + +None at the dependency-resolution level, but `flows/init.go` ensures only **one** of `express_swagger_jsdoc` and `express_openapi_setup` runs per scaffold (decorator choice is mutually exclusive). + +--- + +## See also + +- [generators/express_openapi_setup.md](express_openapi_setup.md) — decorator-driven counterpart +- [generators/express_decorators_core.md](express_decorators_core.md) +- [flows/init.md](../flows/init.md) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/generators/zod_validation_deps.md b/docs/contributor/generators/zod_validation_deps.md new file mode 100644 index 0000000..a127acd --- /dev/null +++ b/docs/contributor/generators/zod_validation_deps.md @@ -0,0 +1,75 @@ +# Generator: `zod_validation_deps` + +Adds the runtime dependencies and `tsconfig` flags needed by the decorator-based validation/OpenAPI stack: Zod, `@asteasolutions/zod-to-openapi`, `reflect-metadata`, `swagger-ui-express`, plus `experimentalDecorators` and `emitDecoratorMetadata`. + +--- + +## Identity + +| Field | Value | +|-------|-------| +| Name | `zod_validation_deps` | +| Version | `0.1.0` | +| Package | `generators/zod_validation_deps` | + +--- + +## Dependencies + +| Generator | Why | +|-----------|-----| +| `typescript_base` | `package.json` and `tsconfig.json` must already exist for the dependency / compiler-option merges | + +--- + +## Answers consumed + +None. + +--- + +## Files written + +None directly — only merges into existing files. + +Also merges into: + +| Path | Keys added / updated | +|------|---------------------| +| `package.json` | `dependencies.zod`, `dependencies.@asteasolutions/zod-to-openapi`, `dependencies.reflect-metadata`, `dependencies.swagger-ui-express`, `devDependencies.@types/swagger-ui-express` | +| `tsconfig.json` | `compilerOptions.experimentalDecorators = true`, `compilerOptions.emitDecoratorMetadata = true` | + +--- + +## Validators + +| Check | Type | Passes when | +|-------|------|-------------| +| `dependencies.zod` in `package.json` | `json_key_exists` | — | +| `dependencies.@asteasolutions/zod-to-openapi` in `package.json` | `json_key_exists` | — | +| `dependencies.reflect-metadata` in `package.json` | `json_key_exists` | — | +| `dependencies.swagger-ui-express` in `package.json` | `json_key_exists` | — | + +--- + +## Post-generation commands + +No PostGenerationCommands. + +## Test commands + +No TestCommands. + +--- + +## Conflicts + +None. + +--- + +## See also + +- [generators/express_decorators_core.md](express_decorators_core.md) +- [generators/express_openapi_setup.md](express_openapi_setup.md) +- [docs/user/decorators.md](../../user/decorators.md) diff --git a/docs/contributor/navigation-guide.md b/docs/contributor/navigation-guide.md index 7a03f53..34052f7 100644 --- a/docs/contributor/navigation-guide.md +++ b/docs/contributor/navigation-guide.md @@ -29,6 +29,8 @@ This guide answers the question **"where do I look?"** for any change you might | Change a question's label or default | `flows/.go` — the relevant `&flow.XxxQuestion{}` struct | [flows/](flows/) (update the flow doc too) | | Add a branch to an existing flow | `flows/.go` — add a new `*flow.Next` edge | [authoring-flows.md — branching](authoring-flows.md#branching) | | Add a new question type | `internal/flow/question.go` + `internal/cli/form_walker.go` (Huh rendering) | [architecture.md — flow engine](architecture.md#flow-engine) | +| Add a `Description` to a Confirm question (multi-line context shown under the title) | `internal/flow/question.go` — `ConfirmQuestion.Description` is rendered by `internal/cli/prompt.go` via `huh.NewConfirm().Description(...)` | [flows/init.md](flows/init.md) — see `ts-backend-decorators-validation` for an example | +| Wire the decorator/validation/OpenAPI questions into the init flow | `flows/init.go` — `ts-backend-decorators-validation` (Confirm) and `ts-backend-validation-lib` (Option), plus the conditional generator selection in `resolveMonorepoGenerators` | [flows/init.md](flows/init.md) | | Fix back-navigation in the TUI | `internal/cli/form_walker.go` — `buildHideFunc` | [architecture.md — flow engine](architecture.md#flow-engine) | | Fix loop question rendering | `internal/cli/prompt.go` — `runLoopSubForms` | [authoring-flows.md — loops](authoring-flows.md#loops) | | Understand question traversal order | `internal/flow/engine.go` | [architecture.md — flow engine](architecture.md#flow-engine) | @@ -40,12 +42,21 @@ This guide answers the question **"where do I look?"** for any change you might | Task | Primary file(s) | Read first | |------|----------------|------------| | Add a new generator | Create `generators//` + register in `internal/cli/registry.go` | [authoring-generators.md](authoring-generators.md), then copy [generators/_template.md](generators/_template.md) | -| Understand the Express generator family | `generators/express_*`, `generators/auth_jwt_*`, `flows/monorepo.go` | [express-backend-guide.md](express-backend-guide.md) — read this first | +| Understand the Express generator family | `generators/express_*`, `generators/auth_jwt_*`, `flows/init.go` | [express-backend-guide.md](express-backend-guide.md) — read this first | +| Understand the decorator-based validation / OpenAPI stack | `generators/zod_validation_deps/`, `express_decorators_core/`, `express_openapi_setup/`, `decorators_clean_arch_adapter/`, `decorators_mvc_adapter/`, `decorators_hexagonal_adapter/` | [user/decorators.md](../user/decorators.md), [express_decorators_core.md](generators/express_decorators_core.md), [express_openapi_setup.md](generators/express_openapi_setup.md), [flows/init.md — decorator-aware templating](flows/init.md#decorator-aware-templating) | +| Understand the classic JSDoc-driven Swagger | `generators/express_swagger_jsdoc/` — selected by `flows/init.go` when `ts-backend-decorators-validation = false`. Existing controllers (`express_server_entrypoint`, `auth_jwt_mvc_route`, `auth_jwt_clean_arch_module`) ship `@openapi` JSDoc blocks that swagger-jsdoc scans at boot | [express_swagger_jsdoc.md](generators/express_swagger_jsdoc.md), [user/decorators.md — Classic JSDoc path](../user/decorators.md#classic-jsdoc-path-writing-new-docs) | +| Add `@openapi` JSDoc to a new route handler | Copy the format from `auth_jwt_mvc_route/files/src/controllers/auth.controller.ts.tmpl` (decorators-OFF branch) | [user/decorators.md](../user/decorators.md), [express_swagger_jsdoc.md](generators/express_swagger_jsdoc.md) | +| Make an existing generator decorator-aware | `generators//generator.go` — read `slices.Contains(ctx.PreviousGens, "express_decorators_core")`, render templates with a `HasDecorators` flag (see `auth_jwt_mvc_route` and `auth_jwt_clean_arch_module` for the pattern) | [flows/init.md — decorator-aware templating](flows/init.md#decorator-aware-templating) | | Understand the auth module (JWT) | `generators/auth_jwt_vanilla/`, `auth_jwt_users_schema/`, `auth_jwt_mvc_route/`, `auth_jwt_clean_arch_module/` | [auth_jwt_vanilla.md](generators/auth_jwt_vanilla.md), [auth_jwt_mvc_route.md](generators/auth_jwt_mvc_route.md), [auth_jwt_clean_arch_module.md](generators/auth_jwt_clean_arch_module.md) | +| Understand BetterAuth wiring | `generators/auth_better_auth/` — `lib/auth.ts` plus a direct `app.ts` mount of `toNodeHandler(auth)` (no intermediate route file) | [auth_better_auth.md](generators/auth_better_auth.md) | | Understand the shared infrastructure generators | `generators/express_shared_errors/`, `express_error_middleware/`, `express_rate_limit/`, `express_auth_validators/` | [express_shared_errors.md](generators/express_shared_errors.md), [express_error_middleware.md](generators/express_error_middleware.md) | | Fix a generator's file output | `generators//generator.go` | [generators/.md](generators/) | | Add a validator to a generator | `generators//manifest.go` — `Validators` field | [authoring-generators.md — validators](authoring-generators.md#validators) | | Add a post-gen or test command | `generators//manifest.go` — `PostGenerationCommands` / `TestCommands` | [authoring-generators.md — commands](authoring-generators.md#postgenerationcommands-and-testcommands) | +| Opt a command **out** of the test-flow cache | `generators//manifest.go` — set `NoCache: true` on the `dotapi.Command{}` (commands are cacheable by default) | [authoring-generators.md — NoCache](authoring-generators.md#nocache-caching-is-opt-out), [test-flow.md — Case-level cache](test-flow.md#case-level-cache) | +| Force a full test-flow re-run | `go run ./tools/test-flow -no-cache` (or `rm -rf .test-flow-cache`) | [test-flow.md — Forcing a re-run](test-flow.md#forcing-a-re-run) | +| Run every test-flow case even after a failure | Pass `-keep-going` (the runner is fail-fast by default) | [test-flow.md — Fail-fast](test-flow.md#fail-fast-default) | +| Bump the test-flow cache schema (invalidate every entry) | `tools/test-flow/cache_persist.go` — increment `cacheSchemaVersion` | [test-flow.md — Case-level cache](test-flow.md#case-level-cache) | | Fix dependency ordering | `generators//manifest.go` — `DependsOn` + `internal/generator/sorter.go` | [architecture.md — generator pipeline](architecture.md#generator-pipeline) | | Fix the topological sort | `internal/generator/sorter.go` | [architecture.md — generator pipeline](architecture.md#generator-pipeline) | | Fix transitive dep resolution | `internal/generator/resolver.go` | — | diff --git a/docs/contributor/test-flow.md b/docs/contributor/test-flow.md index f3cad66..10b8374 100644 --- a/docs/contributor/test-flow.md +++ b/docs/contributor/test-flow.md @@ -97,6 +97,99 @@ make test-flow # equivalent to go run ./tools/test-flow -skip-test | `-skip-test` | `false` | Skip all `TestCommands` globally. Overrides the fixture's `skip_test_commands`. | | `-only NAMES` | (all) | Comma-separated list of fixture `name` values to run. | | `-keep` | `false` | Do not delete scratch directories after the run. Lets you inspect generated files. | +| `-no-cache` | `false` | Ignore cache hits and re-run every case from scratch. Cache entries are still refreshed on success. | +| `-keep-going` | `false` | Continue running remaining cases after a failure. Without this flag the runner stops at the **first** failing case (fail-fast is the default). | + +--- + +## Fail-fast (default) + +The runner stops at the first failing case so you see the failure immediately instead of waiting for the rest of the suite. The summary reports how many cases were skipped: + +``` +✗ 1/18 cases failed (10 not run) + +Stopped at first failure (pass -keep-going to run every case). +``` + +Pass `-keep-going` when you want a full report — typical for triaging multiple unrelated failures or generating an artefact-rich CI run: + +```bash +go run ./tools/test-flow -keep-going # run everything, then summarize +make test-flows -- -keep-going # via the Makefile shortcut +``` + +Failed cases never write to `.test-flow-cache/`, so re-running after a fix only retries cases that didn't pass last time (if combined with the cache). + +--- + +## Case-level cache + +`test-flow` keeps a per-case cache under `.test-flow-cache/` (gitignored) so the second run of an unchanged case completes in well under a second instead of multiple minutes. A typical full warm run finishes in ~3-5 s vs. ~7 min cold. + +### How a cache hit is decided + +1. The runner always re-runs the cheap stages: flow → generator resolution → file persistence → validators. These take <1 s per case. +2. Once the resolved invocation list is known, the runner computes a SHA-256 fingerprint over: + - the fixture's JSON file (raw bytes), + - every involved generator's source tree (`generators//` recursively, sorted by name), + - the entire `flows/` directory (any flow definition edit invalidates), + - `pkg/dotapi/` (Manifest schema changes invalidate), + - `tools/test-flow/` (cache logic + runner changes invalidate), + - the `-skip-post` / `-skip-test` flags (different modes get different cache slots), + - a schema version constant inside `cache_persist.go` (bump it to force-invalidate every cache entry). +3. The cache hits **only when both** of these are true: + - the previous successful run's fingerprint matches, AND + - no `PostGenerationCommand` or `TestCommand` across the involved manifests is marked `NoCache: true` (commands are cacheable by default). +4. On a hit, post-gen and test commands are skipped entirely; the case is reported with `cache: HIT — skipping post-gen + test commands`. + +### Cache misses + +A miss can be caused by any of: + +- editing a fixture JSON file, +- editing any generator that the case resolves to, +- editing a flow definition (`flows/*.go`), +- editing `pkg/dotapi/manifest.go` (or anything else under `pkg/dotapi/`), +- bumping the cache schema constant, +- a generator marking a command `NoCache: true` (a single such command anywhere in the resolved invocation set forces the entire case to re-run). + +When a fingerprint exists but at least one command is `NoCache: true`, the report shows `cache: HIT — N non-cacheable command(s) — running anyway` and the case re-runs. + +### Forcing a re-run + +```bash +go run ./tools/test-flow -no-cache # ignore all cache hits +go run ./tools/test-flow -no-cache -only my_case # for a single case +rm -rf .test-flow-cache # nuclear option +``` + +Failed runs intentionally leave **no** cache entry — that way the next invocation always retries them. + +### Cacheable by default — opt out with `NoCache` + +Commands are cacheable by default. The contract: "given identical scaffolded inputs, the command's outcome is the same." Examples that qualify automatically (no extra field needed): + +- `pnpm install`, `pnpm exec tsc --noEmit`, `pnpm exec vitest run …` +- `pnpm exec biome check .`, `pnpm format:check` +- `pnpm db:generate`, `cp .env.example .env` (idempotent, project-local) + +Set `NoCache: true` on commands that must run every invocation: + +```go +TestCommands: []dotapi.Command{ + // Smoke-start the real dev server every run to catch port-binding regressions. + {Cmd: "pnpm exec vite", Background: true, ReadyDelay: 4 * time.Second, NoCache: true}, +}, +``` + +Common reasons to opt out: + +- Background dev-server smoke-starts (`react_app`) — we want a real boot every run. +- Network-touching one-shots whose result depends on remote state at run time. +- Anything you simply aren't sure is deterministic. + +The cache only fires when **no** command in the resolved invocation set has `NoCache: true` — a single opt-out forces the case to re-run. --- diff --git a/docs/user/decorators.md b/docs/user/decorators.md new file mode 100644 index 0000000..53b14a6 --- /dev/null +++ b/docs/user/decorators.md @@ -0,0 +1,283 @@ +# Decorator-based validation and OpenAPI + +OpenAPI/Swagger is **always available at `/docs`** for any Express backend +generated by dot. You pick how the spec is built: + +- **Decorator runtime** (default) — TypeScript class decorators declare routes, + validate inputs with Zod, and the OpenAPI v3 document is built from the + metadata Express sees at runtime. +- **Classic JSDoc** — handlers stay plain functions; the spec is assembled at + boot from `@openapi` JSDoc blocks in `src/**/*.ts` via `swagger-jsdoc`. + +This page focuses on the decorator path. The classic path needs no +configuration: every handler shipped by dot already carries a `@openapi` +JSDoc block, and you keep using the same syntax for new endpoints. + +The decorator system is built around a small `RouterAdapter` interface so it +can be extended to other frameworks (Fastify, etc.) without rewriting the +decorators. + +## Quickstart + +```bash +dot init +# Answer the survey: +# Library / Framework → Express +# Choose your architecture → Clean Architecture | MVC +# Use decorator-based validation… → Yes (or No for the classic JSDoc path) +# Validation library → Zod (only when Yes) +# ... +pnpm install +pnpm dev +# Open http://localhost:3000/docs — the Swagger UI is served regardless of +# which path you chose above. +``` + +The scaffold ships with one decorated example controller you can copy or +delete. Its routes appear under `/api/example` and in the OpenAPI document at +`/docs/openapi.json`. The DecoratorRouter is mounted at the root of the app +(not under `/api`), so each controller declares its own full prefix +(`'/api/example'`, `'/auth'`, …) — there is no implicit base path to remember. + +## Anatomy of a decorated controller + +```ts +import type { Request, Response } from 'express'; +import { + ApiResponse, + Body, + Controller, + Get, + Params, + Post, +} from '../shared/decorators'; +import { z } from '../shared/openapi/registry'; + +const userParams = z.object({ id: z.string().uuid() }); +const createUser = z.object({ email: z.string().email(), name: z.string().min(1) }); +const userResponse = z.object({ id: z.string().uuid(), email: z.string(), name: z.string() }); + +@Controller({ tag: 'users', prefix: '/users', description: 'User management' }) +export class UsersController { + @Get(':id') + @Params(userParams) + @ApiResponse(200, 'User fetched', userResponse) + @ApiResponse(404, 'User not found') + get(req: Request, res: Response): void { + res.json({ id: req.params.id, email: 'demo@example.com', name: 'Demo' }); + } + + @Post('/') + @Body(createUser) + @ApiResponse(201, 'User created', userResponse) + create(req: Request, res: Response): void { + res.status(201).json({ id: '…', ...req.body }); + } +} +``` + +Behind the scenes: + +- `@Controller` records the OpenAPI tag and the route prefix. +- `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` declare an HTTP method and path. +- `@Body`/`@Query`/`@Params` install validation middleware that runs before + the handler. On success the parsed value replaces `req.body|query|params`, + so the handler sees coerced, typed input. On failure the request is rejected + with a `400` and a structured payload listing every Zod issue. +- `@ApiResponse(status, description, schema?)` adds a response definition to + the OpenAPI spec. It can be stacked to document multiple status codes. +- `@Auth()` marks the route as protected. The route gets a `BearerAuth` + security entry in OpenAPI, and (when an `authMiddleware` is registered on + the adapter) the middleware runs before the handler. +- `@RequiredHeaders([...])` installs a pre-handler check that returns `400` + if any of the named headers are missing. + +## Wiring the router + +```ts +import 'reflect-metadata'; +import express from 'express'; +import { + DecoratorRouter, + ExpressRouterAdapter, +} from './shared/decorators'; +import { buildOpenApiSpec, createRegistry, mountSwagger } from './shared/openapi'; +import { UsersController } from './controllers/users.controller'; + +const app = express(); +app.use(express.json()); + +const router = new DecoratorRouter(new ExpressRouterAdapter()) + .registerController(new UsersController()); + +app.use(router.build()); + +const spec = buildOpenApiSpec({ + info: { title: 'My API', version: '1.0.0' }, + servers: [{ url: '/' }], + routes: router.routes(), + registry: createRegistry(), +}); +mountSwagger(app, spec); +``` + +`DecoratorRouter` walks each controller's metadata, pushes a `RouteRegistration` +into the adapter, and remembers what it registered so the OpenAPI generator +sees exactly the routes that are wired. + +## Authentication + +Pass an Express middleware to the adapter and the `@Auth()` decorator will +gate the route with it: + +```ts +import { authMiddleware } from './shared/middlewares/auth.middleware'; + +const router = new DecoratorRouter( + new ExpressRouterAdapter({ authMiddleware }), +).registerController(new UsersController()); +``` + +When you scaffold with **JWT auth + decorators**, dot wires this for you: +the `auth_jwt_vanilla` generator injects `authMiddleware` into +`ExpressRouterAdapter`, and the auth controller (MVC or Clean Architecture) +is generated as a decorated `AuthController` registered on the same router. +`@Auth()`-protected routes are then gated automatically. + +`BetterAuth` keeps its own catch-all route (`toNodeHandler(auth)`) and is not +exposed through the decorator system — it manages its own routing. + +The OpenAPI document is updated regardless: protected routes always show the +`BearerAuth` requirement, even if you have not registered a middleware yet. + +## Async error handling + +The Express adapter wraps every decorated handler so a thrown error or a +rejected promise is forwarded to Express' error pipeline via `next(err)` — +you can rely on your existing error middleware without sprinkling try/catch +through every controller method. + +## OpenAPI + +- The spec is served raw at `GET /docs/openapi.json` and rendered at + `GET /docs` via `swagger-ui-express`. +- Every Zod schema you reference in `@Body`/`@Query`/`@Params`/`@ApiResponse` + is automatically converted using `@asteasolutions/zod-to-openapi`. +- For named, reusable component schemas, register them on the registry and + reference them inline: + + ```ts + import { getSharedRegistry, z } from '../openapi/registry'; + + export const userResponse = z.object({ id: z.string(), email: z.string() }) + .openapi('User'); + getSharedRegistry().register('User', userResponse); + ``` + +- Tests should call `createRegistry()` to obtain a fresh, isolated registry + rather than share global state. + +## Architecture-specific layout + +| Architecture | Controller location | Schema location | +|---|---|---| +| Clean | `src/modules//application/controllers/` | `src/modules//application/validators/` | +| MVC | `src/controllers/` | `src/shared/validators/` | +| Hexagonal | `src/adapters/primary/http/controllers/` | `src/adapters/primary/http/schemas/` | + +The decorator and OpenAPI runtimes themselves live in `src/shared/decorators/` +and `src/shared/openapi/` regardless of architecture. + +## Adding a Fastify (or other framework) adapter + +The decorator-router reads metadata and produces a `RouteRegistration`: + +```ts +export interface RouteRegistration { + method: HttpMethod; + path: string; + handler: (...args: unknown[]) => unknown; + validation: { body?: ZodSchema; params?: ZodSchema; query?: ZodSchema }; + requiredHeaders: string[]; + isProtected: boolean; +} +``` + +To plug in another framework, implement `RouterAdapter`: + +```ts +import type { FastifyInstance } from 'fastify'; +import type { RouteRegistration, RouterAdapter } from '../shared/decorators/router-adapter'; + +export class FastifyRouterAdapter implements RouterAdapter { + constructor(private readonly fastify: FastifyInstance) {} + + register(reg: RouteRegistration): void { + // translate validation/auth/headers into Fastify hooks and routes + } + + build(): FastifyInstance { + return this.fastify; + } +} +``` + +The decorator surface — `@Controller`, `@Get`, `@Body`, … — stays the same. +Only the adapter changes. + +## Tests shipped with the scaffold + +**Decorator path:** +- `src/shared/decorators/__tests__/decorators.unit.test.ts` covers route + registration, body/params/query validation, auth, and required headers. +- `src/shared/openapi/__tests__/spec.unit.test.ts` verifies the generated + OpenAPI document — paths, tags, security, and the served JSON endpoint. +- `src/__tests__/decorators-.e2e.test.ts` boots the real `app` via + supertest and exercises the example controller end to end. + +**Classic JSDoc path:** +- `src/shared/swagger/__tests__/swagger.unit.test.ts` boots the app, hits + `/docs/openapi.json`, and asserts that `/health` (and any other + `@openapi`-annotated endpoint) is present in the spec. + +Run them with `pnpm test`. + +--- + +## Classic JSDoc path: writing new docs + +When you opt out of decorators, every endpoint that should appear in the spec +needs a `@openapi` JSDoc block above its handler. swagger-jsdoc scans +`src/**/*.{ts,js}` at boot and folds every block into a single document. + +```ts +/** + * @openapi + * /users/{id}: + * get: + * tags: [Users] + * summary: Fetch a user by id + * parameters: + * - name: id + * in: path + * required: true + * schema: { type: string, format: uuid } + * responses: + * 200: + * description: The user + * content: + * application/json: + * schema: + * type: object + * properties: + * id: { type: string, format: uuid } + * email: { type: string, format: email } + * 404: { description: Not found } + */ +export async function getUser(req: Request, res: Response): Promise { + // ... +} +``` + +The dot-generated controllers (`auth.controller.ts`, `app.ts`'s `/health`) +already follow this convention — copy one of them as a template. diff --git a/flows/init.go b/flows/init.go index 8473fa7..63346df 100644 --- a/flows/init.go +++ b/flows/init.go @@ -7,6 +7,12 @@ import ( const CLEAN_ARCHITECTURE = "clean-architecture" const MVC_ARCHITECTURE = "mvc-architecture" +const HEXAGONAL_ARCHITECTURE = "hexagonal-architecture" + +// ValidationLibZod is the ID of the Zod validation library — kept as a +// constant so future libraries (yup, valibot, …) can be added without +// scattering string literals. +const ValidationLibZod = "zod" // InitFlow is the default DOT scaffolding flow. It walks the user through // project name → monorepo structure → language stack → linting → database → auth. @@ -81,13 +87,35 @@ func InitFlow() *FlowDef { }, } + validationLib := &flow.OptionQuestion{ + QuestionBase: flow.QuestionBase{ID_: "ts-backend-validation-lib"}, + Label: "Validation library", + Description: "Schema library used to validate request inputs and document the OpenAPI spec.", + Options: []*flow.Option{ + {Label: "Zod", Value: ValidationLibZod, Next: &flow.Next{Question: formatter}}, + }, + } + + decorators := &flow.ConfirmQuestion{ + QuestionBase: flow.QuestionBase{ID_: "ts-backend-decorators-validation"}, + Label: "Use decorator-based validation and OpenAPI documentation?", + Description: "OpenAPI/Swagger is always available at /docs. Choose Yes for an end-to-end " + + "decorator API (@Controller, @Get, @Body, @Response) with automatic Zod request/response " + + "validation and a spec built from runtime metadata. Choose No to keep plain Express " + + "handlers — the generated code includes JSDoc @openapi comments that swagger-jsdoc scans " + + "to build the spec.", + Default: true, + Then: &flow.Next{Question: validationLib}, + Else: &flow.Next{Question: formatter}, + } + architecture := &flow.OptionQuestion{ QuestionBase: flow.QuestionBase{ID_: "ts-backend-architecture"}, Label: "Choose your architecture.", Options: []*flow.Option{ - {Label: "Clean Architecture", Value: CLEAN_ARCHITECTURE, Next: &flow.Next{Question: formatter}}, - {Label: "MVC", Value: MVC_ARCHITECTURE, Next: &flow.Next{Question: formatter}}, - // {Label: "Hexagonal", Value: "hexagonal-architecture", Next: &flow.Next{Question: formatter}}, + {Label: "Clean Architecture", Value: CLEAN_ARCHITECTURE, Next: &flow.Next{Question: decorators}}, + {Label: "MVC", Value: MVC_ARCHITECTURE, Next: &flow.Next{Question: decorators}}, + // {Label: "Hexagonal", Value: HEXAGONAL_ARCHITECTURE, Next: &flow.Next{Question: decorators}}, }, } @@ -159,6 +187,7 @@ func resolveMonorepoGenerators(s *spec.ProjectSpec) []Invocation { orm, _ := s.Answers["ts-backend-orm"].(string) authEnabled, _ := s.Answers["enable-auth"].(bool) authMethod, _ := s.Answers["ts-backend-auth-method"].(string) + decoratorsEnabled, _ := s.Answers["ts-backend-decorators-validation"].(bool) if stack == "typescript" { out = append(out, Invocation{Name: "typescript_base"}) @@ -181,6 +210,31 @@ func resolveMonorepoGenerators(s *spec.ProjectSpec) []Invocation { out = append(out, Invocation{Name: "backend_architecture_clean_architecture"}) case MVC_ARCHITECTURE: out = append(out, Invocation{Name: "backend_architecture_mvc"}) + case HEXAGONAL_ARCHITECTURE: + out = append(out, Invocation{Name: "backend_architecture_hexagonal_architecture"}) + } + + if framework == "express" { + if decoratorsEnabled { + out = append(out, + Invocation{Name: "zod_validation_deps"}, + Invocation{Name: "express_decorators_core"}, + Invocation{Name: "express_openapi_setup"}, + ) + switch architecture { + case CLEAN_ARCHITECTURE: + out = append(out, Invocation{Name: "decorators_clean_arch_adapter"}) + case MVC_ARCHITECTURE: + out = append(out, Invocation{Name: "decorators_mvc_adapter"}) + case HEXAGONAL_ARCHITECTURE: + out = append(out, Invocation{Name: "decorators_hexagonal_adapter"}) + } + } else { + // Always wire the JSDoc-based Swagger so /docs works regardless of + // the decorator choice — generated controllers ship with @openapi + // comments that swagger-jsdoc picks up at boot. + out = append(out, Invocation{Name: "express_swagger_jsdoc"}) + } } if formatter == "prettier" { diff --git a/generators/auth_better_auth/files/src/routes/auth.route.ts.tmpl b/generators/auth_better_auth/files/src/routes/auth.route.ts.tmpl deleted file mode 100644 index 7935c66..0000000 --- a/generators/auth_better_auth/files/src/routes/auth.route.ts.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -import { Router } from 'express'; -import { auth } from '../lib/auth'; -import { toNodeHandler } from 'better-auth/node'; - -const router = Router(); - -// BetterAuth handles all /api/auth/* routes -router.all('/api/auth/*', toNodeHandler(auth)); - -export default router; diff --git a/generators/auth_better_auth/generator.go b/generators/auth_better_auth/generator.go index e696c45..919c87f 100644 --- a/generators/auth_better_auth/generator.go +++ b/generators/auth_better_auth/generator.go @@ -49,14 +49,28 @@ func (g *Generator) Generate(ctx *dotapi.Context) error { updated := existing + fmt.Sprintf("\n# Auth (BetterAuth)\nBETTER_AUTH_SECRET=%s\nBETTER_AUTH_URL=http://localhost:${PORT:-3000}\n", generateSecretPlaceholder()) ctx.State.WriteFile(".env.example", []byte(updated), state.ContentRaw) - // Inject cookie-parser middleware into app.ts + // Inject cookie-parser middleware AND mount BetterAuth's catch-all + // (toNodeHandler) directly into app.ts. We deliberately skip the + // intermediate "src/routes/auth.route.ts" indirection — BetterAuth owns + // its routing and a one-liner in app.ts is the clearest place to wire it. if f, ok := ctx.State.GetFile("src/app.ts"); ok { content := string(f.Content) if !strings.Contains(content, "cookieParser") { content = "import cookieParser from 'cookie-parser';\n" + content content = strings.Replace(content, "app.use(express.json());", "app.use(express.json());\napp.use(cookieParser());", 1) - ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) } + if !strings.Contains(content, "toNodeHandler") { + imports := "import { toNodeHandler } from 'better-auth/node';\nimport { auth } from './lib/auth';\n" + content = imports + content + mount := "app.all('/api/auth/*', toNodeHandler(auth));\n\n" + exportText := "export default app;" + if strings.Contains(content, exportText) { + content = strings.Replace(content, exportText, mount+exportText, 1) + } else { + content += "\n" + mount + } + } + ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) } return nil diff --git a/generators/auth_better_auth/manifest.go b/generators/auth_better_auth/manifest.go index 0d33ab2..42cbdd8 100644 --- a/generators/auth_better_auth/manifest.go +++ b/generators/auth_better_auth/manifest.go @@ -4,12 +4,11 @@ import "github.com/version14/dot/pkg/dotapi" var Manifest = dotapi.Manifest{ Name: "auth_better_auth", - Version: "0.1.0", - Description: "BetterAuth setup with Drizzle adapter: src/lib/auth.ts and auth route handler", + Version: "0.2.0", + Description: "BetterAuth setup with Drizzle adapter: src/lib/auth.ts plus a direct toNodeHandler mount in src/app.ts (no intermediate route file)", DependsOn: []string{"drizzle_postgres_adapter"}, Outputs: []string{ "src/lib/auth.ts", - "src/routes/auth.route.ts", }, Validators: []dotapi.Validator{ { diff --git a/generators/auth_jwt_clean_arch_module/files/src/modules/auth/application/controllers/auth.controller.ts b/generators/auth_jwt_clean_arch_module/files/src/modules/auth/application/controllers/auth.controller.ts deleted file mode 100644 index b0c285a..0000000 --- a/generators/auth_jwt_clean_arch_module/files/src/modules/auth/application/controllers/auth.controller.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { NextFunction, Request, Response } from 'express'; -import type { AuthRequest } from '../../../../shared/middlewares/auth.middleware'; -import { RefreshTokenRepository } from '../../infrastructure/database/repositories/refresh-token.repository'; -import { UserRepository } from '../../infrastructure/database/repositories/user.repository'; -import { LoginUseCase } from '../use-cases/login.use-case'; -import { LogoutUseCase } from '../use-cases/logout.use-case'; -import { RefreshUseCase } from '../use-cases/refresh.use-case'; -import { RegisterUseCase } from '../use-cases/register.use-case'; - -const userRepository = new UserRepository(); -const refreshTokenRepository = new RefreshTokenRepository(); -const loginUseCase = new LoginUseCase(userRepository, refreshTokenRepository); -const registerUseCase = new RegisterUseCase(userRepository, refreshTokenRepository); -const refreshUseCase = new RefreshUseCase(refreshTokenRepository); -const logoutUseCase = new LogoutUseCase(refreshTokenRepository); - -const COOKIE_OPTIONS = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, -}; - -function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) { - res.cookie('access_token', tokens.accessToken, COOKIE_OPTIONS); - res.cookie('refresh_token', tokens.refreshToken, COOKIE_OPTIONS); -} - -export async function register(req: Request, res: Response, next: NextFunction): Promise { - try { - const { email, password } = req.body as { email: string; password: string }; - const tokens = await registerUseCase.execute(email, password); - setAuthCookies(res, tokens); - res.status(201).json({ message: 'Registered successfully' }); - } catch (error) { - next(error); - } -} - -export async function login(req: Request, res: Response, next: NextFunction): Promise { - try { - const { email, password } = req.body as { email: string; password: string }; - const tokens = await loginUseCase.execute(email, password); - setAuthCookies(res, tokens); - res.json({ message: 'Logged in successfully' }); - } catch (error) { - next(error); - } -} - -export async function refresh(req: Request, res: Response, next: NextFunction): Promise { - try { - const refreshToken = req.cookies?.refresh_token; - if (!refreshToken) { - res.status(400).json({ error: 'Refresh token required' }); - return; - } - const tokens = await refreshUseCase.execute(refreshToken); - setAuthCookies(res, tokens); - res.json({ message: 'Tokens refreshed' }); - } catch (error) { - next(error); - } -} - -export async function logout(req: Request, res: Response, next: NextFunction): Promise { - try { - const refreshToken = req.cookies?.refresh_token; - if (refreshToken) { - await logoutUseCase.execute(refreshToken); - } - res.clearCookie('access_token'); - res.clearCookie('refresh_token'); - res.status(204).send(); - } catch (error) { - next(error); - } -} - -export async function me(req: AuthRequest, res: Response): Promise { - res.json({ user: req.user }); -} diff --git a/generators/auth_jwt_clean_arch_module/files/src/modules/auth/application/controllers/auth.controller.ts.tmpl b/generators/auth_jwt_clean_arch_module/files/src/modules/auth/application/controllers/auth.controller.ts.tmpl new file mode 100644 index 0000000..ebd0127 --- /dev/null +++ b/generators/auth_jwt_clean_arch_module/files/src/modules/auth/application/controllers/auth.controller.ts.tmpl @@ -0,0 +1,253 @@ +{{ if .HasDecorators -}} +import type { Request, Response } from 'express'; +import { z } from '../../../../shared/openapi/registry'; +import { + ApiResponse, + Auth, + Body, + Controller, + Get, + Post, +} from '../../../../shared/decorators'; +import type { AuthRequest } from '../../../../shared/middlewares/auth.middleware'; +import { RefreshTokenRepository } from '../../infrastructure/database/repositories/refresh-token.repository'; +import { UserRepository } from '../../infrastructure/database/repositories/user.repository'; +import { LoginUseCase } from '../use-cases/login.use-case'; +import { LogoutUseCase } from '../use-cases/logout.use-case'; +import { RefreshUseCase } from '../use-cases/refresh.use-case'; +import { RegisterUseCase } from '../use-cases/register.use-case'; + +const credentials = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); +const message = z.object({ message: z.string() }); +const userPayload = z.object({ user: z.unknown() }); + +const userRepository = new UserRepository(); +const refreshTokenRepository = new RefreshTokenRepository(); +const loginUseCase = new LoginUseCase(userRepository, refreshTokenRepository); +const registerUseCase = new RegisterUseCase(userRepository, refreshTokenRepository); +const refreshUseCase = new RefreshUseCase(refreshTokenRepository); +const logoutUseCase = new LogoutUseCase(refreshTokenRepository); + +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, +}; + +function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) { + res.cookie('access_token', tokens.accessToken, COOKIE_OPTIONS); + res.cookie('refresh_token', tokens.refreshToken, COOKIE_OPTIONS); +} + +@Controller({ tag: 'auth', prefix: '/auth', description: 'Authentication endpoints (Clean Architecture)' }) +export class AuthController { + @Post('/register') + @Body(credentials) + @ApiResponse(201, 'User registered', message) + async register(req: Request, res: Response): Promise { + const { email, password } = req.body as { email: string; password: string }; + const tokens = await registerUseCase.execute(email, password); + setAuthCookies(res, tokens); + res.status(201).json({ message: 'Registered successfully' }); + } + + @Post('/login') + @Body(credentials) + @ApiResponse(200, 'Logged in', message) + async login(req: Request, res: Response): Promise { + const { email, password } = req.body as { email: string; password: string }; + const tokens = await loginUseCase.execute(email, password); + setAuthCookies(res, tokens); + res.json({ message: 'Logged in successfully' }); + } + + @Post('/refresh') + @ApiResponse(200, 'Tokens refreshed', message) + async refresh(req: Request, res: Response): Promise { + const refreshToken = req.cookies?.refresh_token; + if (!refreshToken) { + res.status(400).json({ error: 'Refresh token required' }); + return; + } + const tokens = await refreshUseCase.execute(refreshToken); + setAuthCookies(res, tokens); + res.json({ message: 'Tokens refreshed' }); + } + + @Post('/logout') + @ApiResponse(204, 'Logged out') + async logout(req: Request, res: Response): Promise { + const refreshToken = req.cookies?.refresh_token; + if (refreshToken) { + await logoutUseCase.execute(refreshToken); + } + res.clearCookie('access_token'); + res.clearCookie('refresh_token'); + res.status(204).send(); + } + + @Get('/me') + @Auth() + @ApiResponse(200, 'Current user', userPayload) + @ApiResponse(401, 'Unauthorized') + async me(req: Request, res: Response): Promise { + res.json({ user: (req as AuthRequest).user }); + } +} +{{ else -}} +import type { NextFunction, Request, Response } from 'express'; +import type { AuthRequest } from '../../../../shared/middlewares/auth.middleware'; +import { RefreshTokenRepository } from '../../infrastructure/database/repositories/refresh-token.repository'; +import { UserRepository } from '../../infrastructure/database/repositories/user.repository'; +import { LoginUseCase } from '../use-cases/login.use-case'; +import { LogoutUseCase } from '../use-cases/logout.use-case'; +import { RefreshUseCase } from '../use-cases/refresh.use-case'; +import { RegisterUseCase } from '../use-cases/register.use-case'; + +const userRepository = new UserRepository(); +const refreshTokenRepository = new RefreshTokenRepository(); +const loginUseCase = new LoginUseCase(userRepository, refreshTokenRepository); +const registerUseCase = new RegisterUseCase(userRepository, refreshTokenRepository); +const refreshUseCase = new RefreshUseCase(refreshTokenRepository); +const logoutUseCase = new LogoutUseCase(refreshTokenRepository); + +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, +}; + +function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) { + res.cookie('access_token', tokens.accessToken, COOKIE_OPTIONS); + res.cookie('refresh_token', tokens.refreshToken, COOKIE_OPTIONS); +} + +/** + * @openapi + * /auth/register: + * post: + * tags: [Auth] + * summary: Register a new user + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string, format: email } + * password: { type: string, minLength: 8 } + * responses: + * 201: { description: User registered, sets access_token + refresh_token cookies } + * 409: { description: Email already in use } + */ +export async function register(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, password } = req.body as { email: string; password: string }; + const tokens = await registerUseCase.execute(email, password); + setAuthCookies(res, tokens); + res.status(201).json({ message: 'Registered successfully' }); + } catch (error) { + next(error); + } +} + +/** + * @openapi + * /auth/login: + * post: + * tags: [Auth] + * summary: Authenticate a user + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string, format: email } + * password: { type: string } + * responses: + * 200: { description: Logged in, sets access_token + refresh_token cookies } + * 401: { description: Invalid credentials } + */ +export async function login(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, password } = req.body as { email: string; password: string }; + const tokens = await loginUseCase.execute(email, password); + setAuthCookies(res, tokens); + res.json({ message: 'Logged in successfully' }); + } catch (error) { + next(error); + } +} + +/** + * @openapi + * /auth/refresh: + * post: + * tags: [Auth] + * summary: Rotate the access token using the refresh_token cookie + * responses: + * 200: { description: Tokens refreshed } + * 401: { description: Invalid or missing refresh token } + */ +export async function refresh(req: Request, res: Response, next: NextFunction): Promise { + try { + const refreshToken = req.cookies?.refresh_token; + if (!refreshToken) { + res.status(400).json({ error: 'Refresh token required' }); + return; + } + const tokens = await refreshUseCase.execute(refreshToken); + setAuthCookies(res, tokens); + res.json({ message: 'Tokens refreshed' }); + } catch (error) { + next(error); + } +} + +/** + * @openapi + * /auth/logout: + * post: + * tags: [Auth] + * summary: Revoke the refresh token and clear cookies + * responses: + * 204: { description: Logged out } + */ +export async function logout(req: Request, res: Response, next: NextFunction): Promise { + try { + const refreshToken = req.cookies?.refresh_token; + if (refreshToken) { + await logoutUseCase.execute(refreshToken); + } + res.clearCookie('access_token'); + res.clearCookie('refresh_token'); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +/** + * @openapi + * /auth/me: + * get: + * tags: [Auth] + * summary: Return the authenticated user's identity + * security: + * - BearerAuth: [] + * responses: + * 200: { description: Current user } + * 401: { description: Unauthorized } + */ +export async function me(req: AuthRequest, res: Response): Promise { + res.json({ user: req.user }); +} +{{ end -}} diff --git a/generators/auth_jwt_clean_arch_module/files/src/routes/auth.route.ts b/generators/auth_jwt_clean_arch_module/files/src/routes/auth.route.ts.tmpl similarity index 64% rename from generators/auth_jwt_clean_arch_module/files/src/routes/auth.route.ts rename to generators/auth_jwt_clean_arch_module/files/src/routes/auth.route.ts.tmpl index 31c6dbe..b6a6512 100644 --- a/generators/auth_jwt_clean_arch_module/files/src/routes/auth.route.ts +++ b/generators/auth_jwt_clean_arch_module/files/src/routes/auth.route.ts.tmpl @@ -1,3 +1,9 @@ +{{ if .HasDecorators -}} +// Auth routes are registered via DecoratorRouter in src/app.ts. +// Kept as an empty module so the Outputs validator stays satisfied; safe to +// delete if you remove the decorator wiring. +export {}; +{{ else -}} import { Router } from 'express'; import { login, logout, me, refresh, register } from '../modules/auth/application/controllers/auth.controller'; import { authMiddleware } from '../shared/middlewares/auth.middleware'; @@ -11,3 +17,4 @@ router.post('/refresh', refresh); router.post('/logout', logout); export default router; +{{ end -}} diff --git a/generators/auth_jwt_clean_arch_module/generator.go b/generators/auth_jwt_clean_arch_module/generator.go index 61eb4d1..62ed2db 100644 --- a/generators/auth_jwt_clean_arch_module/generator.go +++ b/generators/auth_jwt_clean_arch_module/generator.go @@ -2,6 +2,7 @@ package authjwtcleanarchmodule import ( "embed" + "slices" "strings" "github.com/version14/dot/internal/render" @@ -22,22 +23,39 @@ var fs embed.FS const authRouteImport = "import authRouter from './routes/auth.route';\n" const authRouteUse = "app.use('/auth', authRouter);\n" +const authControllerImport = "import { AuthController } from './modules/auth/application/controllers/auth.controller';\n" + func (g *Generator) Generate(ctx *dotapi.Context) error { + hasDecorators := slices.Contains(ctx.PreviousGens, "express_decorators_core") + renderer := render.NewLocalFolderRenderer(ctx.State) - if err := renderer.Render(fs, nil); err != nil { + if err := renderer.Render(fs, struct{ HasDecorators bool }{HasDecorators: hasDecorators}); err != nil { return err } - if f, ok := ctx.State.GetFile("src/app.ts"); ok { + appPath := "src/app.ts" + + if f, ok := ctx.State.GetFile(appPath); ok { content := string(f.Content) - if !strings.Contains(content, "authRouter") { + if hasDecorators { + if !strings.Contains(content, "AuthController") { + content = authControllerImport + content + content = strings.Replace( + content, + ".registerController(new ExampleController());", + ".registerController(new ExampleController())\n .registerController(new AuthController());", + 1, + ) + ctx.State.WriteFile(appPath, []byte(content), state.ContentRaw) + } + } else if !strings.Contains(content, "authRouter") { content = authRouteImport + content if strings.Contains(content, "app.use(errorMiddleware)") { content = strings.Replace(content, "app.use(errorMiddleware)", authRouteUse+"\napp.use(errorMiddleware)", 1) } else { content = strings.Replace(content, "export default app;", authRouteUse+"\nexport default app;", 1) } - ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) + ctx.State.WriteFile(appPath, []byte(content), state.ContentRaw) } } diff --git a/generators/auth_jwt_mvc_route/files/src/controllers/auth.controller.ts.tmpl b/generators/auth_jwt_mvc_route/files/src/controllers/auth.controller.ts.tmpl index deae878..7738aba 100644 --- a/generators/auth_jwt_mvc_route/files/src/controllers/auth.controller.ts.tmpl +++ b/generators/auth_jwt_mvc_route/files/src/controllers/auth.controller.ts.tmpl @@ -1,3 +1,196 @@ +{{ if .HasDecorators -}} +{{ if .HasDB -}} +import type { Request, Response } from 'express'; +import bcrypt from 'bcryptjs'; +import { eq } from 'drizzle-orm'; +import { z } from '../shared/openapi/registry'; +import { + ApiResponse, + Auth, + Body, + Controller, + Get, + Post, +} from '../shared/decorators'; +import type { AuthRequest } from '../shared/middlewares/auth.middleware'; +import { db } from '../db'; +import { refreshTokens, users } from '../db/schema'; +import { signRefreshToken, signToken, verifyToken } from '../shared/services/jwt'; + +const credentials = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); +const message = z.object({ message: z.string() }); +const userPayload = z.object({ user: z.unknown() }); + +const COOKIE_OPTIONS = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, +}; + +async function issueTokens(userId: string, email: string) { + const accessToken = signToken({ id: userId, email }); + const newRefreshToken = signRefreshToken({ id: userId, email }); + const decoded = verifyToken<{ exp: number }>(newRefreshToken); + const expiresAt = new Date(decoded.exp * 1000); + await db.insert(refreshTokens).values({ token: newRefreshToken, userId, expiresAt }); + return { accessToken, refreshToken: newRefreshToken }; +} + +function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) { + res.cookie('access_token', tokens.accessToken, COOKIE_OPTIONS); + res.cookie('refresh_token', tokens.refreshToken, COOKIE_OPTIONS); +} + +@Controller({ tag: 'auth', prefix: '/auth', description: 'Authentication endpoints' }) +export class AuthController { + @Post('/register') + @Body(credentials) + @ApiResponse(201, 'User registered', message) + @ApiResponse(409, 'Email already in use') + async register(req: Request, res: Response): Promise { + const { email, password } = req.body as { email: string; password: string }; + const existing = await db.select().from(users).where(eq(users.email, email)).limit(1); + if (existing.length > 0) { + res.status(409).json({ error: 'Email already in use' }); + return; + } + const passwordHash = await bcrypt.hash(password, 10); + const result = await db.insert(users).values({ email, passwordHash }).returning(); + const newUser = result[0]; + if (!newUser) { + res.status(500).json({ error: 'Failed to create user' }); + return; + } + const tokens = await issueTokens(newUser.id, newUser.email); + setAuthCookies(res, tokens); + res.status(201).json({ message: 'Registered successfully' }); + } + + @Post('/login') + @Body(credentials) + @ApiResponse(200, 'Logged in', message) + @ApiResponse(401, 'Invalid credentials') + async login(req: Request, res: Response): Promise { + const { email, password } = req.body as { email: string; password: string }; + const result = await db.select().from(users).where(eq(users.email, email)).limit(1); + const user = result[0]; + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + const tokens = await issueTokens(user.id, user.email); + setAuthCookies(res, tokens); + res.json({ message: 'Logged in successfully' }); + } + + @Post('/refresh') + @ApiResponse(200, 'Tokens refreshed', message) + @ApiResponse(401, 'Invalid refresh token') + async refresh(req: Request, res: Response): Promise { + const refreshToken = req.cookies?.refresh_token; + if (!refreshToken) { + res.status(400).json({ error: 'Refresh token required' }); + return; + } + let payload: { id: string; email: string; exp: number } | null = null; + try { + payload = verifyToken<{ id: string; email: string; exp: number }>(refreshToken); + } catch { + res.status(401).json({ error: 'Invalid refresh token' }); + return; + } + const stored = await db + .select() + .from(refreshTokens) + .where(eq(refreshTokens.token, refreshToken)) + .limit(1); + const storedToken = stored[0]; + if (!storedToken) { + res.status(401).json({ error: 'Refresh token not found' }); + return; + } + await db.delete(refreshTokens).where(eq(refreshTokens.id, storedToken.id)); + const tokens = await issueTokens(payload.id, payload.email); + setAuthCookies(res, tokens); + res.json({ message: 'Tokens refreshed' }); + } + + @Post('/logout') + @ApiResponse(204, 'Logged out') + async logout(req: Request, res: Response): Promise { + const refreshToken = req.cookies?.refresh_token; + if (refreshToken) { + await db.delete(refreshTokens).where(eq(refreshTokens.token, refreshToken)); + } + res.clearCookie('access_token'); + res.clearCookie('refresh_token'); + res.status(204).send(); + } + + @Get('/me') + @Auth() + @ApiResponse(200, 'Current user', userPayload) + @ApiResponse(401, 'Unauthorized') + async me(req: Request, res: Response): Promise { + res.json({ user: (req as AuthRequest).user }); + } +} +{{ else -}} +import type { Request, Response } from 'express'; +import { z } from '../shared/openapi/registry'; +import { ApiResponse, Auth, Body, Controller, Get, Post } from '../shared/decorators'; +import type { AuthRequest } from '../shared/middlewares/auth.middleware'; + +const credentials = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +@Controller({ tag: 'auth', prefix: '/auth', description: 'Authentication endpoints (stub: enable a database to wire real persistence)' }) +export class AuthController { + @Post('/register') + @Body(credentials) + @ApiResponse(501, 'Not implemented') + async register(_req: Request, res: Response): Promise { + res.status(501).json({ error: 'not implemented' }); + } + + @Post('/login') + @Body(credentials) + @ApiResponse(501, 'Not implemented') + async login(_req: Request, res: Response): Promise { + res.status(501).json({ error: 'not implemented' }); + } + + @Post('/refresh') + @ApiResponse(501, 'Not implemented') + async refresh(_req: Request, res: Response): Promise { + res.status(501).json({ error: 'not implemented' }); + } + + @Post('/logout') + @ApiResponse(501, 'Not implemented') + async logout(_req: Request, res: Response): Promise { + res.status(501).json({ error: 'not implemented' }); + } + + @Get('/me') + @Auth() + @ApiResponse(200, 'Current user') + async me(req: Request, res: Response): Promise { + res.json({ user: (req as AuthRequest).user }); + } +} +{{ end -}} +{{ else -}} {{ if .HasDB -}} import type { Request, Response } from 'express'; import type { AuthRequest } from '../shared/middlewares/auth.middleware'; @@ -30,6 +223,26 @@ function setAuthCookies(res: Response, tokens: { accessToken: string; refreshTok res.cookie('refresh_token', tokens.refreshToken, COOKIE_OPTIONS); } +/** + * @openapi + * /auth/register: + * post: + * tags: [Auth] + * summary: Register a new user + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string, format: email } + * password: { type: string, minLength: 8 } + * responses: + * 201: { description: User registered, sets access_token + refresh_token cookies } + * 409: { description: Email already in use } + */ export async function register(req: Request, res: Response): Promise { const { email, password } = req.body as { email: string; password: string }; const existing = await db.select().from(users).where(eq(users.email, email)).limit(1); @@ -49,6 +262,26 @@ export async function register(req: Request, res: Response): Promise { res.status(201).json({ message: 'Registered successfully' }); } +/** + * @openapi + * /auth/login: + * post: + * tags: [Auth] + * summary: Authenticate a user + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string, format: email } + * password: { type: string } + * responses: + * 200: { description: Logged in, sets access_token + refresh_token cookies } + * 401: { description: Invalid credentials } + */ export async function login(req: Request, res: Response): Promise { const { email, password } = req.body as { email: string; password: string }; const result = await db.select().from(users).where(eq(users.email, email)).limit(1); @@ -67,6 +300,16 @@ export async function login(req: Request, res: Response): Promise { res.json({ message: 'Logged in successfully' }); } +/** + * @openapi + * /auth/refresh: + * post: + * tags: [Auth] + * summary: Rotate the access token using the refresh_token cookie + * responses: + * 200: { description: Tokens refreshed } + * 401: { description: Invalid or missing refresh token } + */ export async function refresh(req: Request, res: Response): Promise { const refreshToken = req.cookies?.refresh_token; if (!refreshToken) { @@ -101,6 +344,15 @@ export async function refresh(req: Request, res: Response): Promise { res.json({ message: 'Tokens refreshed' }); } +/** + * @openapi + * /auth/logout: + * post: + * tags: [Auth] + * summary: Revoke the current refresh token and clear cookies + * responses: + * 204: { description: Logged out } + */ export async function logout(req: Request, res: Response): Promise { const refreshToken = req.cookies?.refresh_token; if (refreshToken) { @@ -111,6 +363,18 @@ export async function logout(req: Request, res: Response): Promise { res.status(204).send(); } +/** + * @openapi + * /auth/me: + * get: + * tags: [Auth] + * summary: Return the authenticated user's identity + * security: + * - BearerAuth: [] + * responses: + * 200: { description: Current user } + * 401: { description: Unauthorized } + */ export async function me(req: AuthRequest, res: Response): Promise { res.json({ user: req.user }); } @@ -138,3 +402,4 @@ export async function me(req: AuthRequest, res: Response): Promise { res.json({ user: req.user }); } {{ end -}} +{{ end -}} diff --git a/generators/auth_jwt_mvc_route/files/src/routes/auth.route.ts b/generators/auth_jwt_mvc_route/files/src/routes/auth.route.ts.tmpl similarity index 62% rename from generators/auth_jwt_mvc_route/files/src/routes/auth.route.ts rename to generators/auth_jwt_mvc_route/files/src/routes/auth.route.ts.tmpl index 961289a..d0ee806 100644 --- a/generators/auth_jwt_mvc_route/files/src/routes/auth.route.ts +++ b/generators/auth_jwt_mvc_route/files/src/routes/auth.route.ts.tmpl @@ -1,3 +1,9 @@ +{{ if .HasDecorators -}} +// Auth routes are registered via DecoratorRouter in src/app.ts. +// Kept as an empty module so the Outputs validator stays satisfied; safe to +// delete if you remove the decorator wiring. +export {}; +{{ else -}} import { Router } from 'express'; import { login, logout, me, refresh, register } from '../controllers/auth.controller'; import { authMiddleware } from '../shared/middlewares/auth.middleware'; @@ -11,3 +17,4 @@ router.post('/refresh', refresh); router.post('/logout', logout); export default router; +{{ end -}} diff --git a/generators/auth_jwt_mvc_route/generator.go b/generators/auth_jwt_mvc_route/generator.go index 907085a..7967570 100644 --- a/generators/auth_jwt_mvc_route/generator.go +++ b/generators/auth_jwt_mvc_route/generator.go @@ -23,24 +23,43 @@ var fs embed.FS const authRouteImport = "import authRouter from './routes/auth.route';\n" const authRouteUse = "app.use('/auth', authRouter);\n" +const authControllerImport = "import { AuthController } from './controllers/auth.controller';\n" + func (g *Generator) Generate(ctx *dotapi.Context) error { hasDB := slices.Contains(ctx.PreviousGens, "drizzle_postgres_adapter") + hasDecorators := slices.Contains(ctx.PreviousGens, "express_decorators_core") renderer := render.NewLocalFolderRenderer(ctx.State) - if err := renderer.Render(fs, struct{ HasDB bool }{HasDB: hasDB}); err != nil { + if err := renderer.Render(fs, struct { + HasDB bool + HasDecorators bool + }{HasDB: hasDB, HasDecorators: hasDecorators}); err != nil { return err } - if f, ok := ctx.State.GetFile("src/app.ts"); ok { + appPath := "src/app.ts" + + if f, ok := ctx.State.GetFile(appPath); ok { content := string(f.Content) - if !strings.Contains(content, "authRouter") { + if hasDecorators { + if !strings.Contains(content, "AuthController") { + content = authControllerImport + content + content = strings.Replace( + content, + ".registerController(new ExampleController());", + ".registerController(new ExampleController())\n .registerController(new AuthController());", + 1, + ) + ctx.State.WriteFile(appPath, []byte(content), state.ContentRaw) + } + } else if !strings.Contains(content, "authRouter") { content = authRouteImport + content if strings.Contains(content, "app.use(errorMiddleware)") { content = strings.Replace(content, "app.use(errorMiddleware)", authRouteUse+"\napp.use(errorMiddleware)", 1) } else { content = strings.Replace(content, "export default app;", authRouteUse+"\nexport default app;", 1) } - ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) + ctx.State.WriteFile(appPath, []byte(content), state.ContentRaw) } } diff --git a/generators/auth_jwt_vanilla/generator.go b/generators/auth_jwt_vanilla/generator.go index a80139a..2e48625 100644 --- a/generators/auth_jwt_vanilla/generator.go +++ b/generators/auth_jwt_vanilla/generator.go @@ -3,6 +3,7 @@ package authjwtvanilla import ( "embed" "fmt" + "slices" "strings" "github.com/version14/dot/internal/render" @@ -50,14 +51,22 @@ func (g *Generator) Generate(ctx *dotapi.Context) error { updated := existing + fmt.Sprintf("\n# Auth (JWT)\nJWT_SECRET=%s\nJWT_EXPIRES_IN=7d\nJWT_REFRESH_EXPIRES_IN=30d\n", "change-me-to-a-random-secret") ctx.State.WriteFile(".env.example", []byte(updated), state.ContentRaw) - // Inject cookie-parser middleware into app.ts + // Inject cookie-parser middleware into app.ts and, if decorators are + // active, plug the JWT auth middleware into ExpressRouterAdapter so that + // every @Auth()-decorated route gets gated automatically. + hasDecorators := slices.Contains(ctx.PreviousGens, "express_decorators_core") + if f, ok := ctx.State.GetFile("src/app.ts"); ok { content := string(f.Content) if !strings.Contains(content, "cookieParser") { content = "import cookieParser from 'cookie-parser';\n" + content content = strings.Replace(content, "app.use(express.json());", "app.use(express.json());\napp.use(cookieParser());", 1) - ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) } + if hasDecorators && !strings.Contains(content, "authMiddleware") { + content = "import { authMiddleware } from './shared/middlewares/auth.middleware';\n" + content + content = strings.Replace(content, "new ExpressRouterAdapter()", "new ExpressRouterAdapter({ authMiddleware })", 1) + } + ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) } return nil diff --git a/generators/biome_config/manifest.go b/generators/biome_config/manifest.go index d3ad303..25548c1 100644 --- a/generators/biome_config/manifest.go +++ b/generators/biome_config/manifest.go @@ -18,7 +18,7 @@ var Manifest = dotapi.Manifest{ {Cmd: "pnpm exec biome check --write ."}, }, TestCommands: []dotapi.Command{ - {Cmd: "pnpm exec biome check ."}, + {Cmd: "pnpm exec biome check --write ."}, }, Validators: []dotapi.Validator{ { diff --git a/generators/decorators_clean_arch_adapter/files/src/__tests__/decorators-clean.e2e.test.ts b/generators/decorators_clean_arch_adapter/files/src/__tests__/decorators-clean.e2e.test.ts new file mode 100644 index 0000000..a9675fa --- /dev/null +++ b/generators/decorators_clean_arch_adapter/files/src/__tests__/decorators-clean.e2e.test.ts @@ -0,0 +1,49 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import app from '../app'; + +describe('decorator-driven routes (clean architecture, E2E)', () => { + it('GET /health returns ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('GET /api/example/:id returns 400 when id is not a UUID', async () => { + const res = await request(app).get('/api/example/not-a-uuid'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('ValidationError'); + expect(res.body.target).toBe('params'); + }); + + it('GET /api/example/:id returns 200 with the sample payload when the UUID is valid', async () => { + const res = await request(app).get('/api/example/11111111-1111-1111-1111-111111111111'); + expect(res.status).toBe(200); + // Sample controller does not echo the input (see controller comment). + // It returns the canned synthetic example so the response shape matches + // the OpenAPI schema regardless of the URL parameter. + expect(typeof res.body.id).toBe('string'); + expect(res.body.name).toBe('sample'); + }); + + it('POST /api/example returns 400 when body is invalid', async () => { + const res = await request(app).post('/api/example').send({}); + expect(res.status).toBe(400); + expect(res.body.target).toBe('body'); + }); + + it('POST /api/example returns 201 with a synthetic payload when body is valid', async () => { + const res = await request(app).post('/api/example').send({ name: 'demo' }); + expect(res.status).toBe(201); + expect(typeof res.body.id).toBe('string'); + expect(res.body.name).toBe('created'); + }); + + it('GET /docs/openapi.json exposes a valid OpenAPI document with /api/example paths', async () => { + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.0.0'); + expect(res.body.paths['/api/example']).toBeDefined(); + expect(res.body.paths['/api/example/{id}']).toBeDefined(); + }); +}); diff --git a/generators/decorators_clean_arch_adapter/files/src/app.ts b/generators/decorators_clean_arch_adapter/files/src/app.ts new file mode 100644 index 0000000..399b1c2 --- /dev/null +++ b/generators/decorators_clean_arch_adapter/files/src/app.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import express from 'express'; +import cors from 'cors'; +import { + DecoratorRouter, + ExpressRouterAdapter, +} from './shared/decorators'; +import { buildOpenApiSpec, createRegistry, mountSwagger } from './shared/openapi'; +import { corsOptions } from './shared/cors'; +import { ExampleController } from './modules/example/application/controllers/example.controller'; + +const app = express(); + +app.use(cors(corsOptions())); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +const decoratorRouter = new DecoratorRouter(new ExpressRouterAdapter()) + .registerController(new ExampleController()); + +app.use(decoratorRouter.build()); + +const spec = buildOpenApiSpec({ + info: { title: 'API', version: '1.0.0' }, + servers: [{ url: '/' }], + routes: decoratorRouter.routes(), + registry: createRegistry(), +}); +mountSwagger(app, spec); + +export default app; diff --git a/generators/decorators_clean_arch_adapter/files/src/modules/example/application/controllers/example.controller.ts b/generators/decorators_clean_arch_adapter/files/src/modules/example/application/controllers/example.controller.ts new file mode 100644 index 0000000..b847645 --- /dev/null +++ b/generators/decorators_clean_arch_adapter/files/src/modules/example/application/controllers/example.controller.ts @@ -0,0 +1,57 @@ +import type { Request, Response } from 'express'; +import { + ApiResponse, + Body, + Controller, + Get, + Params, + Post, +} from '../../../../shared/decorators'; +import { + exampleCreateSchema, + exampleParamsSchema, + exampleResponseSchema, + type ExampleCreate, + type ExampleParams, +} from '../validators/example.schemas'; + +/** + * Example controller — kept minimal on purpose. Replace this with your real + * use cases (typically injected through the constructor) and remove this file + * once you no longer need the reference. + * + * Design note: the synthetic response payloads below intentionally do **not** + * mirror the request input. Wire the validated DTOs (`params`, `body`) to your + * repositories / use cases and return the persisted entity instead. + */ +@Controller({ tag: 'example', prefix: '/api/example', description: 'Sample resource demonstrating decorator usage' }) +export class ExampleController { + @Get(':id') + @Params(exampleParamsSchema) + @ApiResponse(200, 'Example fetched', exampleResponseSchema) + @ApiResponse(404, 'Example not found') + get(req: Request, res: Response): void { + // params is validated by @Params and typed as ExampleParams. + // Use it to call your repository: `repo.findById(params.id)`. + const params = req.params as unknown as ExampleParams; + res.json({ + id: '11111111-1111-1111-1111-111111111111', + name: 'sample', + description: JSON.stringify(params), + }); + } + + @Post('/') + @Body(exampleCreateSchema) + @ApiResponse(201, 'Example created', exampleResponseSchema) + create(req: Request, res: Response): void { + // body is validated by @Body and typed as ExampleCreate. + // Forward it to a CreateExampleUseCase and return the persisted entity. + const body = req.body as ExampleCreate; + res.status(201).json({ + id: '00000000-0000-0000-0000-000000000000', + name: 'created', + description: JSON.stringify(body), + }); + } +} diff --git a/generators/decorators_clean_arch_adapter/files/src/modules/example/application/validators/example.schemas.ts b/generators/decorators_clean_arch_adapter/files/src/modules/example/application/validators/example.schemas.ts new file mode 100644 index 0000000..9dbf762 --- /dev/null +++ b/generators/decorators_clean_arch_adapter/files/src/modules/example/application/validators/example.schemas.ts @@ -0,0 +1,20 @@ +import { z } from '../../../../shared/openapi/registry'; + +export const exampleParamsSchema = z.object({ + id: z.string().uuid(), +}); + +export const exampleCreateSchema = z.object({ + name: z.string().min(1).max(120), + description: z.string().max(500).optional(), +}); + +export const exampleResponseSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), +}); + +export type ExampleParams = z.infer; +export type ExampleCreate = z.infer; +export type ExampleResponse = z.infer; diff --git a/generators/decorators_clean_arch_adapter/generator.go b/generators/decorators_clean_arch_adapter/generator.go new file mode 100644 index 0000000..ae27e6a --- /dev/null +++ b/generators/decorators_clean_arch_adapter/generator.go @@ -0,0 +1,22 @@ +package decoratorscleanarchadapter + +import ( + "embed" + + "github.com/version14/dot/internal/render" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +//go:embed all:files +var fs embed.FS + +func (g *Generator) Generate(ctx *dotapi.Context) error { + return render.NewLocalFolderRenderer(ctx.State).Render(fs, nil) +} diff --git a/generators/decorators_clean_arch_adapter/manifest.go b/generators/decorators_clean_arch_adapter/manifest.go new file mode 100644 index 0000000..4e3c218 --- /dev/null +++ b/generators/decorators_clean_arch_adapter/manifest.go @@ -0,0 +1,30 @@ +package decoratorscleanarchadapter + +import "github.com/version14/dot/pkg/dotapi" + +var Manifest = dotapi.Manifest{ + Name: "decorators_clean_arch_adapter", + Version: "0.1.0", + Description: "Wires the decorator router into a Clean Architecture project: sample controller in application layer, schemas, OpenAPI mount in app.ts", + DependsOn: []string{ + "backend_architecture_clean_architecture", + "express_decorators_core", + "express_openapi_setup", + }, + Outputs: []string{ + "src/app.ts", + "src/modules/example/application/controllers/example.controller.ts", + "src/modules/example/application/validators/example.schemas.ts", + "src/__tests__/decorators-clean.e2e.test.ts", + }, + Validators: []dotapi.Validator{ + { + Name: "decorators-clean-arch-adapter", + Checks: []dotapi.Check{ + {Type: dotapi.CheckFileExists, Path: "src/modules/example/application/controllers/example.controller.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/modules/example/application/validators/example.schemas.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/__tests__/decorators-clean.e2e.test.ts"}, + }, + }, + }, +} diff --git a/generators/decorators_hexagonal_adapter/files/src/__tests__/decorators-hexagonal.e2e.test.ts b/generators/decorators_hexagonal_adapter/files/src/__tests__/decorators-hexagonal.e2e.test.ts new file mode 100644 index 0000000..eab98d4 --- /dev/null +++ b/generators/decorators_hexagonal_adapter/files/src/__tests__/decorators-hexagonal.e2e.test.ts @@ -0,0 +1,37 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import app from '../app'; + +describe('decorator-driven routes (hexagonal, E2E)', () => { + it('GET /health returns ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('GET /api/example/:id returns 400 when id is not a UUID', async () => { + const res = await request(app).get('/api/example/not-a-uuid'); + expect(res.status).toBe(400); + }); + + it('GET /api/example/:id returns 200 with the sample payload when the UUID is valid', async () => { + const res = await request(app).get('/api/example/33333333-3333-3333-3333-333333333333'); + expect(res.status).toBe(200); + // Sample controller does not echo the input (see controller comment). + expect(typeof res.body.id).toBe('string'); + expect(res.body.name).toBe('sample'); + }); + + it('POST /api/example returns 201 with a synthetic payload when body is valid', async () => { + const res = await request(app).post('/api/example').send({ name: 'hex-demo' }); + expect(res.status).toBe(201); + expect(typeof res.body.id).toBe('string'); + expect(res.body.name).toBe('created'); + }); + + it('GET /docs/openapi.json exposes a valid OpenAPI document', async () => { + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.paths['/api/example']).toBeDefined(); + }); +}); diff --git a/generators/decorators_hexagonal_adapter/files/src/adapters/primary/http/controllers/example.controller.ts b/generators/decorators_hexagonal_adapter/files/src/adapters/primary/http/controllers/example.controller.ts new file mode 100644 index 0000000..bddb740 --- /dev/null +++ b/generators/decorators_hexagonal_adapter/files/src/adapters/primary/http/controllers/example.controller.ts @@ -0,0 +1,55 @@ +import type { Request, Response } from 'express'; +import { + ApiResponse, + Body, + Controller, + Get, + Params, + Post, +} from '../../../../shared/decorators'; +import { + exampleCreateSchema, + exampleParamsSchema, + exampleResponseSchema, + type ExampleCreate, + type ExampleParams, +} from '../schemas/example.schemas'; + +/** + * Primary HTTP adapter for the Example domain. Real implementations would + * inject inbound ports (use case interfaces from core/application/ports/in) + * through the constructor. + * + * Design note: the synthetic response payloads below intentionally do **not** + * mirror the request input. Forward the validated DTOs (`params`, `body`) to + * your inbound ports and return the persisted entity instead. + */ +@Controller({ tag: 'example', prefix: '/api/example', description: 'Sample HTTP adapter wired with decorators' }) +export class ExampleController { + @Get(':id') + @Params(exampleParamsSchema) + @ApiResponse(200, 'Example fetched', exampleResponseSchema) + @ApiResponse(404, 'Example not found') + get(req: Request, res: Response): void { + // params is validated by @Params and typed as ExampleParams. + const params = req.params as unknown as ExampleParams; + res.json({ + id: '33333333-3333-3333-3333-333333333333', + name: 'sample', + description: JSON.stringify(params), + }); + } + + @Post('/') + @Body(exampleCreateSchema) + @ApiResponse(201, 'Example created', exampleResponseSchema) + create(req: Request, res: Response): void { + // body is validated by @Body and typed as ExampleCreate. + const body = req.body as ExampleCreate; + res.status(201).json({ + id: '00000000-0000-0000-0000-000000000000', + name: 'created', + description: JSON.stringify(body), + }); + } +} diff --git a/generators/decorators_hexagonal_adapter/files/src/adapters/primary/http/schemas/example.schemas.ts b/generators/decorators_hexagonal_adapter/files/src/adapters/primary/http/schemas/example.schemas.ts new file mode 100644 index 0000000..9dbf762 --- /dev/null +++ b/generators/decorators_hexagonal_adapter/files/src/adapters/primary/http/schemas/example.schemas.ts @@ -0,0 +1,20 @@ +import { z } from '../../../../shared/openapi/registry'; + +export const exampleParamsSchema = z.object({ + id: z.string().uuid(), +}); + +export const exampleCreateSchema = z.object({ + name: z.string().min(1).max(120), + description: z.string().max(500).optional(), +}); + +export const exampleResponseSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), +}); + +export type ExampleParams = z.infer; +export type ExampleCreate = z.infer; +export type ExampleResponse = z.infer; diff --git a/generators/decorators_hexagonal_adapter/files/src/app.ts b/generators/decorators_hexagonal_adapter/files/src/app.ts new file mode 100644 index 0000000..1a37598 --- /dev/null +++ b/generators/decorators_hexagonal_adapter/files/src/app.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import express from 'express'; +import cors from 'cors'; +import { + DecoratorRouter, + ExpressRouterAdapter, +} from './shared/decorators'; +import { buildOpenApiSpec, createRegistry, mountSwagger } from './shared/openapi'; +import { corsOptions } from './shared/cors'; +import { ExampleController } from './adapters/primary/http/controllers/example.controller'; + +const app = express(); + +app.use(cors(corsOptions())); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +const decoratorRouter = new DecoratorRouter(new ExpressRouterAdapter()) + .registerController(new ExampleController()); + +app.use(decoratorRouter.build()); + +const spec = buildOpenApiSpec({ + info: { title: 'API', version: '1.0.0' }, + servers: [{ url: '/' }], + routes: decoratorRouter.routes(), + registry: createRegistry(), +}); +mountSwagger(app, spec); + +export default app; diff --git a/generators/decorators_hexagonal_adapter/generator.go b/generators/decorators_hexagonal_adapter/generator.go new file mode 100644 index 0000000..bf949da --- /dev/null +++ b/generators/decorators_hexagonal_adapter/generator.go @@ -0,0 +1,22 @@ +package decoratorshexagonaladapter + +import ( + "embed" + + "github.com/version14/dot/internal/render" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +//go:embed all:files +var fs embed.FS + +func (g *Generator) Generate(ctx *dotapi.Context) error { + return render.NewLocalFolderRenderer(ctx.State).Render(fs, nil) +} diff --git a/generators/decorators_hexagonal_adapter/manifest.go b/generators/decorators_hexagonal_adapter/manifest.go new file mode 100644 index 0000000..f74e822 --- /dev/null +++ b/generators/decorators_hexagonal_adapter/manifest.go @@ -0,0 +1,30 @@ +package decoratorshexagonaladapter + +import "github.com/version14/dot/pkg/dotapi" + +var Manifest = dotapi.Manifest{ + Name: "decorators_hexagonal_adapter", + Version: "0.1.0", + Description: "Wires the decorator router into a Hexagonal project: sample primary HTTP adapter controller, schemas, OpenAPI mount in app.ts", + DependsOn: []string{ + "backend_architecture_hexagonal", + "express_decorators_core", + "express_openapi_setup", + }, + Outputs: []string{ + "src/app.ts", + "src/adapters/primary/http/controllers/example.controller.ts", + "src/adapters/primary/http/schemas/example.schemas.ts", + "src/__tests__/decorators-hexagonal.e2e.test.ts", + }, + Validators: []dotapi.Validator{ + { + Name: "decorators-hexagonal-adapter", + Checks: []dotapi.Check{ + {Type: dotapi.CheckFileExists, Path: "src/adapters/primary/http/controllers/example.controller.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/adapters/primary/http/schemas/example.schemas.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/__tests__/decorators-hexagonal.e2e.test.ts"}, + }, + }, + }, +} diff --git a/generators/decorators_mvc_adapter/files/src/__tests__/decorators-mvc.e2e.test.ts b/generators/decorators_mvc_adapter/files/src/__tests__/decorators-mvc.e2e.test.ts new file mode 100644 index 0000000..b589b0f --- /dev/null +++ b/generators/decorators_mvc_adapter/files/src/__tests__/decorators-mvc.e2e.test.ts @@ -0,0 +1,45 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import app from '../app'; + +describe('decorator-driven routes (MVC, E2E)', () => { + it('GET /health returns ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('GET /api/example/:id returns 400 when id is not a UUID', async () => { + const res = await request(app).get('/api/example/not-a-uuid'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('ValidationError'); + }); + + it('GET /api/example/:id returns 200 with the sample payload when the UUID is valid', async () => { + const res = await request(app).get('/api/example/22222222-2222-2222-2222-222222222222'); + expect(res.status).toBe(200); + // Sample controller does not echo the input (see controller comment). + expect(typeof res.body.id).toBe('string'); + expect(res.body.name).toBe('sample'); + }); + + it('POST /api/example returns 400 when body is invalid', async () => { + const res = await request(app).post('/api/example').send({}); + expect(res.status).toBe(400); + expect(res.body.target).toBe('body'); + }); + + it('POST /api/example returns 201 with a synthetic payload when body is valid', async () => { + const res = await request(app).post('/api/example').send({ name: 'mvc-demo' }); + expect(res.status).toBe(201); + expect(typeof res.body.id).toBe('string'); + expect(res.body.name).toBe('created'); + }); + + it('GET /docs/openapi.json exposes a valid OpenAPI document', async () => { + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.0.0'); + expect(res.body.paths['/api/example']).toBeDefined(); + }); +}); diff --git a/generators/decorators_mvc_adapter/files/src/app.ts b/generators/decorators_mvc_adapter/files/src/app.ts new file mode 100644 index 0000000..1b05e40 --- /dev/null +++ b/generators/decorators_mvc_adapter/files/src/app.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import express from 'express'; +import cors from 'cors'; +import { + DecoratorRouter, + ExpressRouterAdapter, +} from './shared/decorators'; +import { buildOpenApiSpec, createRegistry, mountSwagger } from './shared/openapi'; +import { corsOptions } from './shared/cors'; +import { ExampleController } from './controllers/example.controller'; + +const app = express(); + +app.use(cors(corsOptions())); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +const decoratorRouter = new DecoratorRouter(new ExpressRouterAdapter()) + .registerController(new ExampleController()); + +app.use(decoratorRouter.build()); + +const spec = buildOpenApiSpec({ + info: { title: 'API', version: '1.0.0' }, + servers: [{ url: '/' }], + routes: decoratorRouter.routes(), + registry: createRegistry(), +}); +mountSwagger(app, spec); + +export default app; diff --git a/generators/decorators_mvc_adapter/files/src/controllers/example.controller.ts b/generators/decorators_mvc_adapter/files/src/controllers/example.controller.ts new file mode 100644 index 0000000..85c23d2 --- /dev/null +++ b/generators/decorators_mvc_adapter/files/src/controllers/example.controller.ts @@ -0,0 +1,55 @@ +import type { Request, Response } from 'express'; +import { + ApiResponse, + Body, + Controller, + Get, + Params, + Post, +} from '../shared/decorators'; +import { + exampleCreateSchema, + exampleParamsSchema, + exampleResponseSchema, + type ExampleCreate, + type ExampleParams, +} from '../shared/validators/example.schemas'; + +/** + * Example controller — kept minimal on purpose. Replace this with real + * controller logic and remove this file when you no longer need the + * reference. + * + * Design note: the synthetic response payloads below intentionally do **not** + * mirror the request input. Wire the validated DTOs (`params`, `body`) to + * your model layer and return the persisted entity instead. + */ +@Controller({ tag: 'example', prefix: '/api/example', description: 'Sample resource demonstrating decorator usage' }) +export class ExampleController { + @Get(':id') + @Params(exampleParamsSchema) + @ApiResponse(200, 'Example fetched', exampleResponseSchema) + @ApiResponse(404, 'Example not found') + get(req: Request, res: Response): void { + // params is validated by @Params and typed as ExampleParams. + const params = req.params as unknown as ExampleParams; + res.json({ + id: '22222222-2222-2222-2222-222222222222', + name: 'sample', + description: JSON.stringify(params), + }); + } + + @Post('/') + @Body(exampleCreateSchema) + @ApiResponse(201, 'Example created', exampleResponseSchema) + create(req: Request, res: Response): void { + // body is validated by @Body and typed as ExampleCreate. + const body = req.body as ExampleCreate; + res.status(201).json({ + id: '00000000-0000-0000-0000-000000000000', + name: 'created', + description: JSON.stringify(body), + }); + } +} diff --git a/generators/decorators_mvc_adapter/files/src/shared/validators/example.schemas.ts b/generators/decorators_mvc_adapter/files/src/shared/validators/example.schemas.ts new file mode 100644 index 0000000..249bf93 --- /dev/null +++ b/generators/decorators_mvc_adapter/files/src/shared/validators/example.schemas.ts @@ -0,0 +1,20 @@ +import { z } from '../openapi/registry'; + +export const exampleParamsSchema = z.object({ + id: z.string().uuid(), +}); + +export const exampleCreateSchema = z.object({ + name: z.string().min(1).max(120), + description: z.string().max(500).optional(), +}); + +export const exampleResponseSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), +}); + +export type ExampleParams = z.infer; +export type ExampleCreate = z.infer; +export type ExampleResponse = z.infer; diff --git a/generators/decorators_mvc_adapter/generator.go b/generators/decorators_mvc_adapter/generator.go new file mode 100644 index 0000000..61c30f3 --- /dev/null +++ b/generators/decorators_mvc_adapter/generator.go @@ -0,0 +1,22 @@ +package decoratorsmvcadapter + +import ( + "embed" + + "github.com/version14/dot/internal/render" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +//go:embed all:files +var fs embed.FS + +func (g *Generator) Generate(ctx *dotapi.Context) error { + return render.NewLocalFolderRenderer(ctx.State).Render(fs, nil) +} diff --git a/generators/decorators_mvc_adapter/manifest.go b/generators/decorators_mvc_adapter/manifest.go new file mode 100644 index 0000000..941fc26 --- /dev/null +++ b/generators/decorators_mvc_adapter/manifest.go @@ -0,0 +1,30 @@ +package decoratorsmvcadapter + +import "github.com/version14/dot/pkg/dotapi" + +var Manifest = dotapi.Manifest{ + Name: "decorators_mvc_adapter", + Version: "0.1.0", + Description: "Wires the decorator router into an MVC project: sample controller in src/controllers, schemas in src/shared/validators, OpenAPI mount in app.ts", + DependsOn: []string{ + "backend_architecture_mvc", + "express_decorators_core", + "express_openapi_setup", + }, + Outputs: []string{ + "src/app.ts", + "src/controllers/example.controller.ts", + "src/shared/validators/example.schemas.ts", + "src/__tests__/decorators-mvc.e2e.test.ts", + }, + Validators: []dotapi.Validator{ + { + Name: "decorators-mvc-adapter", + Checks: []dotapi.Check{ + {Type: dotapi.CheckFileExists, Path: "src/controllers/example.controller.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/validators/example.schemas.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/__tests__/decorators-mvc.e2e.test.ts"}, + }, + }, + }, +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/__tests__/decorators.unit.test.ts b/generators/express_decorators_core/files/src/shared/decorators/__tests__/decorators.unit.test.ts new file mode 100644 index 0000000..505be9e --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/__tests__/decorators.unit.test.ts @@ -0,0 +1,209 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + ApiResponse, + Auth, + Body, + Controller, + DecoratorRouter, + ExpressRouterAdapter, + Get, + Params, + Post, + Query, + RequiredHeaders, +} from '..'; + +function buildApp(controller: object, options: { authMiddleware?: express.RequestHandler } = {}) { + const adapter = new ExpressRouterAdapter(options); + const router = new DecoratorRouter(adapter).registerController(controller).build(); + const app = express(); + app.use(express.json()); + app.use(router); + return { app, adapter }; +} + +describe('decorators (unit)', () => { + it('registers a GET route declared via @Get', async () => { + @Controller({ tag: 'health', prefix: '/health' }) + class HealthController { + @Get('/') + @ApiResponse(200, 'ok') + ping(_req: express.Request, res: express.Response) { + res.json({ status: 'ok' }); + } + } + + const { app } = buildApp(new HealthController()); + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + it('joins controller prefix and route path correctly', async () => { + @Controller({ tag: 'users', prefix: '/users' }) + class UsersController { + @Get(':id') + get(req: express.Request, res: express.Response) { + res.json({ id: req.params.id }); + } + } + + const { app } = buildApp(new UsersController()); + const res = await request(app).get('/users/42'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: '42' }); + }); + + it('returns 400 with structured issues when @Body validation fails', async () => { + const schema = z.object({ email: z.string().email(), age: z.number().int().min(0) }); + + @Controller({ tag: 'signup', prefix: '/signup' }) + class SignupController { + @Post('/') + @Body(schema) + handle(_req: express.Request, res: express.Response) { + res.status(201).json({ created: true }); + } + } + + const { app } = buildApp(new SignupController()); + const res = await request(app).post('/signup').send({ email: 'nope', age: -1 }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('ValidationError'); + expect(res.body.target).toBe('body'); + expect(res.body.issues.length).toBeGreaterThan(0); + }); + + it('passes through with parsed body when @Body validation succeeds', async () => { + const schema = z.object({ name: z.string() }); + + @Controller({ tag: 'echo', prefix: '/echo' }) + class EchoController { + @Post('/') + @Body(schema) + handle(req: express.Request, res: express.Response) { + res.json(req.body); + } + } + + const { app } = buildApp(new EchoController()); + const res = await request(app).post('/echo').send({ name: 'mathieu' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ name: 'mathieu' }); + }); + + it('coerces query params via @Query schema', async () => { + const schema = z.object({ page: z.coerce.number().int().min(1) }); + + @Controller({ tag: 'list', prefix: '/list' }) + class ListController { + @Get('/') + @Query(schema) + handle(req: express.Request, res: express.Response) { + res.json(req.query); + } + } + + const { app } = buildApp(new ListController()); + const res = await request(app).get('/list?page=3'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ page: 3 }); + }); + + it('rejects invalid params via @Params schema', async () => { + const schema = z.object({ id: z.string().uuid() }); + + @Controller({ tag: 'items', prefix: '/items' }) + class ItemsController { + @Get(':id') + @Params(schema) + handle(_req: express.Request, res: express.Response) { + res.json({ ok: true }); + } + } + + const { app } = buildApp(new ItemsController()); + const res = await request(app).get('/items/not-a-uuid'); + expect(res.status).toBe(400); + expect(res.body.target).toBe('params'); + }); + + it('blocks @Auth-protected route with 401 when no auth middleware accepts the request', async () => { + const denyAll: express.RequestHandler = (_req, res) => { + res.status(401).json({ error: 'Unauthorized' }); + }; + + @Controller({ tag: 'private', prefix: '/private' }) + class PrivateController { + @Get('/') + @Auth() + handle(_req: express.Request, res: express.Response) { + res.json({ ok: true }); + } + } + + const { app } = buildApp(new PrivateController(), { authMiddleware: denyAll }); + const res = await request(app).get('/private'); + expect(res.status).toBe(401); + }); + + it('lets @Auth-protected route through when auth middleware accepts the request', async () => { + const acceptAll: express.RequestHandler = (_req, _res, next) => next(); + + @Controller({ tag: 'private', prefix: '/private' }) + class PrivateController { + @Get('/') + @Auth() + handle(_req: express.Request, res: express.Response) { + res.json({ ok: true }); + } + } + + const { app } = buildApp(new PrivateController(), { authMiddleware: acceptAll }); + const res = await request(app).get('/private'); + expect(res.status).toBe(200); + }); + + it('returns 400 when a @RequiredHeaders header is missing', async () => { + @Controller({ tag: 'gated', prefix: '/gated' }) + class GatedController { + @Get('/') + @RequiredHeaders(['x-tenant']) + handle(_req: express.Request, res: express.Response) { + res.json({ ok: true }); + } + } + + const { app } = buildApp(new GatedController()); + const res = await request(app).get('/gated'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('MissingHeaders'); + }); + + it('exposes registered routes for downstream consumers (e.g. OpenAPI)', () => { + const schema = z.object({ name: z.string() }); + + @Controller({ tag: 'meta', prefix: '/meta' }) + class MetaController { + @Post('/') + @Body(schema) + @ApiResponse(201, 'created', schema) + handle(_req: express.Request, res: express.Response) { + res.status(201).json({}); + } + } + + const adapter = new ExpressRouterAdapter(); + const router = new DecoratorRouter(adapter).registerController(new MetaController()); + const registered = router.routes(); + + expect(registered).toHaveLength(1); + expect(registered[0]?.fullPath).toBe('/meta'); + expect(registered[0]?.route.method).toBe('post'); + expect(registered[0]?.validation.body).toBe(schema); + expect(registered[0]?.responses[0]?.status).toBe(201); + }); +}); diff --git a/generators/express_decorators_core/files/src/shared/decorators/auth.decorator.ts b/generators/express_decorators_core/files/src/shared/decorators/auth.decorator.ts new file mode 100644 index 0000000..c37f48c --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/auth.decorator.ts @@ -0,0 +1,12 @@ +import { setProtected } from './metadata'; + +/** + * Marks a route as protected. The router adapter is responsible for installing + * the auth middleware and OpenAPI security scheme. Without an auth middleware + * registered on the adapter, the decorator only affects the OpenAPI spec. + */ +export function Auth(): MethodDecorator { + return (target, propertyKey) => { + setProtected(target, propertyKey as string); + }; +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/controller.decorator.ts b/generators/express_decorators_core/files/src/shared/decorators/controller.decorator.ts new file mode 100644 index 0000000..35561ed --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/controller.decorator.ts @@ -0,0 +1,22 @@ +import { setController, type ControllerMetadata } from './metadata'; + +export interface ControllerOptions { + tag: string; + prefix?: string; + description?: string; +} + +/** + * Marks a class as an HTTP controller. The `tag` is used as the OpenAPI group; + * the `prefix` is prepended to every route path declared on the class. + */ +export function Controller(options: ControllerOptions): ClassDecorator { + return (target) => { + const meta: ControllerMetadata = { + tag: options.tag, + prefix: options.prefix ?? '', + description: options.description, + }; + setController(target.prototype, meta); + }; +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/decorator-router.ts b/generators/express_decorators_core/files/src/shared/decorators/decorator-router.ts new file mode 100644 index 0000000..a3ec11e --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/decorator-router.ts @@ -0,0 +1,105 @@ +import { + getController, + getRequiredHeaders, + getResponses, + getRoutes, + getValidation, + isProtected, + type ControllerMetadata, + type ResponseMetadata, + type RouteMetadata, + type ValidationMetadata, +} from './metadata'; +import type { RouteRegistration, RouterAdapter } from './router-adapter'; + +export interface RegisteredRoute { + controller: ControllerMetadata; + route: RouteMetadata; + validation: ValidationMetadata; + responses: ResponseMetadata[]; + protected: boolean; + fullPath: string; +} + +function joinPath(prefix: string, path: string): string { + const left = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + // A bare '/' or '' path means "the prefix itself" — emit just the prefix + // (no trailing slash) so OpenAPI path keys are stable. + if (path === '' || path === '/') return left || '/'; + const right = path.startsWith('/') ? path : `/${path}`; + return `${left}${right}`; +} + +/** + * DecoratorRouter walks a controller instance, reads its decorator metadata, + * registers each route on a RouterAdapter, and exposes the registered routes + * for downstream consumers (notably the OpenAPI spec generator). + */ +export class DecoratorRouter { + private readonly registered: RegisteredRoute[] = []; + + constructor(private readonly adapter: RouterAdapter) {} + + /** + * Bind every decorated route on `instance` to the underlying adapter. + * Returns this for fluent chaining. + */ + registerController(instance: object): this { + const proto = Object.getPrototypeOf(instance) as object; + const controller = getController(proto); + if (!controller) { + throw new Error('DecoratorRouter: target is missing the @Controller decorator'); + } + + const routes = getRoutes(proto); + for (const route of routes) { + const handler = (instance as Record)[route.handlerName]; + if (typeof handler !== 'function') { + throw new TypeError(`DecoratorRouter: handler "${route.handlerName}" is not callable`); + } + + const validation = getValidation(proto, route.handlerName); + const responses = getResponses(proto, route.handlerName); + const protectedRoute = isProtected(proto, route.handlerName); + const requiredHeaders = getRequiredHeaders(proto, route.handlerName); + const fullPath = joinPath(controller.prefix, route.path); + + const registration: RouteRegistration = { + method: route.method, + path: fullPath, + handler: (handler as (...args: unknown[]) => unknown).bind(instance), + validation, + requiredHeaders, + isProtected: protectedRoute, + }; + + this.adapter.register(registration); + + this.registered.push({ + controller, + route, + validation, + responses, + protected: protectedRoute, + fullPath, + }); + } + + return this; + } + + /** + * The native router (Express Router, Fastify instance, ...). + */ + build(): NativeRouter { + return this.adapter.build(); + } + + /** + * Snapshot of every route the router has bound. Consumed by the OpenAPI + * generator to emit a spec that exactly matches what is wired. + */ + routes(): readonly RegisteredRoute[] { + return this.registered; + } +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/express-router-adapter.ts b/generators/express_decorators_core/files/src/shared/decorators/express-router-adapter.ts new file mode 100644 index 0000000..c8884fb --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/express-router-adapter.ts @@ -0,0 +1,84 @@ +import { type RequestHandler, Router } from 'express'; +import { validateRequest } from '../middlewares/validate-request'; +import type { RouteRegistration, RouterAdapter } from './router-adapter'; + +export interface ExpressAdapterOptions { + authMiddleware?: RequestHandler; +} + +function requiredHeadersMiddleware(headers: string[]): RequestHandler { + return (req, res, next) => { + const missing = headers.filter((h) => !req.headers[h.toLowerCase()]); + if (missing.length > 0) { + res.status(400).json({ error: 'MissingHeaders', headers: missing }); + return; + } + next(); + }; +} + +/** + * Wrap a possibly-async handler so any thrown error or rejected promise is + * forwarded to Express' error pipeline via next(err) instead of becoming an + * unhandled rejection. Equivalent to the express-async-errors pattern but + * scoped to decorator-registered routes. + */ +function asyncSafe(handler: RouteRegistration['handler']): RequestHandler { + return (req, res, next) => { + try { + const result = (handler as (...args: unknown[]) => unknown)(req, res, next); + if (result instanceof Promise) { + result.catch(next); + } + } catch (err) { + next(err); + } + }; +} + +/** + * Express implementation of RouterAdapter. Translates each RouteRegistration + * into an ordered middleware chain: + * + * [requiredHeaders?] → [auth?] → [validate(params)?] → [validate(query)?] → + * [validate(body)?] → handler + */ +export class ExpressRouterAdapter implements RouterAdapter { + private readonly router: Router; + private readonly authMiddleware?: RequestHandler; + + constructor(options: ExpressAdapterOptions = {}) { + this.router = Router(); + this.authMiddleware = options.authMiddleware; + } + + register(registration: RouteRegistration): void { + const middlewares: RequestHandler[] = []; + + if (registration.requiredHeaders.length > 0) { + middlewares.push(requiredHeadersMiddleware(registration.requiredHeaders)); + } + + if (registration.isProtected && this.authMiddleware) { + middlewares.push(this.authMiddleware); + } + + if (registration.validation.params) { + middlewares.push(validateRequest(registration.validation.params, 'params')); + } + if (registration.validation.query) { + middlewares.push(validateRequest(registration.validation.query, 'query')); + } + if (registration.validation.body) { + middlewares.push(validateRequest(registration.validation.body, 'body')); + } + + middlewares.push(asyncSafe(registration.handler)); + + this.router[registration.method](registration.path, ...middlewares); + } + + build(): Router { + return this.router; + } +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/header.decorator.ts b/generators/express_decorators_core/files/src/shared/decorators/header.decorator.ts new file mode 100644 index 0000000..9e20385 --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/header.decorator.ts @@ -0,0 +1,11 @@ +import { setRequiredHeaders } from './metadata'; + +/** + * Mark headers required for the route. The router adapter installs a + * pre-handler middleware that returns 400 if any are missing. + */ +export function RequiredHeaders(headers: string[]): MethodDecorator { + return (target, propertyKey) => { + setRequiredHeaders(target, propertyKey as string, headers); + }; +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/index.ts b/generators/express_decorators_core/files/src/shared/decorators/index.ts new file mode 100644 index 0000000..bed043f --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/index.ts @@ -0,0 +1,10 @@ +export * from './metadata'; +export * from './controller.decorator'; +export * from './route.decorators'; +export * from './validation.decorators'; +export * from './response.decorator'; +export * from './auth.decorator'; +export * from './header.decorator'; +export * from './router-adapter'; +export * from './express-router-adapter'; +export * from './decorator-router'; diff --git a/generators/express_decorators_core/files/src/shared/decorators/metadata.ts b/generators/express_decorators_core/files/src/shared/decorators/metadata.ts new file mode 100644 index 0000000..36a97bd --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/metadata.ts @@ -0,0 +1,103 @@ +import 'reflect-metadata'; +import type { ZodSchema } from 'zod'; + +export const META_KEYS = { + CONTROLLER: Symbol('decorators:controller'), + ROUTES: Symbol('decorators:routes'), + VALIDATION: Symbol('decorators:validation'), + RESPONSES: Symbol('decorators:responses'), + AUTH: Symbol('decorators:auth'), + REQUIRED_HEADERS: Symbol('decorators:required-headers'), +} as const; + +export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export interface ControllerMetadata { + tag: string; + prefix: string; + description?: string; +} + +export interface RouteMetadata { + method: HttpMethod; + path: string; + handlerName: string; + summary?: string; + description?: string; +} + +export interface ValidationMetadata { + body?: ZodSchema; + params?: ZodSchema; + query?: ZodSchema; +} + +export interface ResponseMetadata { + status: number; + description: string; + schema?: ZodSchema; +} + +export interface RequiredHeadersMetadata { + headers: string[]; +} + +export function getRoutes(target: object): RouteMetadata[] { + return (Reflect.getMetadata(META_KEYS.ROUTES, target) as RouteMetadata[]) ?? []; +} + +export function addRoute(target: object, route: RouteMetadata): void { + const routes = getRoutes(target); + routes.push(route); + Reflect.defineMetadata(META_KEYS.ROUTES, routes, target); +} + +export function getController(target: object): ControllerMetadata | undefined { + return Reflect.getMetadata(META_KEYS.CONTROLLER, target) as ControllerMetadata | undefined; +} + +export function setController(target: object, meta: ControllerMetadata): void { + Reflect.defineMetadata(META_KEYS.CONTROLLER, meta, target); +} + +export function getValidation(target: object, handlerName: string | symbol): ValidationMetadata { + return (Reflect.getMetadata(META_KEYS.VALIDATION, target, handlerName as string) as ValidationMetadata) ?? {}; +} + +export function setValidation(target: object, handlerName: string | symbol, meta: ValidationMetadata): void { + Reflect.defineMetadata(META_KEYS.VALIDATION, meta, target, handlerName as string); +} + +export function getResponses(target: object, handlerName: string | symbol): ResponseMetadata[] { + return (Reflect.getMetadata(META_KEYS.RESPONSES, target, handlerName as string) as ResponseMetadata[]) ?? []; +} + +export function addResponse(target: object, handlerName: string | symbol, response: ResponseMetadata): void { + const existing = getResponses(target, handlerName); + existing.push(response); + Reflect.defineMetadata(META_KEYS.RESPONSES, existing, target, handlerName as string); +} + +export function isProtected(target: object, handlerName: string | symbol): boolean { + return Reflect.getMetadata(META_KEYS.AUTH, target, handlerName as string) === true; +} + +export function setProtected(target: object, handlerName: string | symbol): void { + Reflect.defineMetadata(META_KEYS.AUTH, true, target, handlerName as string); +} + +export function getRequiredHeaders(target: object, handlerName: string | symbol): string[] { + const meta = Reflect.getMetadata(META_KEYS.REQUIRED_HEADERS, target, handlerName as string) as + | RequiredHeadersMetadata + | undefined; + return meta?.headers ?? []; +} + +export function setRequiredHeaders(target: object, handlerName: string | symbol, headers: string[]): void { + Reflect.defineMetadata( + META_KEYS.REQUIRED_HEADERS, + { headers: headers.map((h) => h.toLowerCase()) }, + target, + handlerName as string, + ); +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/response.decorator.ts b/generators/express_decorators_core/files/src/shared/decorators/response.decorator.ts new file mode 100644 index 0000000..27bcb5b --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/response.decorator.ts @@ -0,0 +1,12 @@ +import type { ZodSchema } from 'zod'; +import { addResponse } from './metadata'; + +/** + * Declare an OpenAPI response for a route. Multiple ApiResponse decorators + * can be stacked on the same handler to document several status codes. + */ +export function ApiResponse(status: number, description: string, schema?: ZodSchema): MethodDecorator { + return (target, propertyKey) => { + addResponse(target, propertyKey as string, { status, description, schema }); + }; +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/route.decorators.ts b/generators/express_decorators_core/files/src/shared/decorators/route.decorators.ts new file mode 100644 index 0000000..359197c --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/route.decorators.ts @@ -0,0 +1,55 @@ +import { addRoute, getRoutes, type HttpMethod, type RouteMetadata } from './metadata'; + +export interface RouteOptions { + path?: string; + summary?: string; + description?: string; +} + +function buildRoute(method: HttpMethod, opts: string | RouteOptions | undefined, handlerName: string): RouteMetadata { + if (typeof opts === 'string') { + return { method, path: opts, handlerName }; + } + return { + method, + path: opts?.path ?? '', + handlerName, + summary: opts?.summary, + description: opts?.description, + }; +} + +function makeRouteDecorator(method: HttpMethod) { + return (pathOrOptions?: string | RouteOptions): MethodDecorator => + (target, propertyKey) => { + addRoute(target, buildRoute(method, pathOrOptions, propertyKey as string)); + }; +} + +export const Get = makeRouteDecorator('get'); +export const Post = makeRouteDecorator('post'); +export const Put = makeRouteDecorator('put'); +export const Patch = makeRouteDecorator('patch'); +export const Delete = makeRouteDecorator('delete'); + +/** + * Override the OpenAPI summary for a route after the route decorator was applied. + */ +export function Summary(summary: string): MethodDecorator { + return (target, propertyKey) => { + const routes = getRoutes(target); + const route = routes.find((r) => r.handlerName === propertyKey); + if (route) route.summary = summary; + }; +} + +/** + * Override the OpenAPI long description for a route. + */ +export function Description(description: string): MethodDecorator { + return (target, propertyKey) => { + const routes = getRoutes(target); + const route = routes.find((r) => r.handlerName === propertyKey); + if (route) route.description = description; + }; +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/router-adapter.ts b/generators/express_decorators_core/files/src/shared/decorators/router-adapter.ts new file mode 100644 index 0000000..363baf2 --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/router-adapter.ts @@ -0,0 +1,27 @@ +import type { ZodSchema } from 'zod'; +import type { HttpMethod } from './metadata'; + +export interface RouteRegistration { + method: HttpMethod; + path: string; + handler: (...args: unknown[]) => unknown; + validation: { + body?: ZodSchema; + params?: ZodSchema; + query?: ZodSchema; + }; + requiredHeaders: string[]; + isProtected: boolean; +} + +/** + * Framework-agnostic router contract. Implementations translate a + * RouteRegistration into framework-native middleware + handler wiring. + * + * Express adapter is shipped by default. To support Fastify or another + * framework, implement this interface and pass an instance to DecoratorRouter. + */ +export interface RouterAdapter { + register(registration: RouteRegistration): void; + build(): NativeRouter; +} diff --git a/generators/express_decorators_core/files/src/shared/decorators/validation.decorators.ts b/generators/express_decorators_core/files/src/shared/decorators/validation.decorators.ts new file mode 100644 index 0000000..f8205e8 --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/decorators/validation.decorators.ts @@ -0,0 +1,20 @@ +import type { ZodSchema } from 'zod'; +import { getValidation, setValidation } from './metadata'; + +function setOn(target: object, handlerName: string, key: 'body' | 'params' | 'query', schema: ZodSchema): void { + const meta = getValidation(target, handlerName); + meta[key] = schema; + setValidation(target, handlerName, meta); +} + +export function Body(schema: ZodSchema): MethodDecorator { + return (target, propertyKey) => setOn(target, propertyKey as string, 'body', schema); +} + +export function Params(schema: ZodSchema): MethodDecorator { + return (target, propertyKey) => setOn(target, propertyKey as string, 'params', schema); +} + +export function Query(schema: ZodSchema): MethodDecorator { + return (target, propertyKey) => setOn(target, propertyKey as string, 'query', schema); +} diff --git a/generators/express_decorators_core/files/src/shared/middlewares/validate-request.ts b/generators/express_decorators_core/files/src/shared/middlewares/validate-request.ts new file mode 100644 index 0000000..8114da7 --- /dev/null +++ b/generators/express_decorators_core/files/src/shared/middlewares/validate-request.ts @@ -0,0 +1,32 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import type { ZodSchema } from 'zod'; + +export type ValidationTarget = 'body' | 'params' | 'query'; + +/** + * Returns an Express middleware that validates `req[target]` against the + * supplied Zod schema. On success, `req[target]` is replaced by the parsed + * (and coerced) value so downstream handlers see a typed payload. + * + * On failure, responds with 400 and a structured error payload listing every + * issue Zod surfaced. The handler is never invoked. + */ +export function validateRequest(schema: ZodSchema, target: ValidationTarget): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req[target]); + if (!result.success) { + res.status(400).json({ + error: 'ValidationError', + target, + issues: result.error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + code: issue.code, + })), + }); + return; + } + (req as unknown as Record)[target] = result.data; + next(); + }; +} diff --git a/generators/express_decorators_core/generator.go b/generators/express_decorators_core/generator.go new file mode 100644 index 0000000..682774c --- /dev/null +++ b/generators/express_decorators_core/generator.go @@ -0,0 +1,22 @@ +package expressdecoratorscore + +import ( + "embed" + + "github.com/version14/dot/internal/render" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +//go:embed all:files +var fs embed.FS + +func (g *Generator) Generate(ctx *dotapi.Context) error { + return render.NewLocalFolderRenderer(ctx.State).Render(fs, nil) +} diff --git a/generators/express_decorators_core/manifest.go b/generators/express_decorators_core/manifest.go new file mode 100644 index 0000000..6dd7c93 --- /dev/null +++ b/generators/express_decorators_core/manifest.go @@ -0,0 +1,36 @@ +package expressdecoratorscore + +import "github.com/version14/dot/pkg/dotapi" + +var Manifest = dotapi.Manifest{ + Name: "express_decorators_core", + Version: "0.1.0", + Description: "Framework-agnostic API decorators (Controller, route, validation, response, auth) with an Express RouterAdapter", + DependsOn: []string{"express_server_entrypoint", "zod_validation_deps"}, + Outputs: []string{ + "src/shared/decorators/metadata.ts", + "src/shared/decorators/controller.decorator.ts", + "src/shared/decorators/route.decorators.ts", + "src/shared/decorators/validation.decorators.ts", + "src/shared/decorators/response.decorator.ts", + "src/shared/decorators/auth.decorator.ts", + "src/shared/decorators/header.decorator.ts", + "src/shared/decorators/router-adapter.ts", + "src/shared/decorators/express-router-adapter.ts", + "src/shared/decorators/decorator-router.ts", + "src/shared/decorators/index.ts", + "src/shared/middlewares/validate-request.ts", + "src/shared/decorators/__tests__/decorators.unit.test.ts", + }, + Validators: []dotapi.Validator{ + { + Name: "express-decorators-core", + Checks: []dotapi.Check{ + {Type: dotapi.CheckFileExists, Path: "src/shared/decorators/index.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/decorators/decorator-router.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/decorators/router-adapter.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/middlewares/validate-request.ts"}, + }, + }, + }, +} diff --git a/generators/express_openapi_setup/files/src/shared/openapi/__tests__/spec.unit.test.ts b/generators/express_openapi_setup/files/src/shared/openapi/__tests__/spec.unit.test.ts new file mode 100644 index 0000000..12af3be --- /dev/null +++ b/generators/express_openapi_setup/files/src/shared/openapi/__tests__/spec.unit.test.ts @@ -0,0 +1,128 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { + ApiResponse, + Body, + Controller, + DecoratorRouter, + ExpressRouterAdapter, + Get, + Post, + Auth, +} from '../../decorators'; +import { createRegistry } from '../registry'; +import { buildOpenApiSpec } from '../spec-generator'; +import { mountSwagger } from '../swagger'; +import { z } from 'zod'; + +describe('OpenAPI spec generation', () => { + it('produces a v3 document with the expected paths and tags', () => { + const userSchema = z.object({ id: z.string(), email: z.string().email() }); + + @Controller({ tag: 'users', prefix: '/users', description: 'User management' }) + class UsersController { + @Get(':id') + @ApiResponse(200, 'user found', userSchema) + get(_req: express.Request, res: express.Response) { + res.json({}); + } + + @Post('/') + @Body(userSchema) + @ApiResponse(201, 'user created', userSchema) + create(_req: express.Request, res: express.Response) { + res.json({}); + } + } + + const router = new DecoratorRouter(new ExpressRouterAdapter()).registerController(new UsersController()); + const spec = buildOpenApiSpec({ + info: { title: 'Test API', version: '1.0.0' }, + servers: [{ url: '/api' }], + routes: router.routes(), + registry: createRegistry(), + }); + + expect(spec.openapi).toBe('3.0.0'); + const paths = spec.paths as Record>; + expect(paths['/users/{id}']).toBeDefined(); + expect(paths['/users']).toBeDefined(); + + const getOp = paths['/users/{id}']?.get as { tags?: string[]; responses: Record }; + expect(getOp?.tags).toEqual(['users']); + expect(getOp?.responses['200']).toBeDefined(); + + const postOp = paths['/users']?.post as { requestBody: unknown }; + expect(postOp?.requestBody).toBeDefined(); + }); + + it('marks @Auth-decorated routes with BearerAuth security', () => { + @Controller({ tag: 'me', prefix: '/me' }) + class MeController { + @Get('/') + @Auth() + @ApiResponse(200, 'profile') + handle(_req: express.Request, res: express.Response) { + res.json({}); + } + } + + const router = new DecoratorRouter(new ExpressRouterAdapter()).registerController(new MeController()); + const spec = buildOpenApiSpec({ + info: { title: 'API', version: '1.0.0' }, + routes: router.routes(), + registry: createRegistry(), + }); + + const op = (spec.paths as Record> } }>)['/me']?.get; + expect(op?.security).toEqual([{ BearerAuth: [] }]); + }); + + it('falls back to a default 200 response when none is declared', () => { + @Controller({ tag: 'noop', prefix: '/noop' }) + class NoopController { + @Get('/') + handle(_req: express.Request, res: express.Response) { + res.send(); + } + } + + const router = new DecoratorRouter(new ExpressRouterAdapter()).registerController(new NoopController()); + const spec = buildOpenApiSpec({ + info: { title: 'API', version: '1.0.0' }, + routes: router.routes(), + registry: createRegistry(), + }); + + const op = (spec.paths as Record } }>)['/noop'] + ?.get; + expect(op?.responses['200']?.description).toBe('Successful response'); + }); + + it('serves /docs/openapi.json with the spec', async () => { + @Controller({ tag: 'health', prefix: '/health' }) + class HealthController { + @Get('/') + @ApiResponse(200, 'ok') + handle(_req: express.Request, res: express.Response) { + res.json({}); + } + } + + const router = new DecoratorRouter(new ExpressRouterAdapter()).registerController(new HealthController()); + const spec = buildOpenApiSpec({ + info: { title: 'Mounted API', version: '0.0.1' }, + routes: router.routes(), + registry: createRegistry(), + }); + + const app = express(); + mountSwagger(app, spec); + + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.info.title).toBe('Mounted API'); + expect(res.body.paths['/health']).toBeDefined(); + }); +}); diff --git a/generators/express_openapi_setup/files/src/shared/openapi/index.ts b/generators/express_openapi_setup/files/src/shared/openapi/index.ts new file mode 100644 index 0000000..035e37b --- /dev/null +++ b/generators/express_openapi_setup/files/src/shared/openapi/index.ts @@ -0,0 +1,3 @@ +export * from './registry'; +export * from './spec-generator'; +export * from './swagger'; diff --git a/generators/express_openapi_setup/files/src/shared/openapi/registry.ts b/generators/express_openapi_setup/files/src/shared/openapi/registry.ts new file mode 100644 index 0000000..fafafe2 --- /dev/null +++ b/generators/express_openapi_setup/files/src/shared/openapi/registry.ts @@ -0,0 +1,39 @@ +import { extendZodWithOpenApi, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +/** + * Construct a fresh OpenAPI registry pre-populated with a BearerAuth security + * scheme. Each call returns a new registry — this avoids global mutable state + * and lets tests run in isolation. + * + * Production code typically wants the shared registry below. + */ +export function createRegistry(): OpenAPIRegistry { + const registry = new OpenAPIRegistry(); + registry.registerComponent('securitySchemes', 'BearerAuth', { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }); + return registry; +} + +let shared: OpenAPIRegistry | undefined; + +/** + * Lazily-initialised shared registry for production code that wants a single + * collection point for every OpenAPI definition. Tests should prefer + * createRegistry() to avoid leaking definitions between cases. + */ +export function getSharedRegistry(): OpenAPIRegistry { + if (!shared) shared = createRegistry(); + return shared; +} + +export function resetSharedRegistry(): void { + shared = undefined; +} + +export { z }; diff --git a/generators/express_openapi_setup/files/src/shared/openapi/spec-generator.ts b/generators/express_openapi_setup/files/src/shared/openapi/spec-generator.ts new file mode 100644 index 0000000..a3897cf --- /dev/null +++ b/generators/express_openapi_setup/files/src/shared/openapi/spec-generator.ts @@ -0,0 +1,93 @@ +import { + OpenApiGeneratorV3, + type OpenAPIRegistry, + type ResponseConfig, + type RouteConfig, +} from '@asteasolutions/zod-to-openapi'; +import type { RegisteredRoute } from '../decorators'; +import { getSharedRegistry } from './registry'; + +export interface OpenApiInfo { + title: string; + version: string; + description?: string; + [key: `x-${string}`]: unknown; +} + +export interface OpenApiServer { + url: string; + description?: string; + [key: `x-${string}`]: unknown; +} + +export interface BuildSpecOptions { + info: OpenApiInfo; + servers?: OpenApiServer[]; + routes: readonly RegisteredRoute[]; + registry?: OpenAPIRegistry; +} + +function pathToOpenApi(path: string): string { + return path.replaceAll(/:([A-Za-z0-9_]+)/g, '{$1}'); +} + +function buildResponses(route: RegisteredRoute): RouteConfig['responses'] { + const responses: Record = {}; + for (const r of route.responses) { + responses[r.status] = { + description: r.description, + ...(r.schema && { + content: { 'application/json': { schema: r.schema } }, + }), + }; + } + if (Object.keys(responses).length === 0) { + responses[200] = { description: 'Successful response' }; + } + return responses; +} + +function buildRequest(route: RegisteredRoute): RouteConfig['request'] | undefined { + const request: NonNullable = {}; + if (route.validation.params) { + request.params = route.validation.params as NonNullable['params']; + } + if (route.validation.query) { + request.query = route.validation.query as NonNullable['query']; + } + if (route.validation.body) { + request.body = { + content: { 'application/json': { schema: route.validation.body } }, + }; + } + return Object.keys(request).length > 0 ? request : undefined; +} + +/** + * Convert decorator metadata into a complete OpenAPI v3 document. Pass a fresh + * registry (`createRegistry()`) for tests, or omit to use the shared one. + */ +export function buildOpenApiSpec(options: BuildSpecOptions): Record { + const registry = options.registry ?? getSharedRegistry(); + + for (const r of options.routes) { + registry.registerPath({ + method: r.route.method, + path: pathToOpenApi(r.fullPath), + summary: r.route.summary, + description: r.route.description, + tags: [r.controller.tag], + request: buildRequest(r), + responses: buildResponses(r), + security: r.protected ? [{ BearerAuth: [] }] : undefined, + }); + } + + const generator = new OpenApiGeneratorV3(registry.definitions); + const document = generator.generateDocument({ + openapi: '3.0.0', + info: options.info, + servers: options.servers, + }); + return document as unknown as Record; +} diff --git a/generators/express_openapi_setup/files/src/shared/openapi/swagger.ts b/generators/express_openapi_setup/files/src/shared/openapi/swagger.ts new file mode 100644 index 0000000..76fdd7c --- /dev/null +++ b/generators/express_openapi_setup/files/src/shared/openapi/swagger.ts @@ -0,0 +1,26 @@ +import type { Express, RequestHandler } from 'express'; +import swaggerUi from 'swagger-ui-express'; + +export interface MountSwaggerOptions { + /** URL path that serves the Swagger UI (default: /docs). */ + path?: string; + /** URL path that serves the raw JSON spec (default: /docs/openapi.json). */ + jsonPath?: string; +} + +/** + * Serve a Swagger UI for the given OpenAPI spec at /docs (configurable). The + * raw JSON document is also served so external clients (Postman, codegen) can + * consume it programmatically. + */ +export function mountSwagger(app: Express, spec: Record, opts: MountSwaggerOptions = {}): void { + const path = opts.path ?? '/docs'; + const jsonPath = opts.jsonPath ?? `${path}/openapi.json`; + + const jsonHandler: RequestHandler = (_req, res) => { + res.json(spec); + }; + + app.get(jsonPath, jsonHandler); + app.use(path, swaggerUi.serve, swaggerUi.setup(spec)); +} diff --git a/generators/express_openapi_setup/generator.go b/generators/express_openapi_setup/generator.go new file mode 100644 index 0000000..a3c3714 --- /dev/null +++ b/generators/express_openapi_setup/generator.go @@ -0,0 +1,22 @@ +package expressopenapisetup + +import ( + "embed" + + "github.com/version14/dot/internal/render" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +//go:embed all:files +var fs embed.FS + +func (g *Generator) Generate(ctx *dotapi.Context) error { + return render.NewLocalFolderRenderer(ctx.State).Render(fs, nil) +} diff --git a/generators/express_openapi_setup/manifest.go b/generators/express_openapi_setup/manifest.go new file mode 100644 index 0000000..eae99c9 --- /dev/null +++ b/generators/express_openapi_setup/manifest.go @@ -0,0 +1,27 @@ +package expressopenapisetup + +import "github.com/version14/dot/pkg/dotapi" + +var Manifest = dotapi.Manifest{ + Name: "express_openapi_setup", + Version: "0.1.0", + Description: "OpenAPI spec generator + Swagger UI mount that consumes DecoratorRouter metadata", + DependsOn: []string{"express_decorators_core"}, + Outputs: []string{ + "src/shared/openapi/registry.ts", + "src/shared/openapi/spec-generator.ts", + "src/shared/openapi/swagger.ts", + "src/shared/openapi/index.ts", + "src/shared/openapi/__tests__/spec.unit.test.ts", + }, + Validators: []dotapi.Validator{ + { + Name: "express-openapi-setup", + Checks: []dotapi.Check{ + {Type: dotapi.CheckFileExists, Path: "src/shared/openapi/spec-generator.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/openapi/swagger.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/openapi/registry.ts"}, + }, + }, + }, +} diff --git a/generators/express_server_entrypoint/files/src/app.ts.tmpl b/generators/express_server_entrypoint/files/src/app.ts.tmpl index 92ed93f..b677a73 100644 --- a/generators/express_server_entrypoint/files/src/app.ts.tmpl +++ b/generators/express_server_entrypoint/files/src/app.ts.tmpl @@ -1,12 +1,32 @@ import express from 'express'; import cors from 'cors'; +import { corsOptions } from './shared/cors'; const app = express(); -app.use(cors()); +app.use(cors(corsOptions())); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +/** + * @openapi + * /health: + * get: + * tags: [System] + * summary: Liveness probe + * description: Returns "ok" when the process is up. Use as a load-balancer health check. + * responses: + * 200: + * description: Service is up + * content: + * application/json: + * schema: + * type: object + * required: [status, timestamp] + * properties: + * status: { type: string, example: ok } + * timestamp: { type: string, format: date-time } + */ app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); diff --git a/generators/express_server_entrypoint/files/src/shared/cors.ts b/generators/express_server_entrypoint/files/src/shared/cors.ts new file mode 100644 index 0000000..f246fe5 --- /dev/null +++ b/generators/express_server_entrypoint/files/src/shared/cors.ts @@ -0,0 +1,25 @@ +import type { CorsOptions } from 'cors'; + +/** + * Resolve the CORS configuration from the `CORS_ORIGIN` env variable. + * + * Accepted shapes: + * - `"*"` → allow any origin (no credentials). + * - `"https://a.com,https://b.com"` → comma-separated allow-list with credentials. + * - unset → defaults to `"http://localhost:3000"`. + * + * Production deployments must set `CORS_ORIGIN` to the explicit list of + * trusted origins. Avoid `"*"` outside of local development; with credentials + * enabled, browsers reject it anyway. + */ +export function corsOptions(): CorsOptions { + const raw = process.env.CORS_ORIGIN?.trim(); + if (!raw) { + return { origin: 'http://localhost:3000', credentials: true }; + } + if (raw === '*') { + return { origin: '*' }; + } + const list = raw.split(',').map((s) => s.trim()).filter(Boolean); + return { origin: list, credentials: true }; +} diff --git a/generators/express_server_entrypoint/generator.go b/generators/express_server_entrypoint/generator.go index a4b405d..39a5f57 100644 --- a/generators/express_server_entrypoint/generator.go +++ b/generators/express_server_entrypoint/generator.go @@ -24,7 +24,13 @@ func (g *Generator) Generate(ctx *dotapi.Context) error { return err } - // Base .env.example — downstream generators append their own vars - ctx.State.WriteFile(".env.example", []byte("PORT=3000\n"), state.ContentRaw) + // Base .env.example — downstream generators append their own vars. + // CORS_ORIGIN is consumed by src/shared/cors.ts: "*" allows any origin + // (dev only), a comma-separated list restricts to those origins, and + // unset falls back to http://localhost:3000. + envExample := "PORT=3000\n" + + "# Comma-separated list of allowed origins, or \"*\" for any.\n" + + "CORS_ORIGIN=http://localhost:3000\n" + ctx.State.WriteFile(".env.example", []byte(envExample), state.ContentRaw) return nil } diff --git a/generators/express_server_entrypoint/manifest.go b/generators/express_server_entrypoint/manifest.go index 897bafe..5ff4d21 100644 --- a/generators/express_server_entrypoint/manifest.go +++ b/generators/express_server_entrypoint/manifest.go @@ -10,6 +10,7 @@ var Manifest = dotapi.Manifest{ Outputs: []string{ "src/index.ts", "src/app.ts", + "src/shared/cors.ts", ".env.example", }, Validators: []dotapi.Validator{ @@ -18,6 +19,7 @@ var Manifest = dotapi.Manifest{ Checks: []dotapi.Check{ {Type: dotapi.CheckFileExists, Path: "src/index.ts"}, {Type: dotapi.CheckFileExists, Path: "src/app.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/cors.ts"}, }, }, }, diff --git a/generators/express_swagger_jsdoc/files/src/shared/swagger/__tests__/swagger.unit.test.ts b/generators/express_swagger_jsdoc/files/src/shared/swagger/__tests__/swagger.unit.test.ts new file mode 100644 index 0000000..2994f04 --- /dev/null +++ b/generators/express_swagger_jsdoc/files/src/shared/swagger/__tests__/swagger.unit.test.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { mountSwagger } from '..'; + +describe('JSDoc-based Swagger', () => { + it('serves /docs/openapi.json with a valid v3 document', async () => { + const app = express(); + mountSwagger(app); + + const res = await request(app).get('/docs/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.0.0'); + expect(res.body.info?.title).toBe('API'); + // The /health endpoint declared in src/app.ts should be picked up by the scanner. + expect(res.body.paths?.['/health']).toBeDefined(); + }); + + it('honours custom mount path', async () => { + const app = express(); + mountSwagger(app, { path: '/api-docs' }); + + const json = await request(app).get('/api-docs/openapi.json'); + expect(json.status).toBe(200); + }); +}); diff --git a/generators/express_swagger_jsdoc/files/src/shared/swagger/index.ts b/generators/express_swagger_jsdoc/files/src/shared/swagger/index.ts new file mode 100644 index 0000000..7fc52ac --- /dev/null +++ b/generators/express_swagger_jsdoc/files/src/shared/swagger/index.ts @@ -0,0 +1,31 @@ +import type { Express, RequestHandler } from 'express'; +import swaggerUi from 'swagger-ui-express'; +import { swaggerSpec } from './swagger.config'; + +export interface MountSwaggerOptions { + /** URL path that serves the Swagger UI (default: /docs). */ + path?: string; + /** URL path that serves the raw JSON spec (default: /docs/openapi.json). */ + jsonPath?: string; +} + +/** + * Mount the Swagger UI at /docs and serve the raw spec at /docs/openapi.json. + * + * The spec is rebuilt at boot from every `@openapi` JSDoc block found under + * `src/` (see {@link swaggerSpec}). New routes pick up automatically as long + * as their JSDoc is well-formed. + */ +export function mountSwagger(app: Express, opts: MountSwaggerOptions = {}): void { + const uiPath = opts.path ?? '/docs'; + const jsonPath = opts.jsonPath ?? `${uiPath}/openapi.json`; + + const jsonHandler: RequestHandler = (_req, res) => { + res.json(swaggerSpec); + }; + + app.get(jsonPath, jsonHandler); + app.use(uiPath, swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +} + +export { swaggerSpec, swaggerOptions } from './swagger.config'; diff --git a/generators/express_swagger_jsdoc/files/src/shared/swagger/swagger.config.ts b/generators/express_swagger_jsdoc/files/src/shared/swagger/swagger.config.ts new file mode 100644 index 0000000..96b49b0 --- /dev/null +++ b/generators/express_swagger_jsdoc/files/src/shared/swagger/swagger.config.ts @@ -0,0 +1,35 @@ +import path from 'node:path'; +import swaggerJSDoc, { type Options } from 'swagger-jsdoc'; + +const projectRoot = path.resolve(process.cwd()); + +/** + * swagger-jsdoc options. Scans every `.ts` and `.js` file under `src/` for + * `@openapi` JSDoc blocks and assembles them into a single OpenAPI v3 + * document. Update `info.title`/`info.version` to match your project. + */ +export const swaggerOptions: Options = { + definition: { + openapi: '3.0.0', + info: { + title: 'API', + version: '1.0.0', + description: 'API documentation generated from JSDoc @openapi comments.', + }, + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + apis: [ + path.join(projectRoot, 'src/**/*.ts'), + path.join(projectRoot, 'src/**/*.js'), + ], +}; + +export const swaggerSpec = swaggerJSDoc(swaggerOptions); diff --git a/generators/express_swagger_jsdoc/generator.go b/generators/express_swagger_jsdoc/generator.go new file mode 100644 index 0000000..58082e9 --- /dev/null +++ b/generators/express_swagger_jsdoc/generator.go @@ -0,0 +1,61 @@ +package expressswaggerjsdoc + +import ( + "embed" + "strings" + + "github.com/version14/dot/internal/render" + "github.com/version14/dot/internal/state" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +//go:embed all:files +var fs embed.FS + +const swaggerImports = "import { mountSwagger } from './shared/swagger';\n" + +const swaggerMount = "\nmountSwagger(app);\n" + +func (g *Generator) Generate(ctx *dotapi.Context) error { + if err := render.NewLocalFolderRenderer(ctx.State).Render(fs, nil); err != nil { + return err + } + + if err := ctx.State.UpdateJSON("package.json", func(d *state.JSONDoc) error { + d.Merge(map[string]interface{}{ + "dependencies": map[string]interface{}{ + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + }, + "devDependencies": map[string]interface{}{ + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.6", + }, + }) + return nil + }); err != nil { + return err + } + + if f, ok := ctx.State.GetFile("src/app.ts"); ok { + content := string(f.Content) + if !strings.Contains(content, "mountSwagger") { + content = swaggerImports + content + if strings.Contains(content, "export default app;") { + content = strings.Replace(content, "export default app;", swaggerMount+"\nexport default app;", 1) + } else { + content += swaggerMount + } + ctx.State.WriteFile("src/app.ts", []byte(content), state.ContentRaw) + } + } + + return nil +} diff --git a/generators/express_swagger_jsdoc/manifest.go b/generators/express_swagger_jsdoc/manifest.go new file mode 100644 index 0000000..46fbaef --- /dev/null +++ b/generators/express_swagger_jsdoc/manifest.go @@ -0,0 +1,26 @@ +package expressswaggerjsdoc + +import "github.com/version14/dot/pkg/dotapi" + +var Manifest = dotapi.Manifest{ + Name: "express_swagger_jsdoc", + Version: "0.1.0", + Description: "Classic JSDoc-driven Swagger/OpenAPI: scans source files for @openapi comments, builds the spec at boot, and mounts swagger-ui at /docs", + DependsOn: []string{"express_server_entrypoint"}, + Outputs: []string{ + "src/shared/swagger/swagger.config.ts", + "src/shared/swagger/index.ts", + "src/shared/swagger/__tests__/swagger.unit.test.ts", + }, + Validators: []dotapi.Validator{ + { + Name: "express-swagger-jsdoc", + Checks: []dotapi.Check{ + {Type: dotapi.CheckFileExists, Path: "src/shared/swagger/swagger.config.ts"}, + {Type: dotapi.CheckFileExists, Path: "src/shared/swagger/index.ts"}, + {Type: dotapi.CheckJSONKeyExists, Path: "package.json", Key: "dependencies.swagger-jsdoc"}, + {Type: dotapi.CheckJSONKeyExists, Path: "package.json", Key: "dependencies.swagger-ui-express"}, + }, + }, + }, +} diff --git a/generators/prettier_typescript_deps/manifest.go b/generators/prettier_typescript_deps/manifest.go index b001889..6d7e25c 100644 --- a/generators/prettier_typescript_deps/manifest.go +++ b/generators/prettier_typescript_deps/manifest.go @@ -9,7 +9,7 @@ var Manifest = dotapi.Manifest{ DependsOn: []string{"prettier_config", "*"}, Outputs: []string{}, PostGenerationCommands: []dotapi.Command{ - {Cmd: "pnpm install"}, + {Cmd: "pnpm install --dangerously-allow-all-builds"}, {Cmd: "pnpm format"}, }, TestCommands: []dotapi.Command{ diff --git a/generators/react_app/manifest.go b/generators/react_app/manifest.go index 2e117e4..153399c 100644 --- a/generators/react_app/manifest.go +++ b/generators/react_app/manifest.go @@ -20,13 +20,15 @@ var Manifest = dotapi.Manifest{ "vite.config.ts", }, PostGenerationCommands: []dotapi.Command{ - {Cmd: "pnpm install"}, + {Cmd: "pnpm install --dangerously-allow-all-builds"}, }, TestCommands: []dotapi.Command{ {Cmd: "pnpm exec tsc --noEmit"}, {Cmd: "pnpm exec vite build"}, // Smoke-start the dev server in background to confirm it boots. - {Cmd: "pnpm exec vite", Background: true, ReadyDelay: 4 * time.Second}, + // NoCache: true — we want a real boot every run to catch + // port-binding / runtime regressions, not skip on a cache hit. + {Cmd: "pnpm exec vite", Background: true, ReadyDelay: 4 * time.Second, NoCache: true}, }, Validators: []dotapi.Validator{ { diff --git a/generators/typescript_base/manifest.go b/generators/typescript_base/manifest.go index 6237bc1..451b0ed 100644 --- a/generators/typescript_base/manifest.go +++ b/generators/typescript_base/manifest.go @@ -16,10 +16,10 @@ var Manifest = dotapi.Manifest{ "tsconfig.json", }, PostGenerationCommands: []dotapi.Command{ - {Cmd: "pnpm install"}, + {Cmd: "pnpm install --dangerously-allow-all-builds"}, }, TestCommands: []dotapi.Command{ - {Cmd: "pnpm install"}, + {Cmd: "pnpm install --dangerously-allow-all-builds"}, {Cmd: "pnpm exec tsc --noEmit"}, }, Validators: []dotapi.Validator{ diff --git a/generators/zod_validation_deps/generator.go b/generators/zod_validation_deps/generator.go new file mode 100644 index 0000000..8e52951 --- /dev/null +++ b/generators/zod_validation_deps/generator.go @@ -0,0 +1,42 @@ +package zodvalidationdeps + +import ( + "github.com/version14/dot/internal/state" + "github.com/version14/dot/pkg/dotapi" +) + +type Generator struct{} + +func New() *Generator { return &Generator{} } + +func (g *Generator) Name() string { return Manifest.Name } +func (g *Generator) Version() string { return Manifest.Version } + +func (g *Generator) Generate(ctx *dotapi.Context) error { + if err := ctx.State.UpdateJSON("package.json", func(d *state.JSONDoc) error { + d.Merge(map[string]interface{}{ + "dependencies": map[string]interface{}{ + "zod": "^3.23.8", + "@asteasolutions/zod-to-openapi": "^7.3.0", + "reflect-metadata": "^0.2.2", + "swagger-ui-express": "^5.0.1", + }, + "devDependencies": map[string]interface{}{ + "@types/swagger-ui-express": "^4.1.6", + }, + }) + return nil + }); err != nil { + return err + } + + return ctx.State.UpdateJSON("tsconfig.json", func(d *state.JSONDoc) error { + d.Merge(map[string]interface{}{ + "compilerOptions": map[string]interface{}{ + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + }, + }) + return nil + }) +} diff --git a/generators/zod_validation_deps/manifest.go b/generators/zod_validation_deps/manifest.go new file mode 100644 index 0000000..e799921 --- /dev/null +++ b/generators/zod_validation_deps/manifest.go @@ -0,0 +1,24 @@ +package zodvalidationdeps + +import "github.com/version14/dot/pkg/dotapi" + +var packageFileName = "package.json" + +var Manifest = dotapi.Manifest{ + Name: "zod_validation_deps", + Version: "0.1.0", + Description: "Adds Zod, zod-to-openapi, swagger-ui-express, and reflect-metadata dependencies plus tsconfig flags for decorator metadata", + DependsOn: []string{"typescript_base"}, + Outputs: []string{}, + Validators: []dotapi.Validator{ + { + Name: "zod-validation-deps", + Checks: []dotapi.Check{ + {Type: dotapi.CheckJSONKeyExists, Path: packageFileName, Key: "dependencies.zod"}, + {Type: dotapi.CheckJSONKeyExists, Path: packageFileName, Key: "dependencies.@asteasolutions/zod-to-openapi"}, + {Type: dotapi.CheckJSONKeyExists, Path: packageFileName, Key: "dependencies.reflect-metadata"}, + {Type: dotapi.CheckJSONKeyExists, Path: packageFileName, Key: "dependencies.swagger-ui-express"}, + }, + }, + }, +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index d9125d3..c452d03 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -186,6 +186,7 @@ func (r *HuhFormRunner) buildField(q flow.Question, store *liveStore) (huh.Field *ptr = typed.Default return huh.NewConfirm(). Title(typed.Label). + Description(typed.Description). Value(ptr), nil case *flow.LoopQuestion: diff --git a/internal/cli/registry.go b/internal/cli/registry.go index 4b078c7..56805d1 100644 --- a/internal/cli/registry.go +++ b/internal/cli/registry.go @@ -14,16 +14,22 @@ import ( backendArchitectureMVC "github.com/version14/dot/generators/backend_architecture_mvc_architecture" baseproject "github.com/version14/dot/generators/base_project" biomeconfig "github.com/version14/dot/generators/biome_config" + decoratorscleanarchadapter "github.com/version14/dot/generators/decorators_clean_arch_adapter" + decoratorshexagonaladapter "github.com/version14/dot/generators/decorators_hexagonal_adapter" + decoratorsmvcadapter "github.com/version14/dot/generators/decorators_mvc_adapter" drizzleconfigbase "github.com/version14/dot/generators/drizzle_config_base" drizzlepostgresadapter "github.com/version14/dot/generators/drizzle_postgres_adapter" drizzletypescriptdeps "github.com/version14/dot/generators/drizzle_typescript_deps" expressauthvalidators "github.com/version14/dot/generators/express_auth_validators" + expressdecoratorscore "github.com/version14/dot/generators/express_decorators_core" expresserrormiddleware "github.com/version14/dot/generators/express_error_middleware" expressnodetsconfig "github.com/version14/dot/generators/express_node_tsconfig" + expressopenapisetup "github.com/version14/dot/generators/express_openapi_setup" expressratelimit "github.com/version14/dot/generators/express_rate_limit" expressserverentrypoint "github.com/version14/dot/generators/express_server_entrypoint" expressservertypescriptdeps "github.com/version14/dot/generators/express_server_typescript_deps" expresssharederrors "github.com/version14/dot/generators/express_shared_errors" + expressswaggerjsdoc "github.com/version14/dot/generators/express_swagger_jsdoc" expresstestsetup "github.com/version14/dot/generators/express_test_setup" pluginreposkeleton "github.com/version14/dot/generators/plugin_repo_skeleton" postgresdockercompose "github.com/version14/dot/generators/postgres_docker_compose" @@ -33,6 +39,7 @@ import ( prettiertypescriptdeps "github.com/version14/dot/generators/prettier_typescript_deps" reactapp "github.com/version14/dot/generators/react_app" typescriptbase "github.com/version14/dot/generators/typescript_base" + zodvalidationdeps "github.com/version14/dot/generators/zod_validation_deps" "github.com/version14/dot/internal/generator" ) @@ -63,6 +70,17 @@ func builtinGeneratorEntries() []generator.Entry { {Manifest: expresstestsetup.Manifest, Generator: expresstestsetup.New()}, {Manifest: expressauthvalidators.Manifest, Generator: expressauthvalidators.New()}, + // OpenAPI / Swagger — classic JSDoc path + {Manifest: expressswaggerjsdoc.Manifest, Generator: expressswaggerjsdoc.New()}, + + // Decorator-based validation + OpenAPI + {Manifest: zodvalidationdeps.Manifest, Generator: zodvalidationdeps.New()}, + {Manifest: expressdecoratorscore.Manifest, Generator: expressdecoratorscore.New()}, + {Manifest: expressopenapisetup.Manifest, Generator: expressopenapisetup.New()}, + {Manifest: decoratorscleanarchadapter.Manifest, Generator: decoratorscleanarchadapter.New()}, + {Manifest: decoratorsmvcadapter.Manifest, Generator: decoratorsmvcadapter.New()}, + {Manifest: decoratorshexagonaladapter.Manifest, Generator: decoratorshexagonaladapter.New()}, + // Prettier {Manifest: prettierconfig.Manifest, Generator: prettierconfig.New()}, {Manifest: prettiertypescriptdeps.Manifest, Generator: prettiertypescriptdeps.New()}, diff --git a/internal/flow/question.go b/internal/flow/question.go index 9515ac0..14314ab 100644 --- a/internal/flow/question.go +++ b/internal/flow/question.go @@ -45,10 +45,11 @@ type TextQuestion struct { type ConfirmQuestion struct { QuestionBase - Label string - Default bool - Then *Next - Else *Next + Label string + Description string + Default bool + Then *Next + Else *Next } type LoopQuestion struct { diff --git a/internal/state/json.go b/internal/state/json.go index 1208185..5c688b0 100644 --- a/internal/state/json.go +++ b/internal/state/json.go @@ -3,6 +3,7 @@ package state import ( "encoding/json" "fmt" + "sort" "strings" ) @@ -106,6 +107,67 @@ func (d *JSONDoc) DeleteKey(path string) { delete(cursor, keys[len(keys)-1]) } +// AppendStringSet appends string values into the array at the dotted path, +// deduplicating and sorting the result for deterministic output. Intermediate +// objects and the array itself are created if missing — this is the right +// helper when several generators each contribute entries to a shared list +// (e.g. `pnpm.onlyBuiltDependencies`). Returns an error if a non-array value +// already lives at path or any intermediate segment is not an object. +func (d *JSONDoc) AppendStringSet(path string, values ...string) error { + keys := splitPath(path) + if len(keys) == 0 { + return fmt.Errorf("json: empty path") + } + cursor := d.root + for i, k := range keys[:len(keys)-1] { + next, ok := cursor[k] + if !ok { + child := map[string]interface{}{} + cursor[k] = child + cursor = child + continue + } + child, ok := next.(map[string]interface{}) + if !ok { + return fmt.Errorf("json: %q is not an object at segment %d", path, i) + } + cursor = child + } + leaf := keys[len(keys)-1] + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + switch existing := cursor[leaf].(type) { + case nil: + // fresh array + case []interface{}: + for _, v := range existing { + s, ok := v.(string) + if !ok { + return fmt.Errorf("json: %q contains non-string element", path) + } + if _, dup := seen[s]; !dup { + seen[s] = struct{}{} + out = append(out, s) + } + } + default: + return fmt.Errorf("json: %q is not an array", path) + } + for _, v := range values { + if _, dup := seen[v]; !dup { + seen[v] = struct{}{} + out = append(out, v) + } + } + sort.Strings(out) + arr := make([]interface{}, len(out)) + for i, s := range out { + arr[i] = s + } + cursor[leaf] = arr + return nil +} + // AddDep is a convenience for the common case of adding a key under a // "dependencies" or "devDependencies" object. func (d *JSONDoc) AddDep(section, name, version string) error { diff --git a/pkg/dotapi/manifest.go b/pkg/dotapi/manifest.go index b019f0c..7af3c3e 100644 --- a/pkg/dotapi/manifest.go +++ b/pkg/dotapi/manifest.go @@ -49,6 +49,18 @@ type Manifest struct { // The runner starts them, waits ReadyDelay for them to settle, verifies the // process did not crash, then sends SIGTERM. Foreground commands run to // completion and their exit code is checked. +// +// NoCache = true tells the test-flow runner that this command must run on +// every invocation regardless of cache state. The DEFAULT (false) is that +// commands are cacheable — i.e. their outcome is assumed deterministic +// given the same scaffolded inputs, and the case-level cache may skip them +// on a fingerprint match. Set NoCache=true when the command depends on +// state outside the project (unpinned network calls, a dev-server probe +// whose port binding you want re-verified on every run) or when you simply +// aren't sure the outcome is deterministic. +// +// A single NoCache=true command anywhere in the resolved invocation set +// forces the entire case to re-run from scratch. type Command struct { Cmd string WorkDir string @@ -56,6 +68,10 @@ type Command struct { // ReadyDelay is how long to wait before considering a Background command // "ready" (default 3s when zero). ReadyDelay time.Duration + // NoCache opts the command OUT of the test-flow case-level cache. + // Default (false) means the command is cacheable. See the docstring + // above for the full contract. + NoCache bool } // Validator is a named bundle of structural checks the engine runs to verify diff --git a/tools/test-flow/cache.go b/tools/test-flow/cache.go index eddb336..545b6bf 100644 --- a/tools/test-flow/cache.go +++ b/tools/test-flow/cache.go @@ -17,6 +17,8 @@ import ( // ExpectedIDs — optional list of question IDs the engine MUST visit // SkipPostCommands — when true, do not run PostGenerationCommands // SkipTestCommands — when true, do not run TestCommands +// SourcePath — absolute path to the JSON file (set by LoadCases), +// used to fingerprint the case for the test-flow cache. type TestCase struct { Name string `json:"name"` FlowID string `json:"flow_id"` @@ -25,6 +27,8 @@ type TestCase struct { ExpectedIDs []string `json:"expected_visited,omitempty"` SkipPostCommands bool `json:"skip_post_commands,omitempty"` SkipTestCommands bool `json:"skip_test_commands,omitempty"` + + SourcePath string `json:"-"` } // LoadCases reads every *.json file under dir and parses it as a TestCase. @@ -48,6 +52,11 @@ func LoadCases(dir string) ([]*TestCase, error) { if tc.Name == "" { tc.Name = e.Name() } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + tc.SourcePath = abs cases = append(cases, tc) } return cases, nil diff --git a/tools/test-flow/cache_persist.go b/tools/test-flow/cache_persist.go new file mode 100644 index 0000000..a2d637a --- /dev/null +++ b/tools/test-flow/cache_persist.go @@ -0,0 +1,263 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + + "github.com/version14/dot/internal/generator" + "github.com/version14/dot/pkg/dotapi" +) + +// cacheSchemaVersion is bumped whenever the on-disk cache format or the +// fingerprint algorithm changes. Older entries become invalid automatically. +// - v1: initial Cacheable opt-in (default false) +// - v2: flipped polarity to NoCache opt-out (default cacheable) +const cacheSchemaVersion = 2 + +// cacheRoot is where successful case fingerprints are persisted. Kept under +// the repository root so it follows the working copy (and is gitignored). +const cacheRoot = ".test-flow-cache" + +// CacheEntry records the outcome of one successful case run. We only persist +// passing runs — failed runs intentionally leave no trace so the next +// invocation re-tries them. +type CacheEntry struct { + SchemaVersion int `json:"schema_version"` + Fingerprint string `json:"fingerprint"` + CaseName string `json:"case_name"` + FlowID string `json:"flow_id"` + LastSuccessAt string `json:"last_success_at"` + // Generators that contributed to this fingerprint. Stored for human + // inspection only; not used during equality checks. + Generators []string `json:"generators"` +} + +// CacheKeyInputs aggregates everything that must be hashed to produce the +// case fingerprint. The runner fills it as soon as scaffolding has resolved +// the invocation list. +type CacheKeyInputs struct { + CaseFile string // absolute path to the testdata JSON + FlowsDir string // absolute path to the flows/ directory (whole dir is hashed) + Invocations []generator.Invocation // resolved generator list + Manifests []dotapi.Manifest // matches Invocations + SkipPostFlag bool // -skip-post CLI flag + SkipTestFlag bool // -skip-test CLI flag + GeneratorsDir string // absolute path to repo's generators/ dir + RepoRoot string // absolute path to the repo root +} + +// ComputeFingerprint hashes everything that can plausibly change a case's +// behaviour: the testdata file, every involved generator's source tree, the +// flow definition file, the Manifest schema (pkg/dotapi), and the test-flow +// runner itself. CLI flags that change command execution (`-skip-post`, +// `-skip-test`) are folded in too so different modes get different cache +// slots. +func ComputeFingerprint(in CacheKeyInputs) (string, error) { + h := sha256.New() + fmt.Fprintf(h, "schema:%d\n", cacheSchemaVersion) + + caseBytes, err := os.ReadFile(in.CaseFile) + if err != nil { + return "", fmt.Errorf("hash case file: %w", err) + } + fmt.Fprintf(h, "case:%s\n", sha256Bytes(caseBytes)) + + if in.FlowsDir != "" { + if flowsHash, err := hashDir(in.FlowsDir); err == nil { + fmt.Fprintf(h, "flows-dir:%s\n", flowsHash) + } + } + + // Hash every involved generator's source tree. Order by name so the + // fingerprint is stable regardless of resolver output order. + names := make([]string, 0, len(in.Invocations)) + for _, inv := range in.Invocations { + names = append(names, inv.Name) + } + sort.Strings(names) + for _, name := range names { + dir := filepath.Join(in.GeneratorsDir, name) + genHash, err := hashDir(dir) + if err != nil { + // A missing dir means the generator is registered out-of-tree + // (a plugin). Hash an empty marker so different sets stay + // distinguishable. + fmt.Fprintf(h, "gen:%s:absent\n", name) + continue + } + fmt.Fprintf(h, "gen:%s:%s\n", name, genHash) + } + + // pkg/dotapi controls the Manifest schema; touching it reasonably + // invalidates every case. + if dotapiHash, err := hashDir(filepath.Join(in.RepoRoot, "pkg", "dotapi")); err == nil { + fmt.Fprintf(h, "pkg-dotapi:%s\n", dotapiHash) + } + + // The test-flow tool itself can change semantics (cache logic included); + // hashing its source guarantees the cache invalidates on tool edits. + if toolHash, err := hashDir(filepath.Join(in.RepoRoot, "tools", "test-flow")); err == nil { + fmt.Fprintf(h, "tool-test-flow:%s\n", toolHash) + } + + fmt.Fprintf(h, "skip-post:%t\n", in.SkipPostFlag) + fmt.Fprintf(h, "skip-test:%t\n", in.SkipTestFlag) + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// AllCommandsCacheable returns true when no PostGenerationCommand or +// TestCommand across the supplied manifests opted out of caching via +// NoCache. The case-level cache only fires when this is true — a single +// NoCache command (docker compose up, dev-server probe, network call) is +// enough to force the case to re-run. +func AllCommandsCacheable(manifests []dotapi.Manifest) bool { + for _, m := range manifests { + for _, c := range m.PostGenerationCommands { + if c.NoCache { + return false + } + } + for _, c := range m.TestCommands { + if c.NoCache { + return false + } + } + } + return true +} + +// NonCacheableCommands lists the (generator, command) pairs that block the +// cache from short-circuiting the case. Useful for the reporter so the user +// understands why caching did not apply. +func NonCacheableCommands(manifests []dotapi.Manifest) []string { + var blocking []string + for _, m := range manifests { + for _, c := range m.PostGenerationCommands { + if c.NoCache { + blocking = append(blocking, m.Name+" • post • "+c.Cmd) + } + } + for _, c := range m.TestCommands { + if c.NoCache { + blocking = append(blocking, m.Name+" • test • "+c.Cmd) + } + } + } + return blocking +} + +// LoadCacheEntry reads the per-case JSON record. Returns (nil, nil) when the +// cache file is missing — that is not an error. +func LoadCacheEntry(repoRoot, caseName string) (*CacheEntry, error) { + path := cacheFilePath(repoRoot, caseName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read cache %s: %w", path, err) + } + var entry CacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, fmt.Errorf("decode cache %s: %w", path, err) + } + if entry.SchemaVersion != cacheSchemaVersion { + return nil, nil + } + return &entry, nil +} + +// SaveCacheEntry persists a successful run. Failures intentionally never +// write — leaving no entry forces the next invocation to retry. +func SaveCacheEntry(repoRoot string, entry CacheEntry) error { + path := cacheFilePath(repoRoot, entry.CaseName) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func cacheFilePath(repoRoot, caseName string) string { + return filepath.Join(repoRoot, cacheRoot, sanitizeName(caseName)+".json") +} + +func sanitizeName(name string) string { + out := make([]byte, 0, len(name)) + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + out = append(out, c) + default: + out = append(out, '_') + } + } + return string(out) +} + +func sha256Bytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// hashDir produces a content hash that depends on every file under root — +// sorted by path so ordering is deterministic. Symlinks and hidden files +// are included; that's deliberate (test-flow templates can hide anywhere). +func hashDir(root string) (string, error) { + info, err := os.Stat(root) + if err != nil { + return "", err + } + if !info.IsDir() { + // Treat a single file as a one-element directory. + b, err := os.ReadFile(root) + if err != nil { + return "", err + } + return sha256Bytes(b), nil + } + + type fileEntry struct { + path string + hash string + } + var files []fileEntry + + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + b, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + rel, _ := filepath.Rel(root, path) + files = append(files, fileEntry{path: rel, hash: sha256Bytes(b)}) + return nil + }) + if err != nil { + return "", err + } + + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + + h := sha256.New() + for _, f := range files { + fmt.Fprintf(h, "%s\x00%s\n", f.path, f.hash) + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/tools/test-flow/main.go b/tools/test-flow/main.go index 6731af4..ae4c307 100644 --- a/tools/test-flow/main.go +++ b/tools/test-flow/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "path/filepath" "syscall" "github.com/version14/dot/flows" @@ -45,6 +46,8 @@ func main() { skipTest := flag.Bool("skip-test", false, "skip every TestCommand (faster iteration; default: run them)") only := flag.String("only", "", "comma-separated subset of case names to run") keep := flag.Bool("keep", false, "do not delete per-case scratch dirs (so you can inspect outputs)") + noCache := flag.Bool("no-cache", false, "ignore cache hits and re-run every case from scratch (cache entries are still refreshed on success)") + keepGoing := flag.Bool("keep-going", false, "continue running remaining cases after a failure (default: stop at the first failure)") flag.Parse() ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -77,13 +80,26 @@ func main() { rep := NewReporter(len(cases)) results := make([]*Result, 0, len(cases)) + repoRoot, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, "test-flow:", err) + os.Exit(2) + } + repoRoot, _ = filepath.Abs(repoRoot) + opts := caseOptions{ tempDirRoot: *tmpRoot, skipPostCommands: *skipPost, skipTestCommands: *skipTest, keepScratch: *keep, + noCache: *noCache, + repoRoot: repoRoot, } + // Fail-fast by default — stop the loop on the first failing case so + // developers see the failure immediately instead of waiting for the + // remaining cases to finish. Pass -keep-going to run every case. + stopped := false for _, tc := range cases { if tc.Disabled { continue @@ -96,16 +112,50 @@ func main() { rep.Step("flow lookup", false, "", r.Err) rep.CaseEnd(false) results = append(results, r) + if !*keepGoing { + stopped = true + break + } continue } - results = append(results, runOne(ctx, tc, def, rt, rep, opts)) + + caseOpts := opts + caseOpts.caseFile = tc.SourcePath + caseOpts.flowsDir = flowsDir(repoRoot) + + r := runOne(ctx, tc, def, rt, rep, caseOpts) + results = append(results, r) + if !r.Pass() && !*keepGoing { + stopped = true + break + } + } + + if stopped { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "Stopped at first failure (pass -keep-going to run every case).") } - if Summarize(os.Stdout, results) > 0 { + totalCases := 0 + for _, c := range cases { + if !c.Disabled { + totalCases++ + } + } + if Summarize(os.Stdout, results, totalCases) > 0 { os.Exit(1) } } +// flowsDir returns the absolute path to the flows/ directory. The cache +// fingerprint hashes the whole directory so any edit to a flow definition +// invalidates every case (that's the desired behaviour: it's hard to tell +// from a flow ID alone which Go file produced it, and over-invalidation is +// safer than missing a relevant change). +func flowsDir(repoRoot string) string { + return filepath.Join(repoRoot, "flows") +} + // filterCases narrows cases to those whose Name appears in the comma-separated // only string. Empty only returns cases unchanged. func filterCases(cases []*TestCase, only string) []*TestCase { diff --git a/tools/test-flow/reporter.go b/tools/test-flow/reporter.go index 94edaf3..712fc3a 100644 --- a/tools/test-flow/reporter.go +++ b/tools/test-flow/reporter.go @@ -100,7 +100,11 @@ func (r *StepReporter) CaseEnd(pass bool) { } // Summarize prints the bottom-line tally and returns the failure count. -func Summarize(w io.Writer, results []*Result) int { +// +// total is the number of cases the runner intended to run (i.e. after +// disabled / -only filtering). When fail-fast stops the loop, len(results) +// is smaller than total and the summary makes that distinction visible. +func Summarize(w io.Writer, results []*Result, total int) int { failed := 0 for _, r := range results { if !r.Pass() { @@ -108,13 +112,19 @@ func Summarize(w io.Writer, results []*Result) int { } } fmt.Fprintln(w) - if failed == 0 { + if failed == 0 && len(results) == total { + fmt.Fprintln(w, titleStyle.Render( + fmt.Sprintf("✓ All %d cases passed", total), + )) + } else if failed == 0 { + // Loop exited early but everything that ran passed (e.g. ctx + // cancelled). Report what actually executed. fmt.Fprintln(w, titleStyle.Render( - fmt.Sprintf("✓ All %d cases passed", len(results)), + fmt.Sprintf("✓ %d/%d cases passed (loop ended early)", len(results), total), )) } else { fmt.Fprintln(w, failStyle.Render( - fmt.Sprintf("✗ %d/%d cases failed", failed, len(results)), + fmt.Sprintf("✗ %d/%d cases failed (%d not run)", failed, total, total-len(results)), )) for _, r := range results { if r.Pass() { diff --git a/tools/test-flow/runner.go b/tools/test-flow/runner.go index fec74f0..18f51f7 100644 --- a/tools/test-flow/runner.go +++ b/tools/test-flow/runner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "time" "github.com/version14/dot/flows" @@ -14,6 +15,15 @@ import ( "github.com/version14/dot/pkg/dotapi" ) +// invocationNames returns just the names from a slice of Invocations, in order. +func invocationNames(invs []generator.Invocation) []string { + out := make([]string, len(invs)) + for i, inv := range invs { + out[i] = inv.Name + } + return out +} + // scriptedAdapter answers each question from a recorded map. // // LoopQuestion handling: when the scripted answer for a loop is a JSON array @@ -133,6 +143,10 @@ type caseOptions struct { skipPostCommands bool // skip PostGenerationCommands globally skipTestCommands bool // skip TestCommands globally keepScratch bool // when true, do NOT delete the scratch dir on exit + noCache bool // when true, ignore cache hits and refresh entries + caseFile string // absolute path to the testdata JSON for this case + repoRoot string // absolute path to the dot repo root + flowsDir string // absolute path to the flows/ directory } // runOne drives one TestCase through the full pipeline: @@ -219,8 +233,42 @@ func runOne( rep.Step("validators", true, fmt.Sprintf("%d passed", countChecks(res.Manifests)), nil) } - // Step 3: post-generation commands. - if !opts.skipPostCommands && !tc.SkipPostCommands { + // Step 2.5: case-level cache check. Skips post-gen + test commands when + // the fingerprint matches a previous successful run AND every command + // across the involved manifests is opted-in via Cacheable=true. + cacheHit := false + fingerprint, fpErr := ComputeFingerprint(CacheKeyInputs{ + CaseFile: opts.caseFile, + FlowsDir: opts.flowsDir, + Invocations: res.Invocations, + Manifests: res.Manifests, + SkipPostFlag: opts.skipPostCommands || tc.SkipPostCommands, + SkipTestFlag: opts.skipTestCommands || tc.SkipTestCommands, + GeneratorsDir: filepath.Join(opts.repoRoot, "generators"), + RepoRoot: opts.repoRoot, + }) + + if fpErr != nil { + rep.Step("cache fingerprint", false, "", fpErr) + } else if !opts.noCache { + entry, err := LoadCacheEntry(opts.repoRoot, tc.Name) + if err != nil { + rep.Step("cache load", false, "", err) + } + if entry != nil && entry.Fingerprint == fingerprint && AllCommandsCacheable(res.Manifests) { + rep.Step("cache", true, "HIT — skipping post-gen + test commands", nil) + cacheHit = true + } else if entry != nil && entry.Fingerprint == fingerprint && !AllCommandsCacheable(res.Manifests) { + blocking := NonCacheableCommands(res.Manifests) + detail := fmt.Sprintf("%d non-cacheable command(s) — running anyway", len(blocking)) + rep.Step("cache", true, detail, nil) + } + } + + // Step 3: post-generation commands (skipped on cache hit). + if cacheHit { + // Cache hit short-circuits both post-gen and test commands. + } else if !opts.skipPostCommands && !tc.SkipPostCommands { postPlan := cli.PlanPostGenCommands(res.Spec, res.Manifests) if len(postPlan) > 0 { rep.Substep("post-gen commands", len(postPlan)) @@ -233,7 +281,9 @@ func runOne( } // Step 4: test commands (incl. background dev servers). - if !opts.skipTestCommands && !tc.SkipTestCommands { + if cacheHit { + // see above + } else if !opts.skipTestCommands && !tc.SkipTestCommands { testPlan := cli.PlanTestCommands(res.Spec, res.Manifests) if len(testPlan) > 0 { rep.Substep("test commands", len(testPlan)) @@ -245,6 +295,22 @@ func runOne( rep.Step("test commands", true, "skipped", nil) } + // Persist a fresh cache entry on full success. Failed runs intentionally + // leave no trace so the next invocation retries them. + if r.Pass() && fingerprint != "" && AllCommandsCacheable(res.Manifests) { + entry := CacheEntry{ + SchemaVersion: cacheSchemaVersion, + Fingerprint: fingerprint, + CaseName: tc.Name, + FlowID: tc.FlowID, + LastSuccessAt: time.Now().UTC().Format(time.RFC3339), + Generators: invocationNames(res.Invocations), + } + if err := SaveCacheEntry(opts.repoRoot, entry); err != nil { + rep.Step("cache save", false, "", err) + } + } + rep.CaseEnd(r.Pass()) return r } diff --git a/tools/test-flow/testdata/202504300008_express_mvc_biome_postgres_drizzle_jwt.json b/tools/test-flow/testdata/202504300008_express_mvc_biome_postgres_drizzle_jwt.json index 8c63557..8b2da38 100644 --- a/tools/test-flow/testdata/202504300008_express_mvc_biome_postgres_drizzle_jwt.json +++ b/tools/test-flow/testdata/202504300008_express_mvc_biome_postgres_drizzle_jwt.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202505010001_express_clean_arch_biome_postgres_drizzle_jwt.json b/tools/test-flow/testdata/202505010001_express_clean_arch_biome_postgres_drizzle_jwt.json index 5e33b9a..0b0a577 100644 --- a/tools/test-flow/testdata/202505010001_express_clean_arch_biome_postgres_drizzle_jwt.json +++ b/tools/test-flow/testdata/202505010001_express_clean_arch_biome_postgres_drizzle_jwt.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202505010002_express_mvc_biome_no_db_no_auth.json b/tools/test-flow/testdata/202505010002_express_mvc_biome_no_db_no_auth.json index b0a78c7..cb0b0c1 100644 --- a/tools/test-flow/testdata/202505010002_express_mvc_biome_no_db_no_auth.json +++ b/tools/test-flow/testdata/202505010002_express_mvc_biome_no_db_no_auth.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": false, @@ -18,6 +19,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -25,4 +27,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202505010003_express_clean_arch_biome_no_db_jwt.json b/tools/test-flow/testdata/202505010003_express_clean_arch_biome_no_db_jwt.json index 9e9035c..10038d3 100644 --- a/tools/test-flow/testdata/202505010003_express_clean_arch_biome_no_db_jwt.json +++ b/tools/test-flow/testdata/202505010003_express_clean_arch_biome_no_db_jwt.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": false, @@ -18,6 +19,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -25,4 +27,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604282342_backend_clean_architecture.json b/tools/test-flow/testdata/202604282342_backend_clean_architecture.json index 9124625..6f6615a 100644 --- a/tools/test-flow/testdata/202604282342_backend_clean_architecture.json +++ b/tools/test-flow/testdata/202604282342_backend_clean_architecture.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": false, @@ -18,6 +19,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -25,4 +27,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604282353_backend_mvc_architecture.json b/tools/test-flow/testdata/202604282353_backend_mvc_architecture.json index b0df86b..38bf27a 100644 --- a/tools/test-flow/testdata/202604282353_backend_mvc_architecture.json +++ b/tools/test-flow/testdata/202604282353_backend_mvc_architecture.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": false, @@ -18,6 +19,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -25,4 +27,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300001_express_clean_arch_prettier.json b/tools/test-flow/testdata/202604300001_express_clean_arch_prettier.json index ef76a8e..a9253b4 100644 --- a/tools/test-flow/testdata/202604300001_express_clean_arch_prettier.json +++ b/tools/test-flow/testdata/202604300001_express_clean_arch_prettier.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "prettier", "ts-backend-linter": "prettier", "enable-db": false, @@ -18,6 +19,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -25,4 +27,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300002_express_mvc_postgres_drizzle.json b/tools/test-flow/testdata/202604300002_express_mvc_postgres_drizzle.json index 9ad604b..53cd47c 100644 --- a/tools/test-flow/testdata/202604300002_express_mvc_postgres_drizzle.json +++ b/tools/test-flow/testdata/202604300002_express_mvc_postgres_drizzle.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": true, @@ -21,6 +22,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -31,4 +33,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300003_express_clean_arch_postgres_drizzle_better_auth.json b/tools/test-flow/testdata/202604300003_express_clean_arch_postgres_drizzle_better_auth.json index 428e543..2c97133 100644 --- a/tools/test-flow/testdata/202604300003_express_clean_arch_postgres_drizzle_better_auth.json +++ b/tools/test-flow/testdata/202604300003_express_clean_arch_postgres_drizzle_better_auth.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300004_express_mvc_no_db_jwt.json b/tools/test-flow/testdata/202604300004_express_mvc_no_db_jwt.json index 7085713..b4d344d 100644 --- a/tools/test-flow/testdata/202604300004_express_mvc_no_db_jwt.json +++ b/tools/test-flow/testdata/202604300004_express_mvc_no_db_jwt.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "prettier", "ts-backend-linter": "prettier", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300005_express_clean_arch_prettier_postgres_drizzle_jwt.json b/tools/test-flow/testdata/202604300005_express_clean_arch_prettier_postgres_drizzle_jwt.json index 809f5ae..ec9a279 100644 --- a/tools/test-flow/testdata/202604300005_express_clean_arch_prettier_postgres_drizzle_jwt.json +++ b/tools/test-flow/testdata/202604300005_express_clean_arch_prettier_postgres_drizzle_jwt.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "prettier", "ts-backend-linter": "prettier", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300006_express_mvc_prettier_postgres_drizzle_better_auth.json b/tools/test-flow/testdata/202604300006_express_mvc_prettier_postgres_drizzle_better_auth.json index a3f0a32..b1fbbee 100644 --- a/tools/test-flow/testdata/202604300006_express_mvc_prettier_postgres_drizzle_better_auth.json +++ b/tools/test-flow/testdata/202604300006_express_mvc_prettier_postgres_drizzle_better_auth.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "prettier", "ts-backend-linter": "prettier", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202604300007_express_clean_arch_no_db_better_auth_auto_db.json b/tools/test-flow/testdata/202604300007_express_clean_arch_no_db_better_auth_auto_db.json index 9307888..c0ab751 100644 --- a/tools/test-flow/testdata/202604300007_express_clean_arch_no_db_better_auth_auto_db.json +++ b/tools/test-flow/testdata/202604300007_express_clean_arch_no_db_better_auth_auto_db.json @@ -7,6 +7,7 @@ "stack": "typescript", "ts-backend-framework": "express", "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": false, "ts-backend-formatter": "biome", "ts-backend-linter": "biome", "enable-db": true, @@ -22,6 +23,7 @@ "stack", "ts-backend-framework", "ts-backend-architecture", + "ts-backend-decorators-validation", "ts-backend-formatter", "ts-backend-linter", "enable-db", @@ -33,4 +35,4 @@ ], "skip_post_commands": false, "skip_test_commands": false -} +} \ No newline at end of file diff --git a/tools/test-flow/testdata/202605070101_express_clean_arch_decorators_zod.json b/tools/test-flow/testdata/202605070101_express_clean_arch_decorators_zod.json new file mode 100644 index 0000000..1d3677c --- /dev/null +++ b/tools/test-flow/testdata/202605070101_express_clean_arch_decorators_zod.json @@ -0,0 +1,32 @@ +{ + "name": "express_clean_arch_decorators_zod", + "flow_id": "init", + "answers": { + "project_name": "my-app", + "monorepo_type": "single", + "stack": "typescript", + "ts-backend-framework": "express", + "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": true, + "ts-backend-validation-lib": "zod", + "ts-backend-formatter": "prettier", + "ts-backend-linter": "prettier", + "enable-db": false, + "confirm-generate": true + }, + "expected_visited": [ + "project_name", + "monorepo_type", + "stack", + "ts-backend-framework", + "ts-backend-architecture", + "ts-backend-decorators-validation", + "ts-backend-validation-lib", + "ts-backend-formatter", + "ts-backend-linter", + "enable-db", + "confirm-generate" + ], + "skip_post_commands": false, + "skip_test_commands": false +} diff --git a/tools/test-flow/testdata/202605070102_express_mvc_decorators_zod.json b/tools/test-flow/testdata/202605070102_express_mvc_decorators_zod.json new file mode 100644 index 0000000..2ca63d8 --- /dev/null +++ b/tools/test-flow/testdata/202605070102_express_mvc_decorators_zod.json @@ -0,0 +1,32 @@ +{ + "name": "express_mvc_decorators_zod", + "flow_id": "init", + "answers": { + "project_name": "my-app", + "monorepo_type": "single", + "stack": "typescript", + "ts-backend-framework": "express", + "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": true, + "ts-backend-validation-lib": "zod", + "ts-backend-formatter": "biome", + "ts-backend-linter": "biome", + "enable-db": false, + "confirm-generate": true + }, + "expected_visited": [ + "project_name", + "monorepo_type", + "stack", + "ts-backend-framework", + "ts-backend-architecture", + "ts-backend-decorators-validation", + "ts-backend-validation-lib", + "ts-backend-formatter", + "ts-backend-linter", + "enable-db", + "confirm-generate" + ], + "skip_post_commands": false, + "skip_test_commands": false +} diff --git a/tools/test-flow/testdata/202605070103_express_clean_arch_decorators_postgres_jwt.json b/tools/test-flow/testdata/202605070103_express_clean_arch_decorators_postgres_jwt.json new file mode 100644 index 0000000..95ee41f --- /dev/null +++ b/tools/test-flow/testdata/202605070103_express_clean_arch_decorators_postgres_jwt.json @@ -0,0 +1,40 @@ +{ + "name": "express_clean_arch_decorators_postgres_jwt", + "flow_id": "init", + "answers": { + "project_name": "my-app", + "monorepo_type": "single", + "stack": "typescript", + "ts-backend-framework": "express", + "ts-backend-architecture": "clean-architecture", + "ts-backend-decorators-validation": true, + "ts-backend-validation-lib": "zod", + "ts-backend-formatter": "prettier", + "ts-backend-linter": "prettier", + "enable-db": true, + "ts-backend-db-type": "postgres", + "ts-backend-orm": "drizzle", + "enable-auth": true, + "ts-backend-auth-method": "jwt", + "confirm-generate": true + }, + "expected_visited": [ + "project_name", + "monorepo_type", + "stack", + "ts-backend-framework", + "ts-backend-architecture", + "ts-backend-decorators-validation", + "ts-backend-validation-lib", + "ts-backend-formatter", + "ts-backend-linter", + "enable-db", + "ts-backend-db-type", + "ts-backend-orm", + "enable-auth", + "ts-backend-auth-method", + "confirm-generate" + ], + "skip_post_commands": false, + "skip_test_commands": false +} diff --git a/tools/test-flow/testdata/202605070104_express_mvc_decorators_postgres_jwt.json b/tools/test-flow/testdata/202605070104_express_mvc_decorators_postgres_jwt.json new file mode 100644 index 0000000..3cffe33 --- /dev/null +++ b/tools/test-flow/testdata/202605070104_express_mvc_decorators_postgres_jwt.json @@ -0,0 +1,40 @@ +{ + "name": "express_mvc_decorators_postgres_jwt", + "flow_id": "init", + "answers": { + "project_name": "my-app", + "monorepo_type": "single", + "stack": "typescript", + "ts-backend-framework": "express", + "ts-backend-architecture": "mvc-architecture", + "ts-backend-decorators-validation": true, + "ts-backend-validation-lib": "zod", + "ts-backend-formatter": "biome", + "ts-backend-linter": "biome", + "enable-db": true, + "ts-backend-db-type": "postgres", + "ts-backend-orm": "drizzle", + "enable-auth": true, + "ts-backend-auth-method": "jwt", + "confirm-generate": true + }, + "expected_visited": [ + "project_name", + "monorepo_type", + "stack", + "ts-backend-framework", + "ts-backend-architecture", + "ts-backend-decorators-validation", + "ts-backend-validation-lib", + "ts-backend-formatter", + "ts-backend-linter", + "enable-db", + "ts-backend-db-type", + "ts-backend-orm", + "enable-auth", + "ts-backend-auth-method", + "confirm-generate" + ], + "skip_post_commands": false, + "skip_test_commands": false +}