Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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 workingDir: string = process.cwd();
Expand Down Expand Up @@ -88,8 +89,70 @@ const resolveConfig = async (config?: string | Config): Promise<Config> => {
return typeof config === 'object' ? config : getConfig(config);
};

const validateCompilerOptions = (config?: Record<string, unknown>): CompilerOptions => {
return (config || {}) as CompilerOptions;
const readTsConfig = async (tsconfigPath: string, workingDir: string): Promise<CompilerOptions> => {
const fullPath = isAbsolute(tsconfigPath) ? tsconfigPath : join(workingDir, tsconfigPath);

try {
const configFileText = await fsReadFile(fullPath);
const result = ts.readConfigFile(fullPath, () => configFileText.toString('utf8'));

if (result.error) {
throw new Error(`Error reading tsconfig.json at '${tsconfigPath}': ${result.error.messageText.toString()}`);
}

const parsed = ts.parseJsonConfigFileContent(
result.config,
ts.sys,
dirname(fullPath)
);

return parsed.options;
} catch (err) {
// Re-throw with more context, preserving the original error code if it exists
if (err instanceof Error) {
const errorWithCode = err as Error & { code?: string };
if (errorWithCode.code === 'ENOENT') {
// Preserve the ENOENT error so it can be caught upstream
const notFoundError = new Error(`tsconfig.json not found at '${tsconfigPath}'`);
(notFoundError as Error & { code: string }).code = 'ENOENT';
throw notFoundError;
}
throw new Error(`Failed to read tsconfig.json at '${tsconfigPath}': ${err.message}`);
}
throw err;
}
};

export const validateCompilerOptions = async (config: Config, workingDir: string): Promise<CompilerOptions> => {
let compilerOptions: CompilerOptions = {};

// First, read compilerOptions from tsconfig.json if specified
const tsconfigPath = config.tsconfig || 'tsconfig.json';
try {
compilerOptions = await 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 = ts.parseJsonConfigFileContent(tempConfig, ts.sys, workingDir);
compilerOptions = parsed.options;
}

return compilerOptions;
};

export interface ExtendedSpecConfig extends SpecConfig {
Expand Down Expand Up @@ -337,7 +400,7 @@ async function SpecGenerator(args: SwaggerArgs) {
config.spec.yaml = false;
}

const compilerOptions = validateCompilerOptions(config.compilerOptions);
const compilerOptions = await validateCompilerOptions(config, workingDir);
const swaggerConfig = await validateSpecConfig(config);

await generateSpec(swaggerConfig, compilerOptions, config.ignore);
Expand All @@ -355,7 +418,7 @@ async function routeGenerator(args: ConfigArgs) {
config.routes.basePath = args.basePath;
}

const compilerOptions = validateCompilerOptions(config.compilerOptions);
const compilerOptions = await validateCompilerOptions(config, workingDir);
const routesConfig = await validateRoutesConfig(config);

await generateRoutes(routesConfig, compilerOptions, config.ignore);
Expand All @@ -382,7 +445,7 @@ export async function generateSpecAndRoutes(args: SwaggerArgs, metadata?: Tsoa.M
config.spec.yaml = false;
}

const compilerOptions = validateCompilerOptions(config.compilerOptions);
const compilerOptions = await validateCompilerOptions(config, workingDir);
const routesConfig = await validateRoutesConfig(config);
const swaggerConfig = await validateSpecConfig(config);

Expand Down
12 changes: 11 additions & 1 deletion packages/runtime/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>}
* @memberof RoutesConfig
Expand Down
248 changes: 248 additions & 0 deletions tests/unit/swagger/compilerOptions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
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);

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
try {
const files = await promisify(fs.readdir)(testDir);
for (const file of files) {
await promisify(fs.unlink)(path.join(testDir, file));
}
await fsRmdir(testDir);
} 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 = await 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 = await 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 = await 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 = await validateCompilerOptions(config, testDir);

expect(compilerOptions.target).to.equal(ts.ScriptTarget.ES2018);
expect(compilerOptions.module).to.equal(ts.ModuleKind.ES2015);
});

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 = await 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 {
await 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 = await 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;
});
});
});