From e884039443c95378f323175906a14a5238497418 Mon Sep 17 00:00:00 2001 From: mrosnerr Date: Sun, 8 Dec 2024 13:16:36 -0600 Subject: [PATCH] feat(nestjs-trpc): Support for nesting Router within custom applyDecorators implementations --- .../__tests__/router.decorator.spec.ts | 13 +++- .../lib/factories/middleware.factory.ts | 4 +- .../__tests__/middleware.generator.spec.ts | 4 +- .../__tests__/router.generator.spec.ts | 5 +- .../__tests__/trpc.generator.spec.ts | 12 +-- .../lib/generators/middleware.generator.ts | 25 ++----- .../lib/generators/router.generator.ts | 11 +-- .../lib/interfaces/factory.interface.ts | 4 +- .../nestjs-trpc/lib/scanners/file.scanner.ts | 46 +++++++----- packages/nestjs-trpc/lib/trpc.module.ts | 3 +- .../__tests__/find-class-in-path.spec.ts | 73 +++++++++++++++++++ .../lib/utils/find-class-in-path.ts | 23 ++++++ 12 files changed, 162 insertions(+), 61 deletions(-) create mode 100644 packages/nestjs-trpc/lib/utils/__tests__/find-class-in-path.spec.ts create mode 100644 packages/nestjs-trpc/lib/utils/find-class-in-path.ts diff --git a/packages/nestjs-trpc/lib/decorators/__tests__/router.decorator.spec.ts b/packages/nestjs-trpc/lib/decorators/__tests__/router.decorator.spec.ts index 30fb2f3..e2c55cc 100644 --- a/packages/nestjs-trpc/lib/decorators/__tests__/router.decorator.spec.ts +++ b/packages/nestjs-trpc/lib/decorators/__tests__/router.decorator.spec.ts @@ -8,7 +8,8 @@ describe('Router Decorator', () => { class TestRouter {} const metadata = Reflect.getMetadata(ROUTER_METADATA_KEY, TestRouter); - expect(metadata).toStrictEqual({alias: undefined, path: __filename}) + expect(metadata.alias).toBeUndefined(); + expect(metadata.path[0]).toBe(__filename) }); it('should set router metadata with alias', () => { @@ -18,7 +19,8 @@ describe('Router Decorator', () => { class TestRouter {} const metadata = Reflect.getMetadata(ROUTER_METADATA_KEY, TestRouter); - expect(metadata).toStrictEqual({alias, path: __filename}) + expect(metadata.alias).toBe(alias); + expect(metadata.path[0]).toBe(__filename) }); it('should not affect class methods', () => { @@ -41,7 +43,10 @@ describe('Router Decorator', () => { const metadata1 = Reflect.getMetadata(ROUTER_METADATA_KEY, TestRouter1); const metadata2 = Reflect.getMetadata(ROUTER_METADATA_KEY, TestRouter2); - expect(metadata1).toEqual({ alias: 'router1', path: __filename }); - expect(metadata2).toEqual({ alias: 'router2', path: __filename }); + expect(metadata1.alias).toBe("router1"); + expect(metadata1.path[0]).toBe(__filename); + + expect(metadata2.alias).toBe('router2'); + expect(metadata2.path[0]).toBe(__filename); }); }); \ No newline at end of file diff --git a/packages/nestjs-trpc/lib/factories/middleware.factory.ts b/packages/nestjs-trpc/lib/factories/middleware.factory.ts index e26143b..8e9df43 100644 --- a/packages/nestjs-trpc/lib/factories/middleware.factory.ts +++ b/packages/nestjs-trpc/lib/factories/middleware.factory.ts @@ -5,8 +5,8 @@ import { RouterFactory } from './router.factory'; import { ProcedureFactory } from './procedure.factory'; import { isEqual, uniqWith } from 'lodash'; -interface MiddlewareMetadata { - path: string; +export interface MiddlewareMetadata { + path: string[]; instance: Class | Constructor; } diff --git a/packages/nestjs-trpc/lib/generators/__tests__/middleware.generator.spec.ts b/packages/nestjs-trpc/lib/generators/__tests__/middleware.generator.spec.ts index 8d0ca22..30bb076 100644 --- a/packages/nestjs-trpc/lib/generators/__tests__/middleware.generator.spec.ts +++ b/packages/nestjs-trpc/lib/generators/__tests__/middleware.generator.spec.ts @@ -41,7 +41,7 @@ describe('MiddlewareGenerator', () => { describe('getMiddlewareInterface', () => { it('should return null if middleware class name is not defined', async () => { - const result = await middlewareGenerator.getMiddlewareInterface('routerPath', {} as any, project); + const result = await middlewareGenerator.getMiddlewareInterface(['routerPath'], {} as any, project); expect(result).toBeNull(); }); @@ -58,7 +58,7 @@ describe('MiddlewareGenerator', () => { jest.spyOn(project, 'addSourceFileAtPath').mockReturnValue(sourceFile); - const result = await middlewareGenerator.getMiddlewareInterface('routerPath', TestMiddleware, project); + const result = await middlewareGenerator.getMiddlewareInterface(['routerPath'], TestMiddleware, project); expect(result).toEqual({ name: 'TestMiddleware', properties: [ diff --git a/packages/nestjs-trpc/lib/generators/__tests__/router.generator.spec.ts b/packages/nestjs-trpc/lib/generators/__tests__/router.generator.spec.ts index e76a669..71e1437 100644 --- a/packages/nestjs-trpc/lib/generators/__tests__/router.generator.spec.ts +++ b/packages/nestjs-trpc/lib/generators/__tests__/router.generator.spec.ts @@ -5,7 +5,6 @@ import { Project, SourceFile } from 'ts-morph'; import { RoutersFactoryMetadata, } from '../../interfaces/factory.interface'; import { DecoratorGeneratorMetadata, - ProcedureGeneratorMetadata, RouterGeneratorMetadata, } from '../../interfaces/generator.interface'; import { Query, Mutation } from '../../decorators'; @@ -85,12 +84,12 @@ describe('RouterGenerator', () => { const mockRouter: RoutersFactoryMetadata = { name: 'TestRouter', alias: 'test', - path: 'testPath', + path: ['testPath'], instance: { name: "TestRouter", instance: jest.fn(), alias: 'test', - path:"testPath", + path: ["testPath"], middlewares: [] }, procedures: [ diff --git a/packages/nestjs-trpc/lib/generators/__tests__/trpc.generator.spec.ts b/packages/nestjs-trpc/lib/generators/__tests__/trpc.generator.spec.ts index f214971..9197458 100644 --- a/packages/nestjs-trpc/lib/generators/__tests__/trpc.generator.spec.ts +++ b/packages/nestjs-trpc/lib/generators/__tests__/trpc.generator.spec.ts @@ -5,18 +5,18 @@ import { RouterGenerator } from '../router.generator'; import { MiddlewareGenerator } from '../middleware.generator'; import { ContextGenerator } from '../context.generator'; import { RouterFactory } from '../../factories/router.factory'; -import { MiddlewareFactory } from '../../factories/middleware.factory'; +import { MiddlewareFactory, MiddlewareMetadata } from '../../factories/middleware.factory'; import { ProcedureFactory } from '../../factories/procedure.factory'; import { ClassDeclaration, Project, SourceFile } from 'ts-morph'; import * as fileUtil from '../../utils/ts-morph.util'; -import { ProcedureFactoryMetadata } from '../../interfaces/factory.interface'; +import { ProcedureFactoryMetadata, RouterInstance } from '../../interfaces/factory.interface'; import { MiddlewareOptions, MiddlewareResponse, TRPCContext, TRPCMiddleware } from '../../interfaces'; import { CreateExpressContextOptions } from '@trpc/server/adapters/express'; import { TRPC_GENERATOR_OPTIONS, TRPC_MODULE_CALLER_FILE_PATH } from '../../trpc.constants'; import { TYPESCRIPT_APP_ROUTER_SOURCE_FILE, TYPESCRIPT_PROJECT } from '../generator.constants'; import { StaticGenerator } from '../static.generator'; import { ImportsScanner } from '../../scanners/imports.scanner'; -import { SourceFileImportsMap } from '../../interfaces/generator.interface'; +import { RouterGeneratorMetadata, SourceFileImportsMap } from '../../interfaces/generator.interface'; jest.mock('../../utils/ts-morph.util'); @@ -128,7 +128,7 @@ describe('TRPCGenerator', () => { describe('generateSchemaFile', () => { it('should generate schema file', async () => { - const mockRouters = [{ name: 'TestRouter', instance: {}, alias: 'test', path: 'testPath', middlewares: [] }]; + const mockRouters: Array = [{ name: 'TestRouter', instance: {}, alias: 'test', path: ['testPath'], middlewares: [] }]; const mockProcedures: Array = [{ name: 'testProcedure', implementation: jest.fn(), @@ -138,7 +138,7 @@ describe('TRPCGenerator', () => { params: [], middlewares: [], }]; - const mockRoutersMetadata = [{ name: 'TestRouter', alias: 'test', procedures: [{ name: 'testProcedure', decorators: [] }], path: 'testPath'}]; + const mockRoutersMetadata: Array = [{ name: 'TestRouter', alias: 'test', procedures: [{ name: 'testProcedure', decorators: [] }]}]; routerFactory.getRouters.mockReturnValue(mockRouters); procedureFactory.getProcedures.mockReturnValue(mockProcedures); @@ -185,7 +185,7 @@ describe('TRPCGenerator', () => { } } - const mockMiddlewares = [{ instance: TestMiddleware, path: 'testPath' }]; + const mockMiddlewares: Array = [{ instance: TestMiddleware, path: ['testPath'] }]; const mockMiddlewareInterface = { name: 'TestMiddleware', properties: [{ name: 'test', type: 'string' }] }; const mockImportsMap = new Map([ [TestContext.name, {sourceFile, initializer: sourceFile.getClass(TestContext.name) as ClassDeclaration}] diff --git a/packages/nestjs-trpc/lib/generators/middleware.generator.ts b/packages/nestjs-trpc/lib/generators/middleware.generator.ts index 6f44ef2..cdd313f 100644 --- a/packages/nestjs-trpc/lib/generators/middleware.generator.ts +++ b/packages/nestjs-trpc/lib/generators/middleware.generator.ts @@ -12,11 +12,12 @@ import { import { Injectable } from '@nestjs/common'; import { TRPCMiddleware } from '../interfaces'; import type { Class } from 'type-fest'; +import { findClassInPath } from '../utils/find-class-in-path'; @Injectable() export class MiddlewareGenerator { public async getMiddlewareInterface( - routerFilePath: string, + routerFilePath: string[], middleware: Class, project: Project, ): Promise<{ @@ -34,18 +35,17 @@ export class MiddlewareGenerator { return null; } - const contextSourceFile = project.addSourceFileAtPath(routerFilePath); - - const classDeclaration = this.getClassDeclaration( - contextSourceFile, + const classFromPath = findClassInPath( + project, + routerFilePath, middleware.name, ); - if (!classDeclaration) { + if (!classFromPath) { return null; } - const useMethod = classDeclaration.getMethod('use'); + const useMethod = classFromPath.classDeclaration.getMethod('use'); if (!useMethod) { return null; } @@ -99,17 +99,6 @@ export class MiddlewareGenerator { return ctxProperty.getInitializer()?.getType() || null; } - private getClassDeclaration( - sourceFile: SourceFile, - className: string, - ): ClassDeclaration | undefined { - const classDeclaration = sourceFile.getClass(className); - if (classDeclaration) { - return classDeclaration; - } - return undefined; - } - private typeToProperties( type: Type, ): Array> { diff --git a/packages/nestjs-trpc/lib/generators/router.generator.ts b/packages/nestjs-trpc/lib/generators/router.generator.ts index 6482b2d..440e256 100644 --- a/packages/nestjs-trpc/lib/generators/router.generator.ts +++ b/packages/nestjs-trpc/lib/generators/router.generator.ts @@ -1,4 +1,4 @@ -import { Project } from 'ts-morph'; +import { ClassDeclaration, Project, SourceFile } from 'ts-morph'; import { RouterGeneratorMetadata, ProcedureGeneratorMetadata, @@ -11,6 +11,7 @@ import { DecoratorGenerator } from './decorator.generator'; import { Inject, Injectable } from '@nestjs/common'; import { camelCase } from 'lodash'; import { ProcedureGenerator } from './procedure.generator'; +import { findClassInPath } from '../utils/find-class-in-path'; @Injectable() export class RouterGenerator { @@ -43,18 +44,18 @@ export class RouterGenerator { } private serializeRouterProcedures( - routerFilePath: string, + routerFilePath: string[], procedure: ProcedureFactoryMetadata, routerName: string, project: Project, ): ProcedureGeneratorMetadata { - const sourceFile = project.addSourceFileAtPath(routerFilePath); - const classDeclaration = sourceFile.getClass(routerName); + const classFromPath = findClassInPath(project, routerFilePath, routerName); - if (!classDeclaration) { + if (!classFromPath) { throw new Error(`Could not find router ${routerName} class declaration.`); } + const { sourceFile, classDeclaration } = classFromPath; const methodDeclaration = classDeclaration.getMethod(procedure.name); if (!methodDeclaration) { diff --git a/packages/nestjs-trpc/lib/interfaces/factory.interface.ts b/packages/nestjs-trpc/lib/interfaces/factory.interface.ts index a6caf12..1908131 100644 --- a/packages/nestjs-trpc/lib/interfaces/factory.interface.ts +++ b/packages/nestjs-trpc/lib/interfaces/factory.interface.ts @@ -57,7 +57,7 @@ export interface CustomProcedureFactoryMetadata { export interface RouterInstance { name: string; - path: string; + path: string[]; instance: unknown; middlewares: Array | Constructor>; alias?: string; @@ -65,7 +65,7 @@ export interface RouterInstance { export interface RoutersFactoryMetadata { name: string; - path: string; + path: string[]; alias?: string; instance: RouterInstance; procedures: Array; diff --git a/packages/nestjs-trpc/lib/scanners/file.scanner.ts b/packages/nestjs-trpc/lib/scanners/file.scanner.ts index 6ae3793..26e50c5 100644 --- a/packages/nestjs-trpc/lib/scanners/file.scanner.ts +++ b/packages/nestjs-trpc/lib/scanners/file.scanner.ts @@ -9,7 +9,7 @@ import { SourceMapping } from '../interfaces/scanner.interface'; */ @Injectable() export class FileScanner { - public getCallerFilePath(skip: number = 2): string { + public getCallerFilePath(skip: number = 2): string[] { const originalPrepareStackTrace = Error.prepareStackTrace; Error.prepareStackTrace = (_, stack) => stack; @@ -18,28 +18,38 @@ export class FileScanner { Error.prepareStackTrace = originalPrepareStackTrace; - const caller = stack[skip]; - const jsFilePath = caller?.getFileName(); + const callers = stack.slice(skip); + const jsFilePaths: string[] = []; - if (jsFilePath == null) { - throw new Error(`Could not find caller file: ${caller}`); + for (const caller of callers) { + const fileName = caller.getFileName(); + + if (fileName && !fileName.startsWith('node:')) { + jsFilePaths.push(fileName); + } } - try { - // Attempt to find the source map file and extract the original TypeScript path - const sourceMap = this.getSourceMapFromJSPath(jsFilePath); - return this.normalizePath( - path.resolve(jsFilePath, '..', sourceMap.sources[0]), - ); - } catch (error) { - // Suppress the warning if in test environment - if (process.env.NODE_ENV !== 'test') { - console.warn( - `Warning: Could not resolve source map for ${jsFilePath}. Falling back to default path resolution.`, + if (!jsFilePaths || jsFilePaths.length === 0) { + throw new Error(`Could not find caller file: ${callers}`); + } + + return jsFilePaths.map((jsFilePath) => { + try { + // Attempt to find the source map file and extract the original TypeScript path + const sourceMap = this.getSourceMapFromJSPath(jsFilePath); + return this.normalizePath( + path.resolve(jsFilePath, '..', sourceMap.sources[0]), ); + } catch (error) { + // Suppress the warning if in test environment + if (process.env.NODE_ENV !== 'test' && jsFilePath.endsWith('.ts')) { + console.warn( + `Warning: Could not resolve source map for ${jsFilePath}. Falling back to default path resolution.`, + ); + } + return this.normalizePath(jsFilePath); } - return this.normalizePath(jsFilePath); - } + }); } private normalizePath(p: string): string { diff --git a/packages/nestjs-trpc/lib/trpc.module.ts b/packages/nestjs-trpc/lib/trpc.module.ts index 2c393be..77ed488 100644 --- a/packages/nestjs-trpc/lib/trpc.module.ts +++ b/packages/nestjs-trpc/lib/trpc.module.ts @@ -51,10 +51,11 @@ export class TRPCModule implements OnModuleInit { if (options.autoSchemaFile != null) { const fileScanner = new FileScanner(); const callerFilePath = fileScanner.getCallerFilePath(); + const rootModuleFilePath = callerFilePath[0]; imports.push( GeneratorModule.forRoot({ outputDirPath: options.autoSchemaFile, - rootModuleFilePath: callerFilePath, + rootModuleFilePath: rootModuleFilePath, schemaFileImports: options.schemaFileImports, context: options.context, }), diff --git a/packages/nestjs-trpc/lib/utils/__tests__/find-class-in-path.spec.ts b/packages/nestjs-trpc/lib/utils/__tests__/find-class-in-path.spec.ts new file mode 100644 index 0000000..0e2b9be --- /dev/null +++ b/packages/nestjs-trpc/lib/utils/__tests__/find-class-in-path.spec.ts @@ -0,0 +1,73 @@ +import { Project, SourceFile } from 'ts-morph'; +import { findClassInPath } from '../find-class-in-path'; + +describe('find-class-in-path', () => { + let project: Project; + const routerName = "TestRouter" + const routerFileName = "test.router.ts" + let routerSourceFile: SourceFile; + const decoratorFileName = "test.decorator.ts" + let decoratorSourceFile: SourceFile; + + beforeEach(async () => { + project = new Project(); + + decoratorSourceFile = project.createSourceFile(decoratorFileName, + ` + import { applyDecorators } from '@nestjs/common'; + import { UseMiddlewares, Router } from 'nestjs-trpc'; + import { Middleware1, Middleware2 } from "./middlewares; + + export const MyCustomRoute = (options: { alias: string }) => { + return applyDecorators( + Router({ alias: options.alias }), + UseMiddlewares(Middleware1, Middleware2), + ); + }; + `, { overwrite: true }); + + routerSourceFile = project.createSourceFile( + routerFileName, + ` + @MyCustomRoute({ alias: 'classTest' }) + export class ${routerName} {} + `, { overwrite: true } + ); + }); + + it('should return undefined when path is empty', () => { + expect(findClassInPath(project, [], "SomeClass")).toBeUndefined(); + }); + + it('should return undefined when the path has no classes', () => { + const path = [decoratorFileName] + jest.spyOn(project, 'addSourceFileAtPath').mockReturnValueOnce(decoratorSourceFile); + + const foundClass = findClassInPath(project, path, routerName); + expect(foundClass).toBeUndefined(); + }); + + it('should return a class at start of the path call stack', () => { + const path = [routerFileName] + jest.spyOn(project, 'addSourceFileAtPath').mockReturnValueOnce(routerSourceFile); + + const foundClass = findClassInPath(project, path, routerName); + expect(foundClass).toBeDefined() + expect(foundClass?.classDeclaration).toBeDefined() + expect(foundClass?.sourceFile).toEqual(routerSourceFile) + }); + + it('should return a class that is further down the path call stack', () => { + const path = [decoratorFileName, routerFileName] + jest.spyOn(project, 'addSourceFileAtPath').mockReturnValueOnce(decoratorSourceFile); + jest.spyOn(project, 'addSourceFileAtPath').mockReturnValueOnce(routerSourceFile); + + const foundClass = findClassInPath(project, path, routerName); + expect(foundClass).toBeDefined() + expect(foundClass?.classDeclaration).toBeDefined() + expect(foundClass?.sourceFile).toEqual(routerSourceFile) + expect(foundClass?.sourceFile.getFilePath()).not.toContain(decoratorFileName); + expect(foundClass?.sourceFile.getFilePath()).toContain(routerFileName); + }); + +}); \ No newline at end of file diff --git a/packages/nestjs-trpc/lib/utils/find-class-in-path.ts b/packages/nestjs-trpc/lib/utils/find-class-in-path.ts new file mode 100644 index 0000000..305ce40 --- /dev/null +++ b/packages/nestjs-trpc/lib/utils/find-class-in-path.ts @@ -0,0 +1,23 @@ +import { ClassDeclaration, Project, SourceFile } from 'ts-morph'; + +export const findClassInPath = ( + project: Project, + path: string[], + className: string, +): + | { sourceFile: SourceFile; classDeclaration: ClassDeclaration } + | undefined => { + for (const p of path) { + const sourceFile = project.addSourceFileAtPath(p); + const classDeclaration = sourceFile.getClass(className); + + if (classDeclaration !== undefined) { + return { + sourceFile, + classDeclaration, + }; + } + } + + return undefined; +};