From 8d4ed4a36fbb78e406becca98c96ee6f20bd0ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonz=C3=A1lez?= Date: Sat, 18 Apr 2026 19:34:45 -0600 Subject: [PATCH 1/2] use trusted publishing for npm --- .github/workflows/release.yaml | 11 +---------- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a931485..ce463ad 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -45,13 +45,4 @@ jobs: - name: Publish package to npm working-directory: packages/nestjs-endpoints - run: | - cat << 'EOF' > .npmrc - //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} - registry=https://registry.npmjs.org/ - always-auth=true - EOF - - npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public diff --git a/.tool-versions b/.tool-versions index a82c8e6..08fad1c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 23.9.0 +nodejs 24.14.0 pnpm 9.4.0 From 02aa11ed506b58ef8fe12eb65101b28eb82685e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonz=C3=A1lez?= Date: Sat, 18 Apr 2026 20:59:33 -0600 Subject: [PATCH 2/2] endpoints router multiple root directories --- CHANGELOG.md | 7 + packages/nestjs-endpoints/src/consts.ts | 2 +- packages/nestjs-endpoints/src/endpoint-fn.ts | 8 +- .../src/endpoints-router.module.ts | 163 +++++++++++++----- packages/nestjs-endpoints/src/helpers.ts | 31 +++- .../test-app-express-cjs/src/app.module.ts | 4 + .../src/blog/endpoints/latest.endpoint.ts | 6 + .../src/blog/router.module.ts | 8 + .../src/shop/cart/cart.service.ts | 8 + .../src/shop/cart/endpoints/add.endpoint.ts | 9 + .../src/shop/cart/router.module.ts | 7 + .../src/shop/category/category.service.ts | 8 + .../shop/category/endpoints/list.endpoint.ts | 8 + .../src/shop/category/router.module.ts | 9 + .../shop/endpoints/promo/today.endpoint.ts | 8 + .../src/shop/endpoints/stats.endpoint.ts | 8 + .../src/shop/router.module.ts | 12 ++ .../src/shop/shop.service.ts | 11 ++ .../test/multi-root.e2e-spec.ts | 103 +++++++++++ 19 files changed, 372 insertions(+), 48 deletions(-) create mode 100644 packages/test/test-app-express-cjs/src/blog/endpoints/latest.endpoint.ts create mode 100644 packages/test/test-app-express-cjs/src/blog/router.module.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/cart/cart.service.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/cart/endpoints/add.endpoint.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/cart/router.module.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/category/category.service.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/category/endpoints/list.endpoint.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/category/router.module.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/endpoints/promo/today.endpoint.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/endpoints/stats.endpoint.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/router.module.ts create mode 100644 packages/test/test-app-express-cjs/src/shop/shop.service.ts create mode 100644 packages/test/test-app-express-cjs/test/multi-root.e2e-spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 512c90b..d0d88e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.2.0 (2026-04-18) + +### Features + +- **`rootDirectory` accepts `string | string[]`**: An `EndpointsRouterModule` can now scan multiple folders. Each entry is scanned the same way as a single root: any directory encountered that contains a `router.module.*` is auto-discovered as a nested router (at any depth, including the entry itself). +- **Folder-inferred `basePath` for `router.module.ts` files**: When the callsite is a `router.module.ts` and `basePath` is omitted, the `basePath` is inferred from the folder containing the router module — at the top level from `path.basename(definedAtDir)`, and for nested routers from the parent's `basePath` joined with the child's folder relative to the parent's folder. Pass `basePath: ''` to opt out. + ## 2.1.0 (2026-04-18) ### Features diff --git a/packages/nestjs-endpoints/src/consts.ts b/packages/nestjs-endpoints/src/consts.ts index c1679d8..88ffb63 100644 --- a/packages/nestjs-endpoints/src/consts.ts +++ b/packages/nestjs-endpoints/src/consts.ts @@ -7,7 +7,7 @@ export const settings: { endpoints: { file: string; setupFn: (settings: { - rootDirectory: string; + rootDirectories: string[]; basePath: string; }) => void; }[]; diff --git a/packages/nestjs-endpoints/src/endpoint-fn.ts b/packages/nestjs-endpoints/src/endpoint-fn.ts index 7c78991..98542b9 100644 --- a/packages/nestjs-endpoints/src/endpoint-fn.ts +++ b/packages/nestjs-endpoints/src/endpoint-fn.ts @@ -376,10 +376,10 @@ export function endpoint< class cls {} const file = getCallsiteFile(); const setupFn = ({ - rootDirectory, + rootDirectories, basePath, }: { - rootDirectory: string; + rootDirectories: string[]; basePath: string; }) => { const { httpPath, httpPathPascalName, httpPathSegments } = (() => { @@ -395,7 +395,7 @@ export function endpoint< httpPathPascalName: getHttpPathPascalName(combined), }; } - return getEndpointHttpPath(rootDirectory, basePath, file); + return getEndpointHttpPath(rootDirectories, basePath, file); })(); let outputSchemas: Record | null = null; if (output) { @@ -639,7 +639,7 @@ export function endpoint< setupFn, }); } else { - setupFn({ rootDirectory: process.cwd(), basePath: '' }); + setupFn({ rootDirectories: [process.cwd()], basePath: '' }); } return cls as any; diff --git a/packages/nestjs-endpoints/src/endpoints-router.module.ts b/packages/nestjs-endpoints/src/endpoints-router.module.ts index dd2b2cd..95629ac 100644 --- a/packages/nestjs-endpoints/src/endpoints-router.module.ts +++ b/packages/nestjs-endpoints/src/endpoints-router.module.ts @@ -38,14 +38,39 @@ function isMiddlewareOptions(m: unknown): m is RouterMiddlewareOptions { export class EndpointsRouterModule { static async register(params?: { /** - * The root directory to load endpoints from recursively. Relative and absolute - * paths are supported. - * @default The directory of the calling file + * Directory (or directories) this router loads endpoints from. Relative + * paths are resolved against the calling file's directory; absolute + * paths are used as-is. + * + * Each entry is scanned recursively. Any directory encountered that + * contains a `router.module.*` file is auto-discovered as a nested + * router (regardless of depth) — the parent imports it and does not + * scan inside it. Directories without a `router.module.*` are scanned + * for `*.endpoint.ts` files (non-`_`-prefixed subfolders contribute + * to the inferred URL path). + * + * @example + * rootDirectory: './endpoints' // single scan root + * rootDirectory: ['endpoints', 'clinica'] // each entry scanned independently + * + * @default The directory of the calling file. */ - rootDirectory?: string; + rootDirectory?: string | string[]; /** - * The base path to use for endpoints in this module. - * @default '/' + * The base path to prepend to every endpoint owned by this router. + * + * When omitted, `basePath` is inferred: + * + * - **Top-level `router.module.ts`**: the folder containing the file + * (e.g. `src/auth/router.module.ts` → `basePath: 'auth'`). + * - **Nested**: the parent's effective `basePath` joined with the + * child's folder relative to its matching parent root — or, if the + * child doesn't sit inside one of the parent's `rootDirectories`, + * relative to the parent module file's own folder. + * - **Other callsites** (e.g. `app.module.ts`, or any file not named + * `router.module.*`): no folder inference; defaults to `'/'`. + * + * Pass `''` or `'/'` to opt out of inference and mount at the root. */ basePath?: string; imports?: ModuleMetadata['imports']; @@ -70,46 +95,92 @@ export class EndpointsRouterModule { */ interceptors?: RouterInterceptor[]; }): Promise { - const definedAtDir = path.dirname(getCallsiteFile()); + const callsiteFile = getCallsiteFile(); + const definedAtDir = path.dirname(callsiteFile); const resolveDir = (dir: string) => { if (!path.isAbsolute(dir)) { return path.join(definedAtDir, dir); } return dir; }; - const rootDirectory = params?.rootDirectory - ? resolveDir(params.rootDirectory) - : definedAtDir; + const rootDirectories = ((): string[] => { + if (!params?.rootDirectory) { + return [definedAtDir]; + } + const raw = Array.isArray(params.rootDirectory) + ? params.rootDirectory + : [params.rootDirectory]; + return raw.map(resolveDir); + })(); const parentStore = moduleAls.getStore(); const effectiveBasePath = ((): string => { if (params?.basePath !== undefined) { return params.basePath; } - if ( - parentStore?.parentRootDirectory && - parentStore.parentRootDirectory !== rootDirectory - ) { - const relative = path - .relative(parentStore.parentRootDirectory, definedAtDir) - .split(path.sep) - .join('/'); - return '/' + relative; + if (parentStore) { + // If this child sits under (or is) one of the parent's + // rootDirectories, derive the suffix from that — this preserves + // the legacy `rootDirectory: './endpoints'` + nested-router + // pattern. Otherwise, use path.relative between module files. + const matchingRoot = parentStore.parentRootDirectories.find( + (r) => + definedAtDir === r || definedAtDir.startsWith(r + path.sep), + ); + let suffix: string; + if (matchingRoot) { + suffix = + definedAtDir === matchingRoot + ? path.basename(definedAtDir) + : path.relative(matchingRoot, definedAtDir); + } else { + suffix = path.relative( + parentStore.parentModuleDir, + definedAtDir, + ); + } + suffix = suffix.split(path.sep).join('/'); + const segments = [ + ...parentStore.parentBasePath.split('/').filter(Boolean), + ...suffix.split('/').filter(Boolean), + ]; + return segments.length > 0 ? '/' + segments.join('/') : '/'; + } + // Top-level: infer from the router module file's own folder name, + // but only when the callsite is a `router.module.*` file. That way, + // calling `register()` from an `app.module.ts` (or any other module) + // keeps the legacy `'/'` default and doesn't silently prefix routes + // with the app's top folder. + if (routerModuleFileRegex.test(path.basename(callsiteFile))) { + return '/' + path.basename(definedAtDir); } return '/'; })(); let endpoints: Type[] = []; const nestedRouterModuleFiles: string[] = []; - const endopointFiles = findEndpoints( - rootDirectory, - nestedRouterModuleFiles, - ); + const endopointFiles: string[] = []; + for (const root of rootDirectories) { + findEndpoints( + root, + nestedRouterModuleFiles, + endopointFiles, + callsiteFile, + ); + } const endpointFilesNotImported = endopointFiles.filter((f) => settings.endpoints.every((e) => e.file !== f), ); const nestedModules: (DynamicModule | Promise)[] = []; + const normalizedParentBasePath = effectiveBasePath.replace( + /^\/+|\/+$/g, + '', + ); await moduleAls.run( - { parentRootDirectory: rootDirectory }, + { + parentBasePath: normalizedParentBasePath, + parentModuleDir: definedAtDir, + parentRootDirectories: rootDirectories, + }, // eslint-disable-next-line @typescript-eslint/require-await -- needed since we are replacing the require with await import during build for esm async () => { for (const f of endpointFilesNotImported) { @@ -131,15 +202,15 @@ export class EndpointsRouterModule { for (const { setupFn } of settings.endpoints.filter((e) => endpointFilesNotImported.some((f) => f === e.file), )) { - setupFn({ rootDirectory, basePath: effectiveBasePath }); + setupFn({ rootDirectories, basePath: effectiveBasePath }); } if (params?.endpoints) { for (const ep of params.endpoints) { const epSetupFn = Reflect.getMetadata('endpoints:setupFn', ep) as - | ((s: { rootDirectory: string; basePath: string }) => void) + | ((s: { rootDirectories: string[]; basePath: string }) => void) | undefined; if (epSetupFn) { - epSetupFn({ rootDirectory, basePath: effectiveBasePath }); + epSetupFn({ rootDirectories, basePath: effectiveBasePath }); } if (!endpoints.includes(ep)) { endpoints.push(ep); @@ -198,12 +269,11 @@ export class EndpointsRouterModule { } } } - const normalizedBasePath = effectiveBasePath.replace(/^\/+|\/+$/g, ''); const excludeRoutes = middlewareOptions?.exclude?.map((p) => - normalizedBasePath ? `${normalizedBasePath}/${p}` : p, + normalizedParentBasePath ? `${normalizedParentBasePath}/${p}` : p, ); - const forRoutesPattern = normalizedBasePath - ? `${normalizedBasePath}/*` + const forRoutesPattern = normalizedParentBasePath + ? `${normalizedParentBasePath}/*` : '*'; class RouterModule @@ -239,21 +309,36 @@ export class EndpointsRouterModule { function findEndpoints( dir: string, nestedRouterModuleFiles: string[], - endopointFiles: string[] = [], - isRoot = true, + endopointFiles: string[], + callerRouterModuleFile: string, ) { - const files = fs.readdirSync(dir); - if (!isRoot) { - const routerFile = files.find((f) => f.match(routerModuleFileRegex)); - if (routerFile) { - nestedRouterModuleFiles.push(path.join(dir, routerFile)); + let files: string[]; + try { + files = fs.readdirSync(dir); + } catch { + return endopointFiles; + } + // A directory that contains a `router.module.*` file is treated as a + // nested router — regardless of depth. The only exception is the + // caller's own router module file (avoids self-registration when a + // `rootDirectory` entry happens to resolve to the caller's own folder). + const routerFile = files.find((f) => f.match(routerModuleFileRegex)); + if (routerFile) { + const routerFilePath = path.join(dir, routerFile); + if (routerFilePath !== callerRouterModuleFile) { + nestedRouterModuleFiles.push(routerFilePath); return endopointFiles; } } for (const f of files) { const file = path.join(dir, f); if (fs.statSync(file).isDirectory()) { - findEndpoints(file, nestedRouterModuleFiles, endopointFiles, false); + findEndpoints( + file, + nestedRouterModuleFiles, + endopointFiles, + callerRouterModuleFile, + ); } if (f.match(endpointFileRegex)) { endopointFiles.push(file); diff --git a/packages/nestjs-endpoints/src/helpers.ts b/packages/nestjs-endpoints/src/helpers.ts index a62ddab..a7242ce 100644 --- a/packages/nestjs-endpoints/src/helpers.ts +++ b/packages/nestjs-endpoints/src/helpers.ts @@ -18,18 +18,21 @@ const shortCircuitDirs: Record = { [process.cwd()]: true, }; export function getEndpointHttpPath( - rootDirectory: string, + rootDirectories: string[], basePath: string, file: string, ) { - shortCircuitDirs[rootDirectory] = true; + const stopDirs: Record = { ...shortCircuitDirs }; + for (const d of rootDirectories) { + stopDirs[d] = true; + } let pathSegments: string[] = []; let start = path.dirname(file); let lastDirPathSegment: string | null = null; while (true) { if ( - Object.keys(shortCircuitDirs).some( + Object.keys(stopDirs).some( (d) => path.normalize(d + path.sep) === path.normalize(start + path.sep), @@ -130,5 +133,25 @@ export function getCallsiteFile() { } export const moduleAls = new AsyncLocalStorage<{ - parentRootDirectory: string; + /** + * The parent router's effective basePath (without leading slash, joined + * with '/'). Used by nested child routers to prefix their own inferred + * basePath with the parent's. + */ + parentBasePath: string; + /** + * The directory containing the parent router module file (i.e., the + * directory in which the parent's `router.module.ts` lives). Child + * routers infer their basePath suffix from their own module directory + * relative to this when they don't sit inside one of the parent's + * `rootDirectories`. + */ + parentModuleDir: string; + /** + * The parent router's resolved root directories. When a child router's + * own directory matches (or sits inside) one of these, the basePath + * suffix is computed relative to that root — preserving the legacy + * `rootDirectory: './endpoints'` + nested-router pattern. + */ + parentRootDirectories: string[]; }>(); diff --git a/packages/test/test-app-express-cjs/src/app.module.ts b/packages/test/test-app-express-cjs/src/app.module.ts index 295536c..9551db2 100644 --- a/packages/test/test-app-express-cjs/src/app.module.ts +++ b/packages/test/test-app-express-cjs/src/app.module.ts @@ -14,6 +14,7 @@ import { import { Observable, catchError, throwError } from 'rxjs'; import { ZodError } from 'zod'; import { AuthModule } from './auth/auth.module'; +import BlogRouterModule from './blog/router.module'; import { HelloService } from './endpoints/hello.service'; import { RecipesManualEndpointsRouterModule } from './endpoints/recipes-manual/router.module'; import { @@ -28,6 +29,7 @@ import { IUserRepository, UserService, } from './endpoints/user/user.service'; +import ShopRouterModule from './shop/router.module'; import { VanillaController } from './vanilla.controller'; @Injectable() @@ -67,6 +69,8 @@ export class ZodErrorInterceptor implements NestInterceptor { ], }), RecipesManualEndpointsRouterModule, + ShopRouterModule, + BlogRouterModule, UserListModule, ], providers: [ diff --git a/packages/test/test-app-express-cjs/src/blog/endpoints/latest.endpoint.ts b/packages/test/test-app-express-cjs/src/blog/endpoints/latest.endpoint.ts new file mode 100644 index 0000000..96bba58 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/blog/endpoints/latest.endpoint.ts @@ -0,0 +1,6 @@ +import { endpoint, z } from 'nestjs-endpoints'; + +export default endpoint({ + output: z.object({ title: z.string() }), + handler: () => ({ title: 'Hello' }), +}); diff --git a/packages/test/test-app-express-cjs/src/blog/router.module.ts b/packages/test/test-app-express-cjs/src/blog/router.module.ts new file mode 100644 index 0000000..4d32116 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/blog/router.module.ts @@ -0,0 +1,8 @@ +import { EndpointsRouterModule } from 'nestjs-endpoints'; + +export default EndpointsRouterModule.register({ + rootDirectory: 'endpoints', + // Explicit basePath overrides folder-name inference. Folder name is 'blog' + // but we mount at '/articles' instead. + basePath: 'articles', +}); diff --git a/packages/test/test-app-express-cjs/src/shop/cart/cart.service.ts b/packages/test/test-app-express-cjs/src/shop/cart/cart.service.ts new file mode 100644 index 0000000..e02a6ed --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/cart/cart.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CartService { + add(item: string) { + return { added: item }; + } +} diff --git a/packages/test/test-app-express-cjs/src/shop/cart/endpoints/add.endpoint.ts b/packages/test/test-app-express-cjs/src/shop/cart/endpoints/add.endpoint.ts new file mode 100644 index 0000000..68c5767 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/cart/endpoints/add.endpoint.ts @@ -0,0 +1,9 @@ +import { endpoint, z } from 'nestjs-endpoints'; +import { CartService } from '../cart.service'; + +export default endpoint({ + input: z.object({ item: z.string() }), + output: z.object({ added: z.string() }), + inject: { cart: CartService }, + handler: ({ input, cart }) => cart.add(input.item), +}); diff --git a/packages/test/test-app-express-cjs/src/shop/cart/router.module.ts b/packages/test/test-app-express-cjs/src/shop/cart/router.module.ts new file mode 100644 index 0000000..c7f7cd3 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/cart/router.module.ts @@ -0,0 +1,7 @@ +import { EndpointsRouterModule } from 'nestjs-endpoints'; +import { CartService } from './cart.service'; + +export default EndpointsRouterModule.register({ + rootDirectory: 'endpoints', + providers: [CartService], +}); diff --git a/packages/test/test-app-express-cjs/src/shop/category/category.service.ts b/packages/test/test-app-express-cjs/src/shop/category/category.service.ts new file mode 100644 index 0000000..0e75ec7 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/category/category.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CategoryService { + list() { + return [{ id: 1, name: 'books' }]; + } +} diff --git a/packages/test/test-app-express-cjs/src/shop/category/endpoints/list.endpoint.ts b/packages/test/test-app-express-cjs/src/shop/category/endpoints/list.endpoint.ts new file mode 100644 index 0000000..abbf831 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/category/endpoints/list.endpoint.ts @@ -0,0 +1,8 @@ +import { endpoint, z } from 'nestjs-endpoints'; +import { CategoryService } from '../category.service'; + +export default endpoint({ + output: z.array(z.object({ id: z.number(), name: z.string() })), + inject: { categories: CategoryService }, + handler: ({ categories }) => categories.list(), +}); diff --git a/packages/test/test-app-express-cjs/src/shop/category/router.module.ts b/packages/test/test-app-express-cjs/src/shop/category/router.module.ts new file mode 100644 index 0000000..a235cd8 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/category/router.module.ts @@ -0,0 +1,9 @@ +import { EndpointsRouterModule } from 'nestjs-endpoints'; +import { CategoryService } from './category.service'; + +export default EndpointsRouterModule.register({ + rootDirectory: 'endpoints', + // basePath omitted — inferred as 'shop/category': + // parent shop's basePath ('shop') + this module's folder name ('category'). + providers: [CategoryService], +}); diff --git a/packages/test/test-app-express-cjs/src/shop/endpoints/promo/today.endpoint.ts b/packages/test/test-app-express-cjs/src/shop/endpoints/promo/today.endpoint.ts new file mode 100644 index 0000000..6b33dad --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/endpoints/promo/today.endpoint.ts @@ -0,0 +1,8 @@ +import { endpoint, z } from 'nestjs-endpoints'; +import { ShopService } from '../../shop.service'; + +export default endpoint({ + output: z.object({ code: z.string() }), + inject: { shop: ShopService }, + handler: ({ shop }) => shop.promoToday(), +}); diff --git a/packages/test/test-app-express-cjs/src/shop/endpoints/stats.endpoint.ts b/packages/test/test-app-express-cjs/src/shop/endpoints/stats.endpoint.ts new file mode 100644 index 0000000..8ec739c --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/endpoints/stats.endpoint.ts @@ -0,0 +1,8 @@ +import { endpoint, z } from 'nestjs-endpoints'; +import { ShopService } from '../shop.service'; + +export default endpoint({ + output: z.object({ visitors: z.number() }), + inject: { shop: ShopService }, + handler: ({ shop }) => shop.stats(), +}); diff --git a/packages/test/test-app-express-cjs/src/shop/router.module.ts b/packages/test/test-app-express-cjs/src/shop/router.module.ts new file mode 100644 index 0000000..9d60a84 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/router.module.ts @@ -0,0 +1,12 @@ +import { EndpointsRouterModule } from 'nestjs-endpoints'; +import { ShopService } from './shop.service'; + +export default EndpointsRouterModule.register({ + // Multi-root: 'endpoints' holds this router's own endpoint files; + // 'category' and 'cart' each contain their own router.module.ts and + // are treated as nested routers. + rootDirectory: ['endpoints', 'category', 'cart'], + // basePath intentionally omitted — inferred as 'shop' from this + // router.module.ts file's containing folder. + providers: [ShopService], +}); diff --git a/packages/test/test-app-express-cjs/src/shop/shop.service.ts b/packages/test/test-app-express-cjs/src/shop/shop.service.ts new file mode 100644 index 0000000..1f70f63 --- /dev/null +++ b/packages/test/test-app-express-cjs/src/shop/shop.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ShopService { + stats() { + return { visitors: 42 }; + } + promoToday() { + return { code: 'TODAY10' }; + } +} diff --git a/packages/test/test-app-express-cjs/test/multi-root.e2e-spec.ts b/packages/test/test-app-express-cjs/test/multi-root.e2e-spec.ts new file mode 100644 index 0000000..dbac19b --- /dev/null +++ b/packages/test/test-app-express-cjs/test/multi-root.e2e-spec.ts @@ -0,0 +1,103 @@ +import { Writable } from 'node:stream'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupOpenAPI } from 'nestjs-endpoints'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { createApp } from './create-app'; + +describe('multi-root + folder-inferred basePath', () => { + async function setup() { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + const { app } = await createApp(moduleFixture); + app.useLogger(false); + return { app, req: request(app.getHttpServer()) }; + } + + test('parent router: own endpoints mount under folder-inferred basePath', async () => { + const { app, req } = await setup(); + try { + await req.get('/shop/stats').expect(200, { visitors: 42 }); + await req.get('/shop/promo/today').expect(200, { code: 'TODAY10' }); + } finally { + await app.close(); + } + }); + + test('sibling dir with its own router.module.ts is treated as a nested router', async () => { + const { app, req } = await setup(); + try { + await req + .get('/shop/category/list') + .expect(200, [{ id: 1, name: 'books' }]); + await req + .get('/shop/cart/add') + .query({ item: 'ABC' }) + .expect(200, { added: 'ABC' }); + } finally { + await app.close(); + } + }); + + test('explicit basePath overrides folder-name inference', async () => { + const { app, req } = await setup(); + try { + // Folder is 'blog' but basePath: 'articles' is explicit. + await req.get('/articles/latest').expect(200, { title: 'Hello' }); + await req.get('/blog/latest').expect(404); + } finally { + await app.close(); + } + }); + + test('single-string rootDirectory keeps working (regression)', async () => { + // The top-level AppModule still uses rootDirectory: './endpoints' + // (a single string). Sanity-check that endpoints under there still + // resolve correctly. + const { app, req } = await setup(); + try { + await req + .get('/greet') + .query({ name: 'Jo' }) + .expect(200, 'Hello, Jo!'); + await req + .post('/user/create') + .send({ name: 'X', email: 'x@y.com' }) + .expect(200, { id: 1 }); + } finally { + await app.close(); + } + }); + + test('OpenAPI spec contains the multi-segment paths', async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + const app = moduleFixture.createNestApplication(); + app.useLogger(false); + await app.init(); + try { + let spec = ''; + const stream = new Writable({ + write(chunk, _, callback) { + spec += chunk.toString(); + callback(); + }, + }); + await setupOpenAPI(app, { + configure: (b) => b.setTitle('multi-root'), + outputFile: stream, + }); + const parsed = JSON.parse(spec); + expect(parsed.paths).toHaveProperty('/shop/stats'); + expect(parsed.paths).toHaveProperty('/shop/promo/today'); + expect(parsed.paths).toHaveProperty('/shop/category/list'); + expect(parsed.paths).toHaveProperty('/shop/cart/add'); + expect(parsed.paths).toHaveProperty('/articles/latest'); + expect(parsed.paths).not.toHaveProperty('/blog/latest'); + } finally { + await app.close(); + } + }); +});