mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-29 06:46:49 +08:00
fix(nestjs): routes carry the app's global prefix and URI version (#2117)
`app.setGlobalPrefix('api')` and `app.enableVersioning({ type:
VersioningType.URI, defaultVersion: '1' })` put every controller under
`/api/v1`, but routes were named by the controller path alone (`GET /user`).
A front end's `this.http.get('/api/v1/user')` then linked to nothing:
Ghostfolio's Angular client had no cross-tier edge into its own NestJS API.
postExtract now reads each `NestFactory.create` bootstrap and renames the
routes of controllers in that app, in NestJS's order:
`/{prefix unless excluded}/{v}{version}/{RouterModule prefix}/{path}`.
- `exclude` paths (plain, `{ path, method }`, `{/*wildcard}`, `(.*)`, `*`)
skip the prefix and keep their version, as NestJS does.
- A route's version: its own `@Version` (found past decorators with object
arguments), else the class's `@Version` or `@Controller({ version })`,
else `defaultVersion`. `VERSION_NEUTRAL` has none; header/media-type
versioning adds no segment.
- A computed prefix (`config.get(...)`) is not guessed.
- Each app uses its own bootstrap (the longest root above it). A seed script
that creates the same app without serving it doesn't displace the one that
configures it, and an app with no config doesn't borrow another app's.
- Only routes whose decorator sits in a `@Controller` are touched, so an
Express route in the same repo keeps its name.
Ghostfolio: 0 → 103 cross-tier edges (82 of 85 client call sites), no
mismatches. koel, proshop, IceCubes and nest-samples are byte-identical to main.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c3efbbf4e7
commit
e845b1cd10
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading.
|
||||
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
|
||||
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
|
||||
- In an Angular app, a method a template calls, like `(click)="toggleFavorite()"` or `(ngSubmit)="submitForm()"`, is now linked from its component. These methods used to show no callers at all, so impact and callers questions stopped at them, and dead-code checks could list them as unused. Re-index Angular projects after upgrading.
|
||||
|
||||
@@ -322,7 +322,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
|
||||
| **Flask** | `@app.route('/path', methods=[...])`, blueprint routes |
|
||||
| **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods |
|
||||
| **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
|
||||
| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
|
||||
| **NestJS** | `@Controller` + `@Get/@Post/...` (with `RouterModule` prefixes, `setGlobalPrefix` and URI versioning), GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
|
||||
| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
|
||||
| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` |
|
||||
| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* A NestJS route is named by the path a request takes to it — the app's
|
||||
* global prefix and URI version included.
|
||||
*
|
||||
* `app.setGlobalPrefix('api')` and `app.enableVersioning({ type:
|
||||
* VersioningType.URI, defaultVersion: '1' })` in `main.ts` put every
|
||||
* controller under `/api/v1`, but the routes were named `GET /user`. A front
|
||||
* end's `this.http.get('/api/v1/user')` then connected to nothing: Ghostfolio's
|
||||
* Angular client had no link to its own NestJS API.
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
const projects: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function project(files: Record<string, string>): Promise<CodeGraph> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nest-prefix-'));
|
||||
projects.push(root);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
return CodeGraph.init(root, { index: true });
|
||||
}
|
||||
|
||||
const MAIN = `import { NestFactory } from '@nestjs/core';
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import { AppModule } from './app/app.module';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableVersioning({ defaultVersion: '1', type: VersioningType.URI });
|
||||
app.setGlobalPrefix('api', { exclude: ['sitemap.xml', 'health{/*wildcard}'] });
|
||||
await app.listen(3333);
|
||||
}
|
||||
bootstrap();
|
||||
`;
|
||||
|
||||
describe('NestJS: the global prefix and URI versioning', () => {
|
||||
it('names each route under the prefix and its version — or neither, where the app says so', async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({ dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0' } }),
|
||||
'apps/api/src/main.ts': MAIN,
|
||||
// A seed script creates the same app without serving it — and is read first.
|
||||
'apps/api/src/database/run-seed.ts': `import { NestFactory } from '@nestjs/core';
|
||||
const runSeed = async () => {
|
||||
const app = await NestFactory.create(SeedModule);
|
||||
await app.close();
|
||||
};
|
||||
void runSeed();
|
||||
`,
|
||||
'apps/api/src/app/user.controller.ts': `import { Controller, Get, Post, Version, VERSION_NEUTRAL } from '@nestjs/common';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
@Get()
|
||||
public getUser() {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Version('2')
|
||||
public createUser() {}
|
||||
|
||||
@Get('oidc/callback')
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public callback() {}
|
||||
}
|
||||
`,
|
||||
'apps/api/src/app/sitemap.controller.ts': `import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common';
|
||||
@Controller()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
export class SitemapController {
|
||||
@Get('sitemap.xml')
|
||||
public sitemap() {}
|
||||
}
|
||||
`,
|
||||
'apps/api/src/app/health.controller.ts': `import { Controller, Get } from '@nestjs/common';
|
||||
@Controller({ path: 'health', version: '3' })
|
||||
export class HealthController {
|
||||
@Get('db')
|
||||
public db() {}
|
||||
}
|
||||
`,
|
||||
// A second app that sets nothing: its routes stay as written.
|
||||
'apps/jobs/src/main.ts': `import { NestFactory } from '@nestjs/core';
|
||||
NestFactory.create(JobsModule).then((app) => app.listen(3335));
|
||||
`,
|
||||
'apps/jobs/src/jobs.controller.ts': `import { Controller, Get } from '@nestjs/common';
|
||||
@Controller('jobs')
|
||||
export class JobsController {
|
||||
@Get()
|
||||
public list() {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
cg
|
||||
.getNodesByKind('route')
|
||||
.map((r) => r.name)
|
||||
.sort()
|
||||
).toEqual([
|
||||
// VERSION_NEUTRAL: prefixed, not versioned.
|
||||
'GET /api/user/oidc/callback',
|
||||
'GET /api/v1/user',
|
||||
'GET /jobs',
|
||||
'GET /sitemap.xml',
|
||||
// Excluded from the prefix, still versioned by its controller.
|
||||
'GET /v3/health/db',
|
||||
'POST /api/v2/user',
|
||||
]);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a version past decorators with object arguments, and each app its own bootstrap', async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({ dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0' } }),
|
||||
'apps/api/src/main.ts': MAIN,
|
||||
'apps/api/src/app/stats.controller.ts': `import { Controller, Get, Version } from '@nestjs/common';
|
||||
@Controller('stats')
|
||||
export class StatsController {
|
||||
@Version('2')
|
||||
@ApiResponse({ status: 200, description: 'The stats' })
|
||||
@Get('daily')
|
||||
public daily() {}
|
||||
|
||||
@Get('weekly')
|
||||
public weekly() {}
|
||||
}
|
||||
`,
|
||||
'apps/api/src/app/reports.controller.ts': `import { Controller, Get, Version } from '@nestjs/common';
|
||||
@Version('4')
|
||||
@ApiTags({ name: 'reports' })
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
@Get()
|
||||
public list() {}
|
||||
}
|
||||
`,
|
||||
// A second app whose prefix is read from config: no prefix is claimed for
|
||||
// it, and the other app's `/api` is not borrowed.
|
||||
'apps/admin/src/main.ts': `import { NestFactory } from '@nestjs/core';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AdminModule);
|
||||
app.setGlobalPrefix(config.get('app.prefix'));
|
||||
app.enableVersioning({ defaultVersion: '1' });
|
||||
await app.listen(3334);
|
||||
}
|
||||
bootstrap();
|
||||
`,
|
||||
'apps/admin/src/users.controller.ts': `import { Controller, Get } from '@nestjs/common';
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get()
|
||||
public list() {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
cg
|
||||
.getNodesByKind('route')
|
||||
.map((r) => r.name)
|
||||
.sort()
|
||||
).toEqual(['GET /api/v1/stats/weekly', 'GET /api/v2/stats/daily', 'GET /api/v4/reports', 'GET /v1/users']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("connects a front end's request to the route it reaches, and leaves an Express route in the same repo alone", async () => {
|
||||
const cg = await project({
|
||||
'package.json': JSON.stringify({
|
||||
dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0', '@angular/core': '^19.0.0', express: '^4.0.0' },
|
||||
}),
|
||||
'apps/api/src/main.ts': MAIN,
|
||||
'apps/api/src/app/user.controller.ts': `import { Controller, Get } from '@nestjs/common';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
@Get()
|
||||
public getUser() {}
|
||||
}
|
||||
`,
|
||||
'apps/client/src/app/user.service.ts': `import { HttpClient } from '@angular/common/http';
|
||||
export class UserService {
|
||||
constructor(private http: HttpClient) {}
|
||||
public fetchUser() {
|
||||
return this.http.get<User>('/api/v1/user');
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tools/mock-server/src/server.ts': `import express from 'express';
|
||||
const app = express();
|
||||
app.get('/status', (req, res) => res.json({ ok: true }));
|
||||
`,
|
||||
});
|
||||
try {
|
||||
const names = cg.getNodesByKind('route').map((r) => r.name).sort();
|
||||
expect(names).toContain('GET /api/v1/user');
|
||||
expect(names).toContain('GET /status');
|
||||
const fetchUser = cg.getNodesByName('fetchUser')[0]!;
|
||||
const reached = cg
|
||||
.getOutgoingEdgesFrom([fetchUser.id], ['calls'])
|
||||
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.channel === 'http')
|
||||
.map((e) => cg.getNode(e.target)?.name);
|
||||
expect(reached).toEqual(['GET /api/v1/user']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -208,14 +208,22 @@ export const nestjsResolver: FrameworkResolver = {
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const moduleToPrefix = new Map<string, string>();
|
||||
const controllerToModule = new Map<string, string>();
|
||||
const bootstraps: NestBootstrap[] = [];
|
||||
|
||||
for (const filePath of context.getAllFiles()) {
|
||||
if (!/\.module\.(m?[jt]s|cjs)$/.test(filePath)) continue;
|
||||
if (!/\.(m?[jt]s|cjs)$/.test(filePath)) continue;
|
||||
const isModule = /\.module\.(m?[jt]s|cjs)$/.test(filePath);
|
||||
if (!isModule && !(context.fileContains?.(filePath, 'NestFactory') ?? true)) continue;
|
||||
const content = context.readFile(filePath);
|
||||
if (!content) continue;
|
||||
const safe = stripCommentsForRegex(content, detectLanguage(filePath));
|
||||
collectRouterModuleRegistrations(safe, moduleToPrefix);
|
||||
collectModuleControllers(safe, controllerToModule);
|
||||
if (isModule) {
|
||||
collectRouterModuleRegistrations(safe, moduleToPrefix);
|
||||
collectModuleControllers(safe, controllerToModule);
|
||||
}
|
||||
// Every app counts, even one that adds nothing: its routes must not
|
||||
// borrow another app's prefix.
|
||||
if (/\bNestFactory\s*\.\s*create/.test(safe)) bootstraps.push({ root: appRootOf(filePath), config: readBootstrap(safe) });
|
||||
}
|
||||
|
||||
const controllerToPrefix = new Map<string, string>();
|
||||
@@ -228,35 +236,243 @@ export const nestjsResolver: FrameworkResolver = {
|
||||
}
|
||||
}
|
||||
|
||||
if (controllerToPrefix.size === 0) return [];
|
||||
const adds = bootstraps.some((b) => b.config.prefix !== '' || b.config.uri);
|
||||
if (controllerToPrefix.size === 0 && !adds) return [];
|
||||
|
||||
const updates: Node[] = [];
|
||||
// Route → what the app adds to it. A RouterModule prefix binds through the
|
||||
// controller class's line range; the bootstrap's prefix and versioning
|
||||
// only to a route its source shows is in a `@Controller` — an Express
|
||||
// route has the same qualified-name shape.
|
||||
const plan = new Map<string, { route: Node; modulePrefix: string; config: NestAppConfig | null; version: string | null | undefined }>();
|
||||
for (const [controllerName, prefix] of controllerToPrefix) {
|
||||
const classes = context
|
||||
.getNodesByName(controllerName)
|
||||
.filter((n) => n.kind === 'class');
|
||||
for (const cls of classes) {
|
||||
const routes = context
|
||||
.getNodesInFile(cls.filePath)
|
||||
.filter((n) => n.kind === 'route');
|
||||
for (const cls of context.getNodesByName(controllerName).filter((n) => n.kind === 'class')) {
|
||||
for (const route of context.getNodesInFile(cls.filePath)) {
|
||||
if (route.kind !== 'route' || route.startLine < cls.startLine || route.startLine > cls.endLine) continue;
|
||||
plan.set(route.id, { route, modulePrefix: prefix, config: null, version: undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (adds) {
|
||||
for (const filePath of context.getAllFiles()) {
|
||||
if (!/\.(m?[jt]s|cjs)$/.test(filePath)) continue;
|
||||
if (!(context.fileContains?.(filePath, '@Controller') ?? true)) continue;
|
||||
const config = bootstrapFor(bootstraps, filePath);
|
||||
if (!config || (config.prefix === '' && !config.uri)) continue;
|
||||
const routes = context.getNodesInFile(filePath).filter((n) => n.kind === 'route' && HTTP_ROUTE_QN.test(n.qualifiedName));
|
||||
if (routes.length === 0) continue;
|
||||
const content = context.readFile(filePath);
|
||||
if (!content) continue;
|
||||
const safe = stripCommentsForRegex(content, detectLanguage(filePath));
|
||||
const scopes = buildClassScopes(safe);
|
||||
const hits = findDecorators(safe, HTTP_METHODS);
|
||||
for (const route of routes) {
|
||||
// Multiple controllers can live in one file (covered by the
|
||||
// existing "attributes methods to the right controller" test);
|
||||
// each route must be associated with the controller whose line
|
||||
// range contains it.
|
||||
if (route.startLine < cls.startLine || route.startLine > cls.endLine) {
|
||||
continue;
|
||||
}
|
||||
const updated = applyModulePrefix(route, prefix);
|
||||
if (updated && updated.name !== route.name) updates.push(updated);
|
||||
const hit = hits.find((h) => lineAt(safe, h.index) === route.startLine);
|
||||
const scope = hit ? scopeFor(scopes, hit.index) : null;
|
||||
if (!hit || !scope || scope.kind !== 'controller') continue;
|
||||
const planned = plan.get(route.id);
|
||||
plan.set(route.id, { route, modulePrefix: planned?.modulePrefix ?? '', config, version: routeVersion(safe, hit, scope) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Node[] = [];
|
||||
for (const { route, modulePrefix, config, version } of plan.values()) {
|
||||
const updated = applyAppConfig(route, modulePrefix, config, version);
|
||||
if (updated && updated.name !== route.name) updates.push(updated);
|
||||
}
|
||||
return updates;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The app's bootstrap: global prefix and URI versioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A route's in-file shape in its qualified name: `file::GET:/users`. */
|
||||
const HTTP_ROUTE_QN = /::(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ALL):/;
|
||||
|
||||
/** The version marker for `VERSION_NEUTRAL`: served without a version segment. */
|
||||
const NEUTRAL = '\u0000neutral';
|
||||
|
||||
interface NestAppConfig {
|
||||
/** `setGlobalPrefix('api')` — '' when none, or not a literal. */
|
||||
prefix: string;
|
||||
/** Paths the prefix skips: `exclude: ['health', { path: 'metrics', method }]`, without a leading `/`. */
|
||||
excludes: string[];
|
||||
/** URI versioning is on (`enableVersioning({ type: VersioningType.URI })`, the default type). */
|
||||
uri: boolean;
|
||||
/** `defaultVersion: '1'` — the version a route without its own is served at; null for none. */
|
||||
defaultVersion: string | null;
|
||||
/** The segment's prefix: `v` unless `prefix:` says otherwise (`false` is none). */
|
||||
versionPrefix: string;
|
||||
}
|
||||
|
||||
interface NestBootstrap {
|
||||
root: string;
|
||||
config: NestAppConfig;
|
||||
}
|
||||
|
||||
/** The app a file belongs to: the directory above its `src/` (`apps/api/`), else the repository. */
|
||||
function appRootOf(filePath: string): string {
|
||||
const m = /^((?:[^/]+\/)*?)src\//.exec(filePath);
|
||||
return m ? m[1]! : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The config of the app that holds `filePath`: the bootstrap with the longest
|
||||
* root above it, else — for a file outside every app, like an Nx `libs/`
|
||||
* controller — the app, when there is only one.
|
||||
*/
|
||||
function bootstrapFor(bootstraps: readonly NestBootstrap[], filePath: string): NestAppConfig | null {
|
||||
let root: string | null = null;
|
||||
for (const b of bootstraps) {
|
||||
if (filePath.startsWith(b.root) && (root === null || b.root.length > root.length)) root = b.root;
|
||||
}
|
||||
if (root === null) {
|
||||
if (new Set(bootstraps.map((b) => b.root)).size !== 1) return null;
|
||||
root = bootstraps[0]!.root;
|
||||
}
|
||||
// A seed or migration script creates the same app without serving it: the
|
||||
// bootstrap that configures the app is the one that serves it.
|
||||
const here = bootstraps.filter((b) => b.root === root);
|
||||
return (here.find((b) => b.config.prefix !== '' || b.config.uri) ?? here[0])?.config ?? null;
|
||||
}
|
||||
|
||||
/** One version value: `'2'`, `['1', '2']` (the first), `VERSION_NEUTRAL`. */
|
||||
function versionValue(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (/^VERSION_NEUTRAL\b/.test(t)) return NEUTRAL;
|
||||
const lit = /^\[?\s*(['"`])([^'"`]*)\1/.exec(t);
|
||||
return lit ? lit[2]! : null;
|
||||
}
|
||||
|
||||
/** `setGlobalPrefix` and `enableVersioning` from the file that calls `NestFactory.create`. */
|
||||
function readBootstrap(safe: string): NestAppConfig {
|
||||
const config: NestAppConfig = { prefix: '', excludes: [], uri: false, defaultVersion: null, versionPrefix: 'v' };
|
||||
const prefixCall = /\.setGlobalPrefix\s*\(/.exec(safe);
|
||||
if (prefixCall) {
|
||||
const open = prefixCall.index + prefixCall[0].length - 1;
|
||||
const close = matchingClose(safe, open);
|
||||
const args = close > open ? safe.slice(open + 1, close) : '';
|
||||
const lit = /^\s*(['"`])([^'"`$]*)\1/.exec(args);
|
||||
if (lit) {
|
||||
config.prefix = lit[2]!.replace(/^\/+|\/+$/g, '');
|
||||
const ex = /\bexclude\s*:\s*\[/.exec(args);
|
||||
if (ex) {
|
||||
const exOpen = ex.index + ex[0].length - 1;
|
||||
const exClose = matchingClose(args, exOpen);
|
||||
const list = exClose > exOpen ? args.slice(exOpen + 1, exClose) : '';
|
||||
for (const item of splitTopLevel(list)) {
|
||||
const text = item.trim();
|
||||
const path = /^(['"])([^'"]*)\1$/.exec(text)?.[2] ?? /\bpath\s*:\s*(['"])([^'"]*)\1/.exec(text)?.[2];
|
||||
if (path !== undefined) config.excludes.push(path.replace(/^\/+/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const versioning = /\.enableVersioning\s*\(/.exec(safe);
|
||||
if (versioning) {
|
||||
const open = versioning.index + versioning[0].length - 1;
|
||||
const close = matchingClose(safe, open);
|
||||
const args = close > open ? safe.slice(open + 1, close) : '';
|
||||
const type = /\btype\s*:\s*VersioningType\s*\.\s*(\w+)/.exec(args)?.[1];
|
||||
config.uri = type === undefined || type === 'URI';
|
||||
const def = /\bdefaultVersion\s*:\s*/.exec(args);
|
||||
if (def) config.defaultVersion = versionValue(args.slice(def.index + def[0].length));
|
||||
const pre = /\bprefix\s*:\s*(false|(['"`])([^'"`]*)\2)/.exec(args);
|
||||
if (pre) config.versionPrefix = pre[1] === 'false' ? '' : pre[3]!;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** A route excluded from the global prefix: `health`, `health/(.*)`, `health{/*wildcard}`, `health/*`. */
|
||||
function isExcluded(path: string, excludes: readonly string[]): boolean {
|
||||
const route = path.replace(/^\/+/, '');
|
||||
for (const pattern of excludes) {
|
||||
const wild = /(?:\{\/?\*\w*\}|\/?\(\.\*\)|\/?\*\w*)$/.exec(pattern);
|
||||
const base = wild ? pattern.slice(0, wild.index).replace(/\/+$/, '') : pattern.replace(/\/+$/, '');
|
||||
if (route === base || (wild && route.startsWith(`${base}/`))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The version a route is served at: `@Version(…)` among its own decorators,
|
||||
* else its controller's (`@Version(…)` on the class, `@Controller({ version })`).
|
||||
* Undefined when neither says.
|
||||
*/
|
||||
function routeVersion(safe: string, hit: DecoratorHit, scope: ClassScope): string | null | undefined {
|
||||
// The method's decorators: back through the ones stacked above this one…
|
||||
const from = decoratorChainStart(safe, hit.index, scope.start);
|
||||
// …and on to the handler itself — past the decorators between (`@UseGuards(…)`, `@Version('2')`).
|
||||
const handler = methodNameAfter(safe, hit.end);
|
||||
const at = handler ? new RegExp(String.raw`(?<![@\w$])${handler.replace(/\$/g, '\\$')}\s*\(`).exec(safe.slice(hit.end)) : null;
|
||||
const to = at ? hit.end + at.index : hit.end;
|
||||
const own = /@Version\s*\(([^)]*)\)/.exec(safe.slice(from, to));
|
||||
if (own) return versionValue(own[1]!);
|
||||
// The class's decorators, on either side of `@Controller`.
|
||||
const cls = /\bclass\b/g;
|
||||
cls.lastIndex = scope.start;
|
||||
const head = safe.slice(decoratorChainStart(safe, scope.start, 0), cls.exec(safe)?.index ?? scope.start + 400);
|
||||
const onClass = /@Version\s*\(([^)]*)\)/.exec(head);
|
||||
if (onClass) return versionValue(onClass[1]!);
|
||||
const inController = /@Controller\s*\(\s*\{[^)]*\bversion\s*:\s*([^,}]+)/.exec(head);
|
||||
if (inController) return versionValue(inController[1]!);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the stack of decorators ending at `at` begins: back over each
|
||||
* `@Name(…)` / `@Name` above it. A decorator's arguments may hold an object
|
||||
* (`@ApiResponse({ status: 200 })`), so this steps over balanced parentheses
|
||||
* rather than stopping at the first brace.
|
||||
*/
|
||||
function decoratorChainStart(safe: string, at: number, floor: number): number {
|
||||
let start = at;
|
||||
for (;;) {
|
||||
let i = start;
|
||||
while (i > floor && /\s/.test(safe[i - 1]!)) i--;
|
||||
if (safe[i - 1] === ')') {
|
||||
let depth = 0;
|
||||
let j = i - 1;
|
||||
for (; j >= floor; j--) {
|
||||
if (safe[j] === ')') depth++;
|
||||
else if (safe[j] === '(' && --depth === 0) break;
|
||||
}
|
||||
if (j < floor) return start;
|
||||
i = j;
|
||||
}
|
||||
let name = i;
|
||||
while (name > floor && /[\w$.]/.test(safe[name - 1]!)) name--;
|
||||
if (name === i || safe[name - 1] !== '@') return start;
|
||||
start = name - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A route's name with everything the app adds to it, in NestJS's order:
|
||||
* `/{globalPrefix}/{v}{version}/{RouterModule prefix}/{controller}/{method}`.
|
||||
* Recomputed from the in-file `method:path` in the qualified name, so the pass
|
||||
* is idempotent.
|
||||
*/
|
||||
function applyAppConfig(route: Node, modulePrefix: string, config: NestAppConfig | null, version: string | null | undefined): Node | null {
|
||||
const sep = route.qualifiedName.indexOf('::');
|
||||
if (sep < 0) return null;
|
||||
const tail = route.qualifiedName.slice(sep + 2);
|
||||
const colon = tail.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
const method = tail.slice(0, colon);
|
||||
const inner = joinHttpPath(modulePrefix, tail.slice(colon + 1));
|
||||
let path = inner;
|
||||
if (config) {
|
||||
const v = version === undefined ? config.defaultVersion : version;
|
||||
const versionSeg = config.uri && v !== null && v !== NEUTRAL ? `${config.versionPrefix}${v}` : '';
|
||||
const prefix = config.prefix && !isExcluded(inner, config.excludes) ? config.prefix : '';
|
||||
path = joinHttpPath(joinHttpPath(prefix, versionSeg), inner);
|
||||
}
|
||||
return { ...route, name: `${method} ${path}`, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider resolution conventions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -646,29 +862,36 @@ function classNameAfter(safe: string, start: number): string | null {
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute a route node's `name` by prepending `prefix` to the *original*
|
||||
* in-file path. The original is recovered from `qualifiedName`, which the
|
||||
* per-file extract emits as `${filePath}::${method}:${path}` and which this
|
||||
* pass deliberately never mutates — that's what keeps the update idempotent.
|
||||
*/
|
||||
function applyModulePrefix(route: Node, prefix: string): Node | null {
|
||||
const sep = '::';
|
||||
const idx = route.qualifiedName.indexOf(sep);
|
||||
if (idx < 0) return null;
|
||||
const tail = route.qualifiedName.slice(idx + sep.length);
|
||||
const colon = tail.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
const method = tail.slice(0, colon);
|
||||
const original = tail.slice(colon + 1);
|
||||
const newName = `${method} ${joinHttpPath(prefix, original)}`;
|
||||
return { ...route, name: newName, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small string utilities (object/array literal splitters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A list's comma-separated items at depth 0, strings and brackets stepped over. */
|
||||
function splitTopLevel(list: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let inStr: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const ch = list[i]!;
|
||||
if (inStr) {
|
||||
if (ch === '\\') { i++; continue; }
|
||||
if (ch === inStr) inStr = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
|
||||
if (ch === '[' || ch === '{' || ch === '(') depth++;
|
||||
else if (ch === ']' || ch === '}' || ch === ')') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
out.push(list.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(list.slice(start));
|
||||
return out.filter((item) => item.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Return the index of the bracket that closes the one at `open`, or -1. */
|
||||
function matchingClose(s: string, open: number): number {
|
||||
const opener = s[open];
|
||||
|
||||
Reference in New Issue
Block a user