diff --git a/packages/cli/.yo-rc.json b/packages/cli/.yo-rc.json index 6973e6a7ffd9..65a3f195d11b 100644 --- a/packages/cli/.yo-rc.json +++ b/packages/cli/.yo-rc.json @@ -987,6 +987,14 @@ "name": "promote-anonymous-schemas", "hide": false }, + "outDir": { + "description": "Custom output directory for generated files.", + "required": false, + "default": "src", + "type": "String", + "name": "outDir", + "hide": false + }, "config": { "type": "String", "alias": "c", diff --git a/packages/cli/generators/openapi/index.js b/packages/cli/generators/openapi/index.js index c03a1811ac06..78d922f3cd8e 100644 --- a/packages/cli/generators/openapi/index.js +++ b/packages/cli/generators/openapi/index.js @@ -22,6 +22,8 @@ const DATASOURCE = 'datasources'; const SERVICE = 'services'; const g = require('../../lib/globalize'); const json5 = require('json5'); +const fs = require('fs'); +const {Project, SyntaxKind} = require('ts-morph'); const isWindows = process.platform === 'win32'; @@ -87,11 +89,30 @@ module.exports = class OpenApiGenerator extends BaseGenerator { type: Boolean, }); + this.option('outDir', { + description: g.f('Custom output directory for generated files.'), + required: false, + default: 'src', + type: String, + }); + return super._setupGenerator(); } setOptions() { - return super.setOptions(); + const result = super.setOptions(); + if (this.options.config) { + const config = + typeof this.options.config === 'string' + ? JSON.parse(this.options.config) + : this.options.config; + if (config.outDir) this.options.outDir = config.outDir; + if (config.url) this.options.url = config.url; + if (config.prefix) this.options.prefix = config.prefix; + if (config.client !== undefined) this.options.client = config.client; + if (config.server !== undefined) this.options.server = config.server; + } + return result; } checkLoopBackProject() { @@ -241,6 +262,11 @@ module.exports = class OpenApiGenerator extends BaseGenerator { log: this.log, validate: this.options.validate, promoteAnonymousSchemas: this.options['promote-anonymous-schemas'], + prefix: + this.options.outDir && this.options.outDir !== 'src' + ? '' + : this.options.prefix, + previousPrefix: this.options.previousPrefix || '', }); debugJson('OpenAPI spec', result.apiSpec); Object.assign(this, result); @@ -251,6 +277,13 @@ module.exports = class OpenApiGenerator extends BaseGenerator { async selectControllers() { if (this.shouldExit()) return; + this.controllerSpecs = this.controllerSpecs.map(c => { + if (this.options.prefix && c.tag && c.tag.includes(this.options.prefix)) { + const splited = c.tag.split(this.options.prefix); + c.tag = splited.join(''); + } + return c; + }); const choices = this.controllerSpecs.map(c => { const names = []; if (this.options.server !== false) { @@ -287,8 +320,17 @@ module.exports = class OpenApiGenerator extends BaseGenerator { ); this.selectedServices = this.selectedControllers; this.selectedControllers.forEach(c => { - c.fileName = getControllerFileName(c.tag || c.className); - c.serviceFileName = getServiceFileName(c.tag || c.serviceClassName); + const originalClassName = c.className; + const originalServiceClassName = c.serviceClassName; + + c.fileName = getControllerFileName(c.tag || originalClassName); + if ( + this.options.prefix && + (!this.options.outDir || this.options.outDir === 'src') + ) { + c.fileName = this.options.prefix.toLowerCase() + '.' + c.fileName; + } + c.serviceFileName = getServiceFileName(c.tag || originalServiceClassName); }); } @@ -302,7 +344,10 @@ module.exports = class OpenApiGenerator extends BaseGenerator { if (debug.enabled) { debug(`Artifact output filename set to: ${controllerFile}`); } - const dest = this.destinationPath(`src/controllers/${controllerFile}`); + const outDir = this.options.outDir || 'src'; + const dest = this.destinationPath( + `${outDir}/controllers/${controllerFile}`, + ); if (debug.enabled) { debug('Copying artifact to: %s', dest); } @@ -349,7 +394,10 @@ module.exports = class OpenApiGenerator extends BaseGenerator { if (debug.enabled) { debug(`Artifact output filename set to: ${dataSourceFile}`); } - const dest = this.destinationPath(`src/datasources/${dataSourceFile}`); + const outDir = this.options.outDir || 'src'; + const dest = this.destinationPath( + `${outDir}/datasources/${dataSourceFile}`, + ); if (debug.enabled) { debug('Copying artifact to: %s', dest); } @@ -372,7 +420,8 @@ module.exports = class OpenApiGenerator extends BaseGenerator { if (debug.enabled) { debug(`Artifact output filename set to: ${file}`); } - const dest = this.destinationPath(`src/services/${file}`); + const outDir = this.options.outDir || 'src'; + const dest = this.destinationPath(`${outDir}/services/${file}`); if (debug.enabled) { debug('Copying artifact to: %s', dest); } @@ -396,11 +445,33 @@ module.exports = class OpenApiGenerator extends BaseGenerator { if (debug.enabled) { debug(`Artifact output filename set to: ${modelFile}`); } - const dest = this.destinationPath(`src/models/${modelFile}`); + const outDir = this.options.outDir || 'src'; + const dest = this.destinationPath(`${outDir}/models/${modelFile}`); if (debug.enabled) { debug('Copying artifact to: %s', dest); } - const source = m.kind === 'class' ? modelSource : typeSource; + let source = m.kind === 'class' ? modelSource : typeSource; + if ( + m.kind === 'class' && + modelFile.includes('-with-relations.model.ts') + ) { + const modelSourceWithExportsOnly = this.templatePath( + 'src/models/model-template-with-re-exports-only.ts.ejs', + ); + source = modelSourceWithExportsOnly; + if (this.options.prefix) m.prefix = this.options.prefix.toLowerCase(); + const exportModelFileName = modelFile.split( + '-with-relations.model.ts', + )[0]; + let modelName = exportModelFileName; + if (m.prefix) { + const stripped = exportModelFileName.split(m.prefix + '-')[1]; + modelName = stripped ?? exportModelFileName; + } + m.exportModelName = utils.toClassName(modelName); + m.exportModelFileName = exportModelFileName + '.model'; + if (m.prefix) m.prefix = utils.toClassName(m.prefix); + } this.copyTemplatedFiles(source, dest, mixinEscapeComment(m)); } } @@ -408,7 +479,8 @@ module.exports = class OpenApiGenerator extends BaseGenerator { // update index file for models and controllers async _updateIndex(dir) { const update = async files => { - const targetDir = this.destinationPath(`src/${dir}`); + const outDir = this.options.outDir || 'src'; + const targetDir = this.destinationPath(`${outDir}/${dir}`); for (const f of files) { // Check all files being generated to ensure they succeeded const status = this.conflicter.generationStatus[f]; @@ -490,6 +562,9 @@ module.exports = class OpenApiGenerator extends BaseGenerator { this._generateServiceProxies(); await this._updateIndex(SERVICE); } + if (this.options.outDir && this.options.outDir !== 'src') { + await this._updateBootOptions(); + } } install() { @@ -525,6 +600,83 @@ module.exports = class OpenApiGenerator extends BaseGenerator { }); } + async _updateBootOptions() { + const invokedFrom = this.destinationRoot(); + const applicationPath = path.join(invokedFrom, 'src', 'application.ts'); + if (!fs.existsSync(applicationPath)) return; + + const relDir = (this.options.outDir || 'src').replace(/^src[\\\\/]/, ''); + const artifactTypes = [ + { + name: 'controllers', + dir: `${relDir}/controllers`, + extension: '.controller.js', + }, + { + name: 'datasources', + dir: `${relDir}/datasources`, + extension: '.datasource.js', + }, + {name: 'models', dir: `${relDir}/models`, extension: '.model.js'}, + {name: 'services', dir: `${relDir}/services`, extension: '.service.js'}, + ]; + + const project = new Project({}); + project.addSourceFilesAtPaths(`${invokedFrom}/src/**/*.ts`); + const applicationFile = project.getSourceFileOrThrow(applicationPath); + const constructor = applicationFile.getClasses()[0].getConstructors()[0]; + const bootOptionsObject = constructor + .getDescendantsOfKind(SyntaxKind.BinaryExpression) + .find(expr => expr.getLeft().getText() === 'this.bootOptions') + ?.getRight() + .asKindOrThrow(SyntaxKind.ObjectLiteralExpression); + this.log('bootOptions found: ' + !!bootOptionsObject); + + if (bootOptionsObject) { + for (const artifact of artifactTypes) { + let artifactProperty = bootOptionsObject.getProperty(artifact.name); + if (!artifactProperty) { + bootOptionsObject.addPropertyAssignment({ + name: artifact.name, + initializer: `{ + dirs: ['${artifact.name}'], + extensions: ['${artifact.extension}'], + nested: true, + }`, + }); + artifactProperty = bootOptionsObject.getProperty(artifact.name); + } + if (!artifactProperty) continue; + + const artifactObject = artifactProperty.getInitializerIfKindOrThrow( + SyntaxKind.ObjectLiteralExpression, + ); + let dirsProperty = artifactObject.getProperty('dirs'); + if (!dirsProperty) { + artifactObject.addPropertyAssignment({ + name: 'dirs', + initializer: `['${artifact.name}']`, + }); + dirsProperty = artifactObject.getProperty('dirs'); + } + if (!dirsProperty) continue; + + const dirsArray = dirsProperty.getInitializerIfKindOrThrow( + SyntaxKind.ArrayLiteralExpression, + ); + const exists = dirsArray.getElements().some(el => { + const literal = el.asKind(SyntaxKind.StringLiteral); + return literal?.getLiteralValue() === artifact.dir; + }); + if (!exists) { + dirsArray.addElement(`'${artifact.dir}'`); + } + } + applicationFile.formatText(); + applicationFile.saveSync(); + } + } + async end() { await super.end(); if (this.shouldExit()) return; diff --git a/packages/cli/test/fixtures/copyright/single-package/index.js b/packages/cli/test/fixtures/copyright/single-package/index.js index 2ce82f2c60e8..4fd1794103dc 100644 --- a/packages/cli/test/fixtures/copyright/single-package/index.js +++ b/packages/cli/test/fixtures/copyright/single-package/index.js @@ -1,7 +1,7 @@ -// Copyright IBM Corp. and LoopBack contributors 2020. All Rights Reserved. -// Node module: @loopback/cli -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT +// Copyright ACME Inc. 2020,2026. All Rights Reserved. +// Node module: myapp +// This file is licensed under the ISC License. +// License text available at https://www.isc.org/licenses/ // Use the same set of files (except index.js) in this folder const files = require('../index')(__dirname); diff --git a/packages/cli/test/fixtures/copyright/single-package/lib/no-header.js b/packages/cli/test/fixtures/copyright/single-package/lib/no-header.js index 4ed25f524c47..5069443cdc8a 100644 --- a/packages/cli/test/fixtures/copyright/single-package/lib/no-header.js +++ b/packages/cli/test/fixtures/copyright/single-package/lib/no-header.js @@ -1,2 +1,7 @@ +// Copyright ACME Inc. 2020,2026. All Rights Reserved. +// Node module: myapp +// This file is licensed under the ISC License. +// License text available at https://www.isc.org/licenses/ + // XYZ exports.xyz = {}; diff --git a/packages/cli/test/fixtures/copyright/single-package/src/application.ts b/packages/cli/test/fixtures/copyright/single-package/src/application.ts index cd003e7cb1f2..5748de32c3d9 100644 --- a/packages/cli/test/fixtures/copyright/single-package/src/application.ts +++ b/packages/cli/test/fixtures/copyright/single-package/src/application.ts @@ -1,6 +1,6 @@ -// Copyright IBM Corp. 2020. All Rights Reserved. +// Copyright ACME Inc. 2020,2026. All Rights Reserved. // Node module: myapp -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT +// This file is licensed under the ISC License. +// License text available at https://www.isc.org/licenses/ export class MyApplication {} diff --git a/packages/cli/test/integration/generators/openapi-client.integration.js b/packages/cli/test/integration/generators/openapi-client.integration.js index 7198930155d5..9467172d6e32 100644 --- a/packages/cli/test/integration/generators/openapi-client.integration.js +++ b/packages/cli/test/integration/generators/openapi-client.integration.js @@ -13,6 +13,8 @@ const testUtils = require('../../test-utils'); const sandbox = new TestSandbox(path.resolve(__dirname, '../.sandbox')); const generator = path.join(__dirname, '../../../generators/openapi'); +const assert = require('assert'); +const fs = require('fs'); const props = { url: path.resolve( @@ -121,6 +123,330 @@ it('generates files with --client and --datasource for an existing datasource', assertModels(); }); +describe('OpenAPI Generator - outDir support and artifact generation', /** @this {Mocha.Suite} */ function () { + this.timeout(10000); + before('reset sandbox', () => sandbox.reset()); + afterEach('reset sandbox', () => sandbox.reset()); + + const applicationTsContent = `import {BootMixin} from '@loopback/boot'; + import {ApplicationConfig} from '@loopback/core'; + export class MyApplication extends BootMixin(Application) { + constructor(options: ApplicationConfig = {}) { + super(options); + this.bootOptions = { + controllers: { + dirs: ['controllers'], + extensions: ['.controller.js'], + nested: true, + }, + }; + } + }`; + + it('should generate all artifacts into custom outDir', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => testUtils.givenLBProject(sandbox.path)) + .withPrompts(props) + .withOptions({client: true, server: true, outDir: 'src/generated'}); + + assert.ok( + fs.existsSync(path.join(sandbox.path, 'src/generated/controllers')), + 'Controllers folder should exist in src/generated', + ); + assert.ok( + fs.existsSync(path.join(sandbox.path, 'src/generated/datasources')), + 'Datasources folder should exist in src/generated', + ); + assert.ok( + fs.existsSync(path.join(sandbox.path, 'src/generated/services')), + 'Services folder should exist in src/generated', + ); + assert.ok( + fs.existsSync(path.join(sandbox.path, 'src/generated/models')), + 'Models folder should exist in src/generated', + ); + }); + + it('should read outDir from config option and generate artifacts in custom outDir', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => testUtils.givenLBProject(sandbox.path)) + .withPrompts(props) + .withOptions({ + client: true, + config: JSON.stringify({ + outDir: 'src/generated', + }), + }); + + // artifacts should be in custom outDir from config + assert.ok( + fs.existsSync(path.join(sandbox.path, 'src/generated/models')), + 'Models folder should exist in src/generated when outDir set via config', + ); + assert.ok( + fs.existsSync(path.join(sandbox.path, 'src/generated/services')), + 'Services folder should exist in src/generated when outDir set via config', + ); + }); + + it('should prefix controller filenames when prefix option is set', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => testUtils.givenLBProject(sandbox.path)) + .withPrompts(props) + .withOptions({client: true, server: true, prefix: 'pet'}); + + // controller files should start with prefix + const controllers = fs.readdirSync( + path.join(sandbox.path, 'src/controllers'), + ); + assert.ok( + controllers.some(f => f.startsWith('pet.')), + "Controller files should start with 'pet.' prefix", + ); + }); + + it('should not prefix controller filenames when prefix is not set', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => testUtils.givenLBProject(sandbox.path)) + .withPrompts(props) + .withOptions({client: true, server: true}); + + // controller files should NOT have prefix pattern + const controllers = fs.readdirSync( + path.join(sandbox.path, 'src/controllers'), + ); + assert.ok( + controllers.every(f => !f.match(/^\w+\.\w+\.controller\.ts$/)), + 'Controller files should not have prefix pattern when no prefix is set', + ); + }); + it('should update application.ts bootOptions with custom outDir paths', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => { + testUtils.givenLBProject(sandbox.path); + // Create src folder and application.ts manually + const srcDir = path.join(sandbox.path, 'src'); + fs.mkdirSync(srcDir, {recursive: true}); + fs.writeFileSync( + path.join(srcDir, 'application.ts'), + applicationTsContent, + ); + }) + .withPrompts(props) + .withOptions({client: true, outDir: 'src/generated'}); + + const applicationTs = fs.readFileSync( + path.join(sandbox.path, 'src/application.ts'), + 'utf-8', + ); + + // should contain custom paths + assert.ok( + applicationTs.includes("'generated/controllers'"), + "bootOptions should contain 'generated/controllers'", + ); + assert.ok( + applicationTs.includes("'generated/datasources'"), + "bootOptions should contain 'generated/datasources'", + ); + assert.ok( + applicationTs.includes("'generated/models'"), + "bootOptions should contain 'generated/models'", + ); + assert.ok( + applicationTs.includes("'generated/services'"), + "bootOptions should contain 'generated/services'", + ); + }); + + it('should skip bootOptions update when application.ts does not exist', async () => { + await assert.doesNotReject( + testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => testUtils.givenLBProject(sandbox.path)) + .withPrompts(props) + .withOptions({client: true, outDir: 'src/generated'}), + 'Should not throw when application.ts is missing', + ); + }); + + it('should skip bootOptions update when outDir is default src', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => { + testUtils.givenLBProject(sandbox.path); + const srcDir = path.join(sandbox.path, 'src'); + fs.mkdirSync(srcDir, {recursive: true}); + fs.writeFileSync( + path.join(srcDir, 'application.ts'), + applicationTsContent, + ); + }) + .withPrompts(props) + .withOptions({client: true, outDir: 'src'}); + + const applicationTs = fs.readFileSync( + path.join(sandbox.path, 'src/application.ts'), + 'utf-8', + ); + + // should NOT contain custom paths + assert.ok( + !applicationTs.includes("'src/controllers'"), + "bootOptions should NOT contain 'src/controllers'", + ); + }); + + it('should ignore prefix when custom outDir is set', async () => { + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => testUtils.givenLBProject(sandbox.path)) + .withPrompts(props) + .withOptions({ + client: true, + server: true, + outDir: 'src/generated', + prefix: 'pet', + }); + + // controller files should NOT be prefixed when outDir is custom + const controllers = fs.readdirSync( + path.join(sandbox.path, 'src/generated/controllers'), + ); + assert.ok( + controllers.every(f => !f.startsWith('pet.')), + 'Controller files should NOT have prefix when custom outDir is set', + ); + }); + + it('should select correct template and set export fields for -with-relations models', () => { + const OpenApiGenerator = require('../../../generators/openapi'); + + const cases = [ + { + label: 'with prefix', + modelSpecs: [ + {kind: 'class', fileName: 'aladv-user-with-relations.model.ts'}, + ], + options: {prefix: 'aladv', outDir: 'src'}, + expected: { + template: 'model-template-with-re-exports-only.ts.ejs', + exportModelName: 'User', + exportModelFileName: 'aladv-user.model', + prefix: 'Aladv', + }, + }, + { + label: 'without prefix', + modelSpecs: [{kind: 'class', fileName: 'user-with-relations.model.ts'}], + options: {outDir: 'src'}, + expected: { + template: 'model-template-with-re-exports-only.ts.ejs', + exportModelName: 'User', + exportModelFileName: 'user.model', + prefix: undefined, + }, + }, + ]; + + for (const {label, modelSpecs, options, expected} of cases) { + const copiedFiles = []; + const gen = Object.create(OpenApiGenerator.prototype); + gen.options = options; + gen.modelSpecs = modelSpecs; + gen.templatePath = p => p; + gen.destinationPath = p => p; + gen.copyTemplatedFiles = (source, dest, context) => + copiedFiles.push({source, context}); + + gen._generateModels(); + + const {source, context} = copiedFiles[0]; + assert.ok( + source.includes(expected.template), + `[${label}] wrong template selected`, + ); + assert.strictEqual( + context.exportModelName, + expected.exportModelName, + `[${label}] exportModelName`, + ); + assert.strictEqual( + context.exportModelFileName, + expected.exportModelFileName, + `[${label}] exportModelFileName`, + ); + assert.strictEqual(context.prefix, expected.prefix, `[${label}] prefix`); + } + }); + + it('should use plain model or type template for non-with-relations models', () => { + const OpenApiGenerator = require('../../../generators/openapi'); + + const cases = [ + { + label: 'class model', + modelSpecs: [{kind: 'class', fileName: 'user.model.ts'}], + expectedTemplate: 'model-template.ts.ejs', + }, + { + label: 'type model', + modelSpecs: [{kind: 'type', fileName: 'user.model.ts'}], + expectedTemplate: 'type-template.ts.ejs', + }, + ]; + + for (const {label, modelSpecs, expectedTemplate} of cases) { + const copiedFiles = []; + const gen = Object.create(OpenApiGenerator.prototype); + gen.options = {outDir: 'src'}; + gen.modelSpecs = modelSpecs; + gen.templatePath = p => p; + gen.destinationPath = p => p; + gen.copyTemplatedFiles = (source, dest, context) => + copiedFiles.push({source, context}); + + gen._generateModels(); + + assert.ok( + copiedFiles[0].source.includes(expectedTemplate), + `[${label}] should use ${expectedTemplate}`, + ); + } + }); + + it('should not crash when prefix does not match the model filename', () => { + const OpenApiGenerator = require('../../../generators/openapi'); + + const copiedFiles = []; + const gen = Object.create(OpenApiGenerator.prototype); + gen.options = {prefix: 'xyz', outDir: 'src'}; + gen.modelSpecs = [ + {kind: 'class', fileName: 'user-with-relations.model.ts'}, + ]; + gen.templatePath = p => p; + gen.destinationPath = p => p; + gen.copyTemplatedFiles = (source, dest, context) => + copiedFiles.push({source, context}); + + // Should not throw + assert.doesNotThrow(() => gen._generateModels()); + + const {context} = copiedFiles[0]; + // exportModelName should still be defined and a non-empty string + assert.ok( + typeof context.exportModelName === 'string' && + context.exportModelName.length > 0, + 'exportModelName should still be set even when prefix does not match filename', + ); + }); +}); + function assertModels(options) { assertFiles( options,