Files
codegraph/__tests__/nestjs-global-prefix.test.ts
Colby Mchenry e845b1cd10 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>
2026-09-30 03:17:35 +00:00

218 lines
7.1 KiB
TypeScript

/**
* 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();
}
});
});