import { Controller, Get, Param, Res } from "@nestjs/common"; import * as fs from "node:fs"; import * as path from "node:path"; import { createRequire } from "node:module"; import type { Response } from "express"; import { parse as parseYaml } from "yaml"; const req = createRequire(process.cwd()); /** * Public API documentation surface (TASK-0127): * * GET /api/v1/docs — interactive swagger-ui (the OFFICIAL * swagger-ui-dist bundle served from * node_modules — no CDN, works offline). * The OpenAPI spec is parsed server-side * (YAML → JSON) and inlined into the page. * GET /api/v1/docs/openapi.yaml — the raw spec (the "Download YAML" link). * GET /api/v1/docs/assets/:file — the vendored swagger-ui static files * (allow-listed: css/js/favicon only). * * The CANONICAL spec file is `docs/api/openapi.yaml` (hand-maintained; the * bidirectional contract test in `tests/integration/api-contract/` keeps it * honest — every spec path must have a live route and vice versa). * * The surface is PUBLIC by design (it serves documentation, not data — no * live API calls from the UI; see docs/security/security-architecture.md). * The swagger-ui bundle is loaded from node_modules at request time (the * API is a modular monolith without a build step — `createRequire` resolves * the package exactly as the runtime would). */ /** * Repo root (same pattern as database-ai.service.ts): `process.cwd()` first * (the API boots from the repo root in dev and the test harness), a * `__dirname` walk under the CJS compile, then a cwd walk-up (vitest cwd = a * subdir). Verified by the spec file's existence where possible. */ function repoRoot(): string { if (fs.existsSync(path.join(process.cwd(), "docs", "api", "openapi.yaml"))) return process.cwd(); if (typeof __dirname !== "undefined") { // apps/api/src/modules/docs → repo root is four levels up. const up = path.resolve(__dirname, "..", "..", "..", ".."); if (fs.existsSync(path.join(up, "docs", "api", "openapi.yaml"))) return up; } let dir = process.cwd(); for (let i = 0; i < 6; i++) { const parent = path.dirname(dir); if (parent === dir) break; dir = parent; if (fs.existsSync(path.join(dir, "docs", "api", "openapi.yaml"))) return dir; } return process.cwd(); } function specPath(): string { return path.join(repoRoot(), "docs", "api", "openapi.yaml"); } /** swagger-ui-dist static files allowed at /docs/assets/ (no traversal). */ const ASSET_WHITELIST: Record = { "swagger-ui.css": "text/css; charset=utf-8", "index.css": "text/css; charset=utf-8", "swagger-ui-bundle.js": "application/javascript; charset=utf-8", "swagger-ui-standalone-preset.js": "application/javascript; charset=utf-8", "swagger-initializer.js": "application/javascript; charset=utf-8", "favicon-16x16.png": "image/png", "favicon-32x32.png": "image/png" }; let swaggerDistDir: string | null = null; function distDir(): string { if (!swaggerDistDir) { const entry = req.resolve("swagger-ui-dist"); swaggerDistDir = path.dirname(entry); } return swaggerDistDir; } function readSpecYaml(): string { return fs.readFileSync(specPath(), "utf8"); } @Controller("docs") export class DocsController { /** Interactive docs — swagger-ui with the inlined (parsed) spec. */ @Get() index(@Res() res: Response): void { const specJson = JSON.stringify(parseYaml(readSpecYaml())).replace(/ AI Software Factory — API v1
`); } /** The raw OpenAPI spec (the download link in the UI). */ @Get("openapi.yaml") spec(@Res() res: Response): void { res.setHeader("Content-Type", "application/yaml; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.send(readSpecYaml()); } /** Vendored swagger-ui static assets (allow-listed by name). */ @Get("assets/:file") asset(@Param("file") file: string, @Res() res: Response): void { const contentType = ASSET_WHITELIST[file]; if (!contentType) { res.status(404).send("Not found"); return; } const full = path.join(distDir(), file); if (!fs.existsSync(full)) { res.status(404).send("Not found"); return; } res.setHeader("Content-Type", contentType); res.setHeader("Cache-Control", "public, max-age=86400"); res.send(fs.readFileSync(full)); } }