diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 180d579c2..e3ff3dd52 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,8 +8,10 @@ import { generateRoutes } from './module/generate-routes'; import { generateSpec } from './module/generate-spec'; import { fsExists, fsReadFile } from './utils/fs'; import { AbstractRouteGenerator } from './routeGeneration/routeGenerator'; -import { extname, isAbsolute } from 'node:path'; +import { extname, isAbsolute, dirname, join } from 'node:path'; +import * as ts from 'typescript'; import type { CompilerOptions } from 'typescript'; +const { sys: tsSys, findConfigFile, readConfigFile, parseJsonConfigFileContent, flattenDiagnosticMessageText } = ts; const workingDir: string = process.cwd(); @@ -88,8 +90,75 @@ const resolveConfig = async (config?: string | Config): Promise => { return typeof config === 'object' ? config : getConfig(config); }; -const validateCompilerOptions = (config?: Record): CompilerOptions => { - return (config || {}) as CompilerOptions; +const readTsConfig = (tsconfigPath: string, workingDir: string): CompilerOptions => { + let resolvedPath: string | undefined; + + if (isAbsolute(tsconfigPath)) { + resolvedPath = tsconfigPath; + } else { + // Use TypeScript's findConfigFile to search up the directory tree (like TypeScript does) + // eslint-disable-next-line @typescript-eslint/unbound-method + resolvedPath = findConfigFile(workingDir, tsSys.fileExists, tsconfigPath); + + if (!resolvedPath) { + // If not found, try relative to working directory + const fullPath = join(workingDir, tsconfigPath); + if (tsSys.fileExists(fullPath)) { + resolvedPath = fullPath; + } + } + } + + if (!resolvedPath || !tsSys.fileExists(resolvedPath)) { + const notFoundError = new Error(`tsconfig.json not found at '${tsconfigPath}'`); + (notFoundError as Error & { code: string }).code = 'ENOENT'; + throw notFoundError; + } + + // Read tsconfig.json file using TypeScript's API + // eslint-disable-next-line @typescript-eslint/unbound-method + const tsconfigFile = readConfigFile(resolvedPath, tsSys.readFile); + + if (tsconfigFile.error) { + const errorMessage = flattenDiagnosticMessageText(tsconfigFile.error.messageText, tsSys.newLine); + throw new Error(`Error reading tsconfig.json at '${tsconfigPath}': ${errorMessage}`); + } + + // Parse and resolve extends + const parsed = parseJsonConfigFileContent(tsconfigFile.config, tsSys, dirname(resolvedPath)); + + return parsed.options; +}; + +export const validateCompilerOptions = (config: Config, workingDir: string): CompilerOptions => { + let compilerOptions: CompilerOptions = {}; + + // First, read compilerOptions from tsconfig.json if specified + const tsconfigPath = config.tsconfig || 'tsconfig.json'; + try { + compilerOptions = readTsConfig(tsconfigPath, workingDir); + } catch (err) { + // If tsconfig.json doesn't exist and no explicit tsconfig path was provided, that's okay + // Check if it's a file not found error + const isFileNotFound = err instanceof Error && (('code' in err && err.code === 'ENOENT') || err.message.includes('ENOENT')); + + if (config.tsconfig || !isFileNotFound) { + throw err; + } + // If no explicit tsconfig was provided and file doesn't exist, continue with empty compilerOptions + } + + // Then merge with compilerOptions from tsoa.json (tsoa.json takes precedence) + if (config.compilerOptions) { + // Convert string values to proper TypeScript enum values by parsing a temporary config + const tempConfig = { + compilerOptions: { ...compilerOptions, ...config.compilerOptions }, + }; + const parsed = parseJsonConfigFileContent(tempConfig, tsSys, workingDir); + compilerOptions = parsed.options; + } + + return compilerOptions; }; export interface ExtendedSpecConfig extends SpecConfig { @@ -337,7 +406,7 @@ async function SpecGenerator(args: SwaggerArgs) { config.spec.yaml = false; } - const compilerOptions = validateCompilerOptions(config.compilerOptions); + const compilerOptions = validateCompilerOptions(config, workingDir); const swaggerConfig = await validateSpecConfig(config); await generateSpec(swaggerConfig, compilerOptions, config.ignore); @@ -355,7 +424,7 @@ async function routeGenerator(args: ConfigArgs) { config.routes.basePath = args.basePath; } - const compilerOptions = validateCompilerOptions(config.compilerOptions); + const compilerOptions = validateCompilerOptions(config, workingDir); const routesConfig = await validateRoutesConfig(config); await generateRoutes(routesConfig, compilerOptions, config.ignore); @@ -382,7 +451,7 @@ export async function generateSpecAndRoutes(args: SwaggerArgs, metadata?: Tsoa.M config.spec.yaml = false; } - const compilerOptions = validateCompilerOptions(config.compilerOptions); + const compilerOptions = validateCompilerOptions(config, workingDir); const routesConfig = await validateRoutesConfig(config); const swaggerConfig = await validateSpecConfig(config); diff --git a/packages/runtime/src/config.ts b/packages/runtime/src/config.ts index ab0463de7..325a476cd 100644 --- a/packages/runtime/src/config.ts +++ b/packages/runtime/src/config.ts @@ -33,7 +33,17 @@ export interface Config { noImplicitAdditionalProperties?: 'throw-on-extras' | 'silently-remove-extras' | 'ignore'; /** - * Typescript CompilerOptions to be used during generation + * Path to tsconfig.json file to read compilerOptions from. + * If not specified, defaults to 'tsconfig.json' in the working directory. + * The compilerOptions from tsconfig.json will be merged with compilerOptions + * from this config, with this config's compilerOptions taking precedence. + */ + tsconfig?: string; + + /** + * Typescript CompilerOptions to be used during generation. + * These will be merged with compilerOptions from tsconfig.json (if specified), + * with these options taking precedence over tsconfig.json. * * @type {Record} * @memberof RoutesConfig diff --git a/tests/unit/swagger/compilerOptions.spec.ts b/tests/unit/swagger/compilerOptions.spec.ts new file mode 100644 index 000000000..92b928682 --- /dev/null +++ b/tests/unit/swagger/compilerOptions.spec.ts @@ -0,0 +1,327 @@ +import { expect } from 'chai'; +import 'mocha'; +import { validateCompilerOptions } from '@tsoa/cli/cli'; +import { Config } from '@tsoa/runtime'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import { tmpdir } from 'os'; +import * as ts from 'typescript'; + +const fsWriteFile = promisify(fs.writeFile); +const fsUnlink = promisify(fs.unlink); +const fsMkdir = promisify(fs.mkdir); +const fsRmdir = promisify(fs.rmdir); +const fsRm = promisify(fs.rm); + +describe('CompilerOptions', () => { + let testDir: string; + + beforeEach(async () => { + // Create a temporary directory for each test + testDir = path.join(tmpdir(), `tsoa-test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); + await fsMkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up temporary directory recursively + try { + await fsRm(testDir, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + } + }); + + describe('validateCompilerOptions', () => { + it('should read compilerOptions from tsconfig.json', async () => { + // Create a tsconfig.json with compilerOptions + const tsconfigPath = path.join(testDir, 'tsconfig.json'); + await fsWriteFile( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + target: 'es2020', + module: 'commonjs', + strict: true, + experimentalDecorators: true, + }, + }), + 'utf8', + ); + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2020); + expect(compilerOptions.module).to.equal(ts.ModuleKind.CommonJS); + expect(compilerOptions.strict).to.be.true; + expect(compilerOptions.experimentalDecorators).to.be.true; + }); + + it('should support tsconfig.json with comments and trailing commas', async () => { + // Create a tsconfig.json with comments and trailing commas (like real tsconfig.json) + const tsconfigPath = path.join(testDir, 'tsconfig.json'); + await fsWriteFile( + tsconfigPath, + `{ + "compilerOptions": { + "target": "es2021", // This is a comment + "module": "commonjs", + "strict": true, + "experimentalDecorators": true, // trailing comma + } +}`, + 'utf8', + ); + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2021); + expect(compilerOptions.module).to.equal(ts.ModuleKind.CommonJS); + expect(compilerOptions.strict).to.be.true; + expect(compilerOptions.experimentalDecorators).to.be.true; + }); + + it('should merge compilerOptions from tsoa.json over tsconfig.json', async () => { + // Create a tsconfig.json + const tsconfigPath = path.join(testDir, 'tsconfig.json'); + await fsWriteFile( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + target: 'es2020', + module: 'commonjs', + strict: true, + experimentalDecorators: true, + }, + }), + 'utf8', + ); + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + compilerOptions: { + target: 'es2015', // Override tsconfig.json + module: 'esnext', // Override tsconfig.json + // strict and experimentalDecorators should come from tsconfig.json + }, + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + // tsoa.json options should take precedence + // TypeScript's parseJsonConfigFileContent converts strings to enum values + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2015); + expect(compilerOptions.module).to.equal(ts.ModuleKind.ESNext); + // Options not in tsoa.json should come from tsconfig.json + expect(compilerOptions.strict).to.be.true; + expect(compilerOptions.experimentalDecorators).to.be.true; + }); + + it('should use custom tsconfig path when specified', async () => { + // Create a custom tsconfig file + const customTsconfigPath = path.join(testDir, 'custom-tsconfig.json'); + await fsWriteFile( + customTsconfigPath, + JSON.stringify({ + compilerOptions: { + target: 'es2018', + module: 'es2015', + }, + }), + 'utf8', + ); + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + tsconfig: 'custom-tsconfig.json', + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2018); + expect(compilerOptions.module).to.equal(ts.ModuleKind.ES2015); + }); + + it('should use tsconfig.a.json when specified', async () => { + // Create a tsconfig.a.json file + const tsconfigAPath = path.join(testDir, 'tsconfig.a.json'); + await fsWriteFile( + tsconfigAPath, + JSON.stringify({ + compilerOptions: { + target: 'es2019', + module: 'esnext', + strict: true, + experimentalDecorators: true, + }, + }), + 'utf8', + ); + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + tsconfig: 'tsconfig.a.json', + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2019); + expect(compilerOptions.module).to.equal(ts.ModuleKind.ESNext); + expect(compilerOptions.strict).to.be.true; + expect(compilerOptions.experimentalDecorators).to.be.true; + }); + + it('should find tsconfig.json in the parent directory', async () => { + // Create a parent directory structure + // testDir (parent) contains tsconfig.json + // testDir/child (child) is the working directory + const parentDir = testDir; + const childDir = path.join(testDir, 'child'); + + // Create child directory + await fsMkdir(childDir, { recursive: true }); + + // Create tsconfig.json in parent directory + const tsconfigPath = path.join(parentDir, 'tsconfig.json'); + await fsWriteFile( + tsconfigPath, + JSON.stringify({ + compilerOptions: { + target: 'es2022', + module: 'node16', + strict: true, + experimentalDecorators: true, + esModuleInterop: true, + }, + }), + 'utf8', + ); + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + // Don't specify tsconfig - should find parent's tsconfig.json + }; + + // Call from child directory - should find parent's tsconfig.json + const compilerOptions = validateCompilerOptions(config, childDir); + + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2022); + expect(compilerOptions.module).to.equal(ts.ModuleKind.Node16); + expect(compilerOptions.strict).to.be.true; + expect(compilerOptions.experimentalDecorators).to.be.true; + expect(compilerOptions.esModuleInterop).to.be.true; + }); + + it('should handle missing tsconfig.json gracefully when not explicitly specified', async () => { + // Don't create tsconfig.json + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + // Should return empty compilerOptions + expect(compilerOptions).to.deep.equal({}); + }); + + it('should throw error when explicitly specified tsconfig.json is missing', async () => { + // Don't create tsconfig.json + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + tsconfig: 'custom-tsconfig.json', // Explicitly specified but doesn't exist + }; + + try { + validateCompilerOptions(config, testDir); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect((err as Error).message).to.include('custom-tsconfig.json'); + } + }); + + it('should use only tsoa.json compilerOptions when tsconfig.json is missing', async () => { + // Don't create tsconfig.json + + const config: Config = { + entryFile: './test.ts', + spec: { + outputDirectory: './dist', + }, + routes: { + routesDir: './routes', + }, + compilerOptions: { + target: 'es2017', + module: 'amd', + strict: false, + }, + }; + + const compilerOptions = validateCompilerOptions(config, testDir); + + // TypeScript's parseJsonConfigFileContent converts strings to enum values + expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2017); + expect(compilerOptions.module).to.equal(ts.ModuleKind.AMD); + expect(compilerOptions.strict).to.be.false; + }); + }); +}); diff --git a/tests/unit/swagger/pathGeneration/rootSecurityRoutes.spec.ts b/tests/unit/swagger/pathGeneration/rootSecurityRoutes.spec.ts index 574d0e766..0c476d2c6 100644 --- a/tests/unit/swagger/pathGeneration/rootSecurityRoutes.spec.ts +++ b/tests/unit/swagger/pathGeneration/rootSecurityRoutes.spec.ts @@ -8,13 +8,11 @@ import type { Swagger } from '@tsoa/runtime'; describe('Security route generation with root security', () => { describe('with @Security() on controller', () => { - const noSecurityControllerMetadata = new MetadataGenerator( - './fixtures/controllers/noSecurityController.ts', - undefined, - undefined, - undefined, [{ - root_level_auth: [] - }]).Generate(); + const noSecurityControllerMetadata = new MetadataGenerator('./fixtures/controllers/noSecurityController.ts', undefined, undefined, undefined, [ + { + root_level_auth: [], + }, + ]).Generate(); const noSecuritySpec = new SpecGenerator2(noSecurityControllerMetadata, getDefaultExtendedOptions()).GetSpec(); it('should use the method level security over root/controller security', () => { @@ -46,17 +44,14 @@ describe('Security route generation with root security', () => { expect(path.get.security).to.deep.equal([{ tsoa_auth: ['write:pets', 'read:pets'] }]); }); - }); describe('with undefined controller level security', () => { - const plainControllerMetadata = new MetadataGenerator( - './fixtures/controllers/pathlessGetController.ts', - undefined, - undefined, - undefined, [{ - root_level_auth: [] - }]).Generate(); + const plainControllerMetadata = new MetadataGenerator('./fixtures/controllers/pathlessGetController.ts', undefined, undefined, undefined, [ + { + root_level_auth: [], + }, + ]).Generate(); const plainSpec = new SpecGenerator2(plainControllerMetadata, getDefaultExtendedOptions()).GetSpec(); it('should use root level security if no security defined on method', () => { @@ -66,21 +61,20 @@ describe('Security route generation with root security', () => { throw new Error('No get operation.'); } - expect(path.get.security).to.deep.equal([{ - root_level_auth: [] - }]); + expect(path.get.security).to.deep.equal([ + { + root_level_auth: [], + }, + ]); }); - }); describe('with @NoSecurity() on controller', () => { - const noSecurityControllerMetadata = new MetadataGenerator( - './fixtures/controllers/noSecurityOnController.ts', - undefined, - undefined, - undefined, [{ - root_level_auth: [] - }]).Generate(); + const noSecurityControllerMetadata = new MetadataGenerator('./fixtures/controllers/noSecurityOnController.ts', undefined, undefined, undefined, [ + { + root_level_auth: [], + }, + ]).Generate(); const noSecurityOnControllerSpec = new SpecGenerator2(noSecurityControllerMetadata, getDefaultExtendedOptions()).GetSpec(); it('should use the method level security over root/controller security', () => { @@ -112,8 +106,7 @@ describe('Security route generation with root security', () => { expect(path.get.security).to.deep.equal([]); }); - - }) + }); function verifyPath(spec: Swagger.Spec2, route: string, model = 'UserResponseModel') { return VerifyPath(spec, route, path => path.get, undefined, false, `#/definitions/${model}`);