/** * uat-plan/discover-endpoints.ts — Parse generated controllers into the API axis. * * PURE parse (parseController) + a thin I/O wrapper (discoverEndpoints) that reads * every `*Controller.cs` under the API project. The structural contract (verified * against SmartStack.app + the scaffold-controller/screen-controller templates): * - class-level [Route("api/...")] (literal) and/or [NavRoute("a.b")] * - per action [HttpGet] / [HttpPost("{id:guid}")] / [HttpDelete("{id:guid}")] * - per action [RequirePermission()] (on the line(s) BELOW the verb) * * The permission is a C# CONSTANT reference (e.g. `Permissions.Admin.Users.View`), * not a dot-path string — we capture it verbatim as `permissionExpr` and let * discover.ts resolve it to a permission path against the live permission set. */ import { readText } from '../../../lib/fs.js'; import { findControllerFiles } from '../../../lib/detector.js'; import { extractNavRoutes } from '../../../lib/navroute-parser.js'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export interface ParsedEndpoint { controller: string; method: HttpMethod; /** Full API route, e.g. "/api/administration/users" or ".../users/{id}". */ route: string; /** Controller [NavRoute("a.b")] (or inferred from [Route]) — ties to a nav path. */ navRoute?: string; /** Raw [RequirePermission()] argument list, verbatim (commas included). */ permissionExpr?: string; /** * Every argument of [RequirePermission(...)], split on commas — the platform * applies ANY semantics to a multi-arg gate. Single-gated endpoints have one * entry; the v3.62 `/lookup` dual gate yields two, the FIRST being the * minimal grant (`….Lookup`). */ permissionExprs?: string[]; /** Success status from [ProducesResponseType(..., StatusCodes.Status2xx)] (default 200). */ okStatus?: number; /** Action carries [AllowAnonymous]. */ allowAnonymous?: boolean; } const VERB_RE = /\[Http(Get|Post|Put|Patch|Delete)(?:\(\s*"([^"]*)"\s*\))?\]/; const REQUIRE_PERM_RE = /\[RequirePermission\(\s*([^)]+?)\s*\)\]/; /** A C# method signature line: `public Name(` (not a property/ctor body). */ const METHOD_SIG_RE = /\bpublic\b[^;{]*\b\w+\s*\(/; /** The success status declared by a `[ProducesResponseType(..., StatusCodes.Status2xx)]`. */ const PRODUCES_2XX_RE = /StatusCodes\.Status(2\d\d)/; /** Drop EF/MVC route constraints: `{id:guid}` → `{id}`. */ function stripRouteConstraints(t: string): string { return t.replace(/\{(\w+):[^}]+\}/g, '{$1}'); } /** Resolve the controller's base API path the way the RUNTIME serves it. The platform * rewrites integration controllers from [NavRoute] → /api/{module}/{section} and * discards any [Route], so [NavRoute] WINS. (Preferring [Route] here was the same * blindness that let the /api/v1/integration mismatch through audit-dev-wire.) * NOTE: `navroute-parser` already INFERS a navRoute from any `[Route("api/…")]` * (screen controllers → `screens.{plural}`), so the classRoute branch below only * fires for the rare `[Route]` not under `api/` — kept as a defensive fallback. */ function resolveBase(classRoute: string | undefined, navRoute: string | undefined): string { if (navRoute) return `/api/${navRoute.replace(/\./g, '/')}`; if (classRoute && !classRoute.includes('[')) { return `/${classRoute.replace(/^\/+/, '').replace(/\/+$/, '')}`; } return '/api'; } /** Append a method template to the base route. */ function joinRoute(base: string, template: string | undefined): string { if (!template) return base; return `${base}/${stripRouteConstraints(template).replace(/^\/+/, '')}`; } /** * Parse one controller file's content into its endpoints. PURE. * * For each method signature we gather the CONTIGUOUS attribute block immediately * above it (skipping blank/comment lines) and read verb/route, permission, success * status and the anonymous flag from that block — order-independent, so it is * robust to whether [RequirePermission]/[ProducesResponseType]/[AllowAnonymous] * sit above or below the [Http*] verb. */ export function parseController(content: string): ParsedEndpoint[] { const controller = content.match(/class\s+(\w+Controller)\b/)?.[1] ?? 'UnknownController'; const navRoute = extractNavRoutes(content)[0]?.navRoute; const classRoute = content.match(/\[Route\(\s*"([^"]+)"\s*\)\]/)?.[1]; const base = resolveBase(classRoute, navRoute); const lines = content.split(/\r?\n/); const out: ParsedEndpoint[] = []; for (let i = 0; i < lines.length; i++) { if (!METHOD_SIG_RE.test(lines[i])) continue; // Walk up to gather the action's attribute block; stop at the first line that // is neither an attribute nor a blank/comment (a brace, the class decl, or the // previous method's body). const lines_above: string[] = []; for (let k = i - 1; k >= 0; k--) { const t = lines[k].trim(); if (t.startsWith('[')) lines_above.push(t); else if (t === '' || t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) continue; else break; } const attrs = lines_above.join('\n'); const verb = attrs.match(VERB_RE); if (!verb) continue; // not an HTTP action (ctor, helper, …) const endpoint: ParsedEndpoint = { controller, method: verb[1].toUpperCase() as HttpMethod, route: joinRoute(base, verb[2]), }; if (navRoute) endpoint.navRoute = navRoute; const perm = attrs.match(REQUIRE_PERM_RE); if (perm) { endpoint.permissionExpr = perm[1].trim(); endpoint.permissionExprs = perm[1] .split(',') .map((s) => s.trim()) .filter(Boolean); } const ok = attrs.match(PRODUCES_2XX_RE); if (ok) endpoint.okStatus = parseInt(ok[1], 10); if (/\[AllowAnonymous\]/.test(attrs)) endpoint.allowAnonymous = true; out.push(endpoint); } return out; } /** Read + parse every controller under the API project. */ export async function discoverEndpoints(apiDir: string): Promise { const files = await findControllerFiles(apiDir); const out: ParsedEndpoint[] = []; for (const file of files) { out.push(...parseController(await readText(file))); } return out; }