#!/usr/bin/env node import * as path from 'path'; import * as ts from 'typescript'; import * as YAML from 'yamljs'; import * as yargs from 'yargs'; import { RoutesConfig } from '@tsoa/runtime'; import { generateSpec } from './module/generate-spec'; import { fsExists, fsReadFile } from './utils/fs'; import { registerRouteMockApi } from './module/mock-server.run'; const swaggerUi = require('swagger-ui-express'); import swaggerUi from 'swagger-ui-express'; import express = require('express'); import { Config, SpecConfig } from './model/config'; const workingDir: string = process.cwd(); let packageJson: any; const getPackageJsonValue = async (key: string, defaultValue = ''): Promise => { if (!packageJson) { try { const packageJsonRaw = await fsReadFile(`${workingDir}/package.json`); packageJson = JSON.parse(packageJsonRaw.toString('utf8')); } catch (err) { return defaultValue; } } return packageJson[key] || ''; }; const nameDefault = () => getPackageJsonValue('name', 'TSOA'); const versionDefault = () => getPackageJsonValue('version', '1.0.0'); const descriptionDefault = () => getPackageJsonValue('description', 'Build swagger-compliant REST APIs using TypeScript and Node'); const licenseDefault = () => getPackageJsonValue('license', 'MIT'); const determineNoImplicitAdditionalSetting = (noImplicitAdditionalProperties: Config['noImplicitAdditionalProperties']): Exclude => { if (noImplicitAdditionalProperties === 'silently-remove-extras' || noImplicitAdditionalProperties === 'throw-on-extras' || noImplicitAdditionalProperties === 'ignore') { return noImplicitAdditionalProperties; } else { return 'ignore'; } }; const authorInformation: Promise< | string | { name?: string; email?: string; url?: string; } > = getPackageJsonValue('author', 'unknown'); const getConfig = async (configPath = 'tsoa.json'): Promise => { let config: Config; try { const ext = path.extname(configPath); if (ext === '.yaml' || ext === '.yml') { config = YAML.load(configPath); } else { const configRaw = await fsReadFile(`${workingDir}/${configPath}`); config = JSON.parse(configRaw.toString('utf8')); } } catch (err) { if (err.code === 'MODULE_NOT_FOUND') { throw Error(`No config file found at '${configPath}'`); } else if (err.name === 'SyntaxError') { // eslint-disable-next-line no-console console.error(err); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw Error(`Invalid JSON syntax in config at '${configPath}': ${err.message}`); } else { // eslint-disable-next-line no-console console.error(err); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw Error(`Unhandled error encountered loading '${configPath}': ${err.message}`); } } return config; }; const validateCompilerOptions = (config?: Record): ts.CompilerOptions => { return (config || {}) as ts.CompilerOptions; }; export interface ExtendedSpecConfig extends SpecConfig { entryFile: Config['entryFile']; noImplicitAdditionalProperties: Exclude; controllerPathGlobs?: Config['controllerPathGlobs']; } export const validateSpecConfig = async (config: Config): Promise => { if (!config.spec) { throw new Error('Missing spec: configuration must contain spec. Spec used to be called swagger in previous versions of tsoa.'); } if (!config.spec.outputDirectory) { throw new Error('Missing outputDirectory: configuration must contain output directory.'); } if (!config.entryFile && (!config.controllerPathGlobs || !config.controllerPathGlobs.length)) { throw new Error('Missing entryFile and controllerPathGlobs: Configuration must contain an entry point file or controller path globals.'); } if (!!config.entryFile && !(await fsExists(config.entryFile))) { throw new Error(`EntryFile not found: ${config.entryFile} - please check your tsoa config.`); } config.spec.version = config.spec.version || (await versionDefault()); config.spec.specVersion = config.spec.specVersion || 2; if (config.spec.specVersion !== 2 && config.spec.specVersion !== 3) { throw new Error('Unsupported Spec version.'); } if (config.spec.spec && !['immediate', 'recursive', 'deepmerge', undefined].includes(config.spec.specMerging)) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid specMerging config: ${config.spec.specMerging}`); } const noImplicitAdditionalProperties = determineNoImplicitAdditionalSetting(config.noImplicitAdditionalProperties); config.spec.name = config.spec.name || (await nameDefault()); config.spec.description = config.spec.description || (await descriptionDefault()); config.spec.license = config.spec.license || (await licenseDefault()); config.spec.basePath = config.spec.basePath || '/'; if (!config.spec.contact) { config.spec.contact = {}; } const author = await authorInformation; if (typeof author === 'string') { const contact = /^([^<(]*)?\s*(?:<([^>(]*)>)?\s*(?:\(([^)]*)\)|$)/m.exec(author); config.spec.contact.name = config.spec.contact.name || contact?.[1]; config.spec.contact.email = config.spec.contact.email || contact?.[2]; config.spec.contact.url = config.spec.contact.url || contact?.[3]; } else if (typeof author === 'object') { config.spec.contact.name = config.spec.contact.name || author?.name; config.spec.contact.email = config.spec.contact.email || author?.email; config.spec.contact.url = config.spec.contact.url || author?.url; } return { ...config.spec, noImplicitAdditionalProperties, entryFile: config.entryFile, controllerPathGlobs: config.controllerPathGlobs, }; }; export interface ExtendedRoutesConfig extends RoutesConfig { entryFile: Config['entryFile']; noImplicitAdditionalProperties: Exclude; controllerPathGlobs?: Config['controllerPathGlobs']; } const validateRoutesConfig = async (config: Config): Promise => { if (!config.entryFile && (!config.controllerPathGlobs || !config.controllerPathGlobs.length)) { throw new Error('Missing entryFile and controllerPathGlobs: Configuration must contain an entry point file or controller path globals.'); } if (!!config.entryFile && !(await fsExists(config.entryFile))) { throw new Error(`EntryFile not found: ${config.entryFile} - Please check your tsoa config.`); } if (!config.routes.routesDir) { throw new Error('Missing routesDir: Configuration must contain a routes file output directory.'); } if (config.routes.authenticationModule && !((await fsExists(config.routes.authenticationModule)) || (await fsExists(config.routes.authenticationModule + '.ts')))) { throw new Error(`No authenticationModule file found at '${config.routes.authenticationModule}'`); } if (config.routes.iocModule && !((await fsExists(config.routes.iocModule)) || (await fsExists(config.routes.iocModule + '.ts')))) { throw new Error(`No iocModule file found at '${config.routes.iocModule}'`); } const noImplicitAdditionalProperties = determineNoImplicitAdditionalSetting(config.noImplicitAdditionalProperties); config.routes.basePath = config.routes.basePath || '/'; config.routes.middleware = config.routes.middleware || 'express'; return { ...config.routes, entryFile: config.entryFile, noImplicitAdditionalProperties, controllerPathGlobs: config.controllerPathGlobs, }; }; const configurationArgs: yargs.Options = { alias: 'c', describe: 'tsoa configuration file; default is tsoa.json in the working directory', required: false, type: 'string', }; const hostArgs: yargs.Options = { describe: 'API host', required: false, type: 'string', }; const basePathArgs: yargs.Options = { describe: 'Base API path', required: false, type: 'string', }; const yarmlArgs: yargs.Options = { describe: 'Swagger spec yaml format', required: false, type: 'boolean', }; const jsonArgs: yargs.Options = { describe: 'Swagger spec json format', required: false, type: 'boolean', }; export interface ConfigArgs { basePath?: string; configuration?: string; } export interface SwaggerArgs extends ConfigArgs { host?: string; json?: boolean; yaml?: boolean; } export function runCLI(): void { yargs .usage('Usage: $0 [options]') .demand(1) .command( 'spec', 'Generate OpenAPI spec', { basePath: basePathArgs, configuration: configurationArgs, host: hostArgs, json: jsonArgs, yaml: yarmlArgs, }, SpecGenerator as any, ) .command( 'swagger-and-mock-server-api', 'Generate OpenAPI spec and routes', { basePath: basePathArgs, configuration: configurationArgs, host: hostArgs, json: jsonArgs, yaml: yarmlArgs, }, RunBothSwaggerAndMockServer as any, ) .command( 'mock-server-api', 'Run Mock Server', { basePath: basePathArgs, configuration: configurationArgs, host: hostArgs, json: jsonArgs, yaml: yarmlArgs, }, GenerateOpenApiMock as any, ) .help('help') .alias('help', 'h').argv; } if (!module.parent) runCLI(); async function SpecGenerator(args: SwaggerArgs) { try { const config = await getConfig(args.configuration); if (args.basePath) { config.spec.basePath = args.basePath; } if (args.yaml) { config.spec.yaml = args.yaml; } if (args.json) { config.spec.yaml = false; } const compilerOptions = validateCompilerOptions(config.compilerOptions); const swaggerConfig = await validateSpecConfig(config); const routesConfig = await validateRoutesConfig(config); await generateSpec(swaggerConfig, routesConfig, compilerOptions, config.ignore); } catch (err) { // eslint-disable-next-line no-console console.error('Generate swagger error.\n', err); process.exit(1); } } async function GenerateOpenApiMock(args: SwaggerArgs) { try { const config = await getConfig(args.configuration); if (args.basePath) { config.spec.basePath = args.basePath; } if (args.yaml) { config.spec.yaml = args.yaml; } if (args.json) { config.spec.yaml = false; } const server = express(); const compilerOptions = validateCompilerOptions(config.compilerOptions); const swaggerConfig = await validateSpecConfig(config); const routesConfig = await validateRoutesConfig(config); registerRouteMockApi(swaggerConfig, routesConfig, server, compilerOptions); // Start server const port = 3000; server.listen(port, (): void => { console.log(`> Ready on http://localhost:${port}`); }); } catch (err) { // eslint-disable-next-line no-console console.error('Generate swagger error.\n', err); process.exit(1); } } async function RunBothSwaggerAndMockServer(args: SwaggerArgs) { try { const config = await getConfig(args.configuration); if (args.basePath) { config.spec.basePath = args.basePath; } if (args.yaml) { config.spec.yaml = args.yaml; } if (args.json) { config.spec.yaml = false; } const server = express(); const compilerOptions = validateCompilerOptions(config.compilerOptions); const swaggerConfig = await validateSpecConfig(config); const routesConfig = await validateRoutesConfig(config); await generateSpec(swaggerConfig, routesConfig, compilerOptions, config.ignore); server.use(express.static("public")); server.use( '/docs', swaggerUi.serve, swaggerUi.setup(undefined, { swaggerOptions: { url: '/swagger.json', }, }), ); registerRouteMockApi(swaggerConfig, routesConfig, server, compilerOptions); // Start server const port = 3000; server.listen(port, (): void => { console.log(`> Ready on http://localhost:${port}`); }); } catch (err) { // eslint-disable-next-line no-console console.error('Generate swagger error.\n', err); process.exit(1); } }