UNPKG

@asyncapi/generator

Version:

The AsyncAPI generator. It can generate documentation, code, anything!

1,141 lines (1,033 loc) 46.3 kB
const path = require('path'); const fs = require('fs'); const xfs = require('fs.extra'); const minimatch = require('minimatch'); const filenamify = require('filenamify'); const git = require('simple-git'); const log = require('loglevel'); const Arborist = require('@npmcli/arborist'); const Config = require('@npmcli/config'); const requireg = require('requireg'); const npmPath = requireg.resolve('npm').replace('index.js',''); const { isAsyncAPIDocument } = require('@asyncapi/parser/cjs/document'); const { configureReact, renderReact, saveRenderedReactContent } = require('./renderer/react'); const { configureNunjucks, renderNunjucks } = require('./renderer/nunjucks'); const { validateTemplateConfig } = require('./templateConfigValidator'); const { isGenerationConditionMet } = require('./conditionalGeneration'); const { convertMapToObject, isFileSystemPath, readFile, readDir, writeFile, copyFile, exists, fetchSpec, isReactTemplate, isJsFile, getTemplateDetails, convertCollectionToObject, } = require('./utils'); const { parse, usesNewAPI, getProperApiDocument } = require('./parser'); const { registerFilters } = require('./filtersRegistry'); const { registerHooks } = require('./hooksRegistry'); const { definitions, flatten, shorthands } = require('@npmcli/config/lib/definitions'); const FILTERS_DIRNAME = 'filters'; const HOOKS_DIRNAME = 'hooks'; const CONFIG_FILENAME = 'package.json'; const PACKAGE_JSON_FILENAME = 'package.json'; const GIT_IGNORE_FILENAME = '{.gitignore}'; const NPM_IGNORE_FILENAME = '{.npmignore}'; const ROOT_DIR = path.resolve(__dirname, '..'); const DEFAULT_TEMPLATES_DIR = path.resolve(ROOT_DIR, 'node_modules'); const TRANSPILED_TEMPLATE_LOCATION = '__transpiled'; const TEMPLATE_CONTENT_DIRNAME = 'template'; const GENERATOR_OPTIONS = ['debug', 'disabledHooks', 'entrypoint', 'forceWrite', 'install', 'noOverwriteGlobs', 'output', 'templateParams', 'mapBaseUrlToFolder', 'url', 'auth', 'token', 'registry', 'compile']; const logMessage = require('./logMessages'); const shouldIgnoreFile = filePath => filePath.startsWith(`.git${path.sep}`); const shouldIgnoreDir = dirPath => dirPath === '.git' || dirPath.startsWith(`.git${path.sep}`); class Generator { /** * Instantiates a new Generator object. * * @example * const path = require('path'); * const generator = new Generator('@asyncapi/html-template', path.resolve(__dirname, 'example')); * * @example <caption>Passing custom params to the template</caption> * const path = require('path'); * const generator = new Generator('@asyncapi/html-template', path.resolve(__dirname, 'example'), { * templateParams: { * sidebarOrganization: 'byTags' * } * }); * * @param {String} templateName Name of the template to generate. * @param {String} targetDir Path to the directory where the files will be generated. * @param {Object} options * @param {Object<string, string>} [options.templateParams] Optional parameters to pass to the template. Each template define their own params. * @param {String} [options.entrypoint] Name of the file to use as the entry point for the rendering process. Use in case you want to use only a specific template file. Note: this potentially avoids rendering every file in the template. * @param {String[]} [options.noOverwriteGlobs] List of globs to skip when regenerating the template. * @param {Object<String, Boolean | String | String[]>} [options.disabledHooks] Object with hooks to disable. The key is a hook type. If key has "true" value, then the generator skips all hooks from the given type. If the value associated with a key is a string with the name of a single hook, then the generator skips only this single hook name. If the value associated with a key is an array of strings, then the generator skips only hooks from the array. * @param {String} [options.output='fs'] Type of output. Can be either 'fs' (default) or 'string'. Only available when entrypoint is set. * @param {Boolean} [options.forceWrite=false] Force writing of the generated files to given directory even if it is a git repo with unstaged files or not empty dir. Default is set to false. * @param {Boolean} [options.install=false] Install the template and its dependencies, even when the template has already been installed. * @param {Boolean} [options.debug=false] Enable more specific errors in the console. At the moment it only shows specific errors about filters. Keep in mind that as a result errors about template are less descriptive. * @param {Boolean} [options.compile=true] Whether to compile the template or use the cached transpiled version provided by template in '__transpiled' folder * @param {Object<String, String>} [options.mapBaseUrlToFolder] Optional parameter to map schema references from a base url to a local base folder e.g. url=https://schema.example.com/crm/ folder=./test/docs/ . * @param {Object} [options.registry] Optional parameter with private registry configuration * @param {String} [options.registry.url] Parameter to pass npm registry url * @param {String} [options.registry.auth] Optional parameter to pass npm registry username and password encoded with base64, formatted like username:password value should be encoded * @param {String} [options.registry.token] Optional parameter to pass npm registry auth token that you can grab from .npmrc file */ constructor(templateName, targetDir, { templateParams = {}, entrypoint, noOverwriteGlobs, disabledHooks, output = 'fs', forceWrite = false, install = false, debug = false, mapBaseUrlToFolder = {}, registry = {}, compile = true } = {}) { const options = arguments[arguments.length - 1]; this.verifyoptions(options); if (!templateName) throw new Error('No template name has been specified.'); if (!entrypoint && !targetDir) throw new Error('No target directory has been specified.'); if (!['fs', 'string'].includes(output)) throw new Error(`Invalid output type ${output}. Valid values are 'fs' and 'string'.`); /** @type {Boolean} Whether to compile the template or use the cached transpiled version provided by template in '__transpiled' folder. */ this.compile = compile; /** @type {Object} Npm registry information. */ this.registry = registry; /** @type {String} Name of the template to generate. */ this.templateName = templateName; /** @type {String} Path to the directory where the files will be generated. */ this.targetDir = targetDir; /** @type {String} Name of the file to use as the entry point for the rendering process. Use in case you want to use only a specific template file. Note: this potentially avoids rendering every file in the template. */ this.entrypoint = entrypoint; /** @type {String[]} List of globs to skip when regenerating the template. */ this.noOverwriteGlobs = noOverwriteGlobs || []; /** @type {Object<String, Boolean | String | String[]>} Object with hooks to disable. The key is a hook type. If key has "true" value, then the generator skips all hooks from the given type. If the value associated with a key is a string with the name of a single hook, then the generator skips only this single hook name. If the value associated with a key is an array of strings, then the generator skips only hooks from the array. */ this.disabledHooks = disabledHooks || {}; /** @type {String} Type of output. Can be either 'fs' (default) or 'string'. Only available when entrypoint is set. */ this.output = output; /** @type {Boolean} Force writing of the generated files to given directory even if it is a git repo with unstaged files or not empty dir. Default is set to false. */ this.forceWrite = forceWrite; /** @type {Boolean} Enable more specific errors in the console. At the moment it only shows specific errors about filters. Keep in mind that as a result errors about template are less descriptive. */ this.debug = debug; /** @type {Boolean} Install the template and its dependencies, even when the template has already been installed. */ this.install = install; /** @type {Object} The template configuration. */ this.templateConfig = {}; /** @type {Object} Hooks object with hooks functions grouped by the hook type. */ this.hooks = {}; /** @type {Object} Maps schema URL to folder. */ this.mapBaseUrlToFolder = mapBaseUrlToFolder; // Load template configuration /** @type {Object} The template parameters. The structure for this object is based on each individual template. */ this.templateParams = {}; Object.keys(templateParams).forEach(key => { const self = this; Object.defineProperty(this.templateParams, key, { enumerable: true, get() { if (!self.templateConfig.parameters?.[key]) { throw new Error(`Template parameter "${key}" has not been defined in the Generator Configuration. Please make sure it's listed there before you use it in your template.`); } return templateParams[key]; } }); }); } /** * Check if the Registry Options are valid or not. * * @private * @param {Object} invalidRegOptions Invalid Registry Options. * */ verifyoptions(Options) { if (typeof Options !== 'object') return []; const invalidOptions = Object.keys(Options).filter(param => !GENERATOR_OPTIONS.includes(param)); if (invalidOptions.length > 0) { throw new Error(`These options are not supported by the generator: ${invalidOptions.join(', ')}`); } } /** * Generates files from a given template and an AsyncAPIDocument object. * * @async * @example * await generator.generate(myAsyncAPIdocument); * console.log('Done!'); * * @example * generator * .generate(myAsyncAPIdocument) * .then(() => { * console.log('Done!'); * }) * .catch(console.error); * * @example <caption>Using async/await</caption> * try { * await generator.generate(myAsyncAPIdocument); * console.log('Done!'); * } catch (e) { * console.error(e); * } * * @param {AsyncAPIDocument | string} asyncapiDocument - AsyncAPIDocument object to use as source. * @param {Object} [parseOptions={}] - AsyncAPI Parser parse options. * Check out {@link https://www.github.com/asyncapi/parser-js|@asyncapi/parser} for more information. * Remember to use the right options for the right parser depending on the template you are using. * @return {Promise<void>} A Promise that resolves when the generation is completed. */ async generate(asyncapiDocument, parseOptions = {}) { this.validateAsyncAPIDocument(asyncapiDocument); await this.setupOutput(); this.setLogLevel(); await this.installAndSetupTemplate(); await this.configureTemplateWorkflow(parseOptions); await this.handleEntrypoint(); await this.executeAfterHook(); } /** * Validates the provided AsyncAPI document. * * @param {*} asyncapiDocument - The AsyncAPI document to be validated. * @throws {Error} Throws an error if the document is not valid. * @since 10/9/2023 - 4:26:33 PM */ validateAsyncAPIDocument(asyncapiDocument) { const isAlreadyParsedDocument = isAsyncAPIDocument(asyncapiDocument); const isParsableCompatible = asyncapiDocument && typeof asyncapiDocument === 'string'; if (!isAlreadyParsedDocument && !isParsableCompatible) { throw new Error('Parameter "asyncapiDocument" must be a non-empty string or an already parsed AsyncAPI document.'); } this.asyncapi = this.originalAsyncAPI = asyncapiDocument; } /** * Sets up the output configuration based on the specified output type. * * @example * const generator = new Generator(); * await generator.setupOutput(); * * @async * * @throws {Error} If 'output' is set to 'string' without providing 'entrypoint'. */ async setupOutput() { if (this.output === 'fs') { await this.setupFSOutput(); } else if (this.output === 'string' && this.entrypoint === undefined) { throw new Error('Parameter entrypoint is required when using output = "string"'); } } /** * Sets up the file system (FS) output configuration. * * This function creates the target directory if it does not exist and verifies * the target directory if forceWrite is not enabled. * * @async * @returns {Promise<void>} A promise that fulfills when the setup is complete. * * @throws {Error} If verification of the target directory fails and forceWrite is not enabled. */ async setupFSOutput() { // Create directory if not exists xfs.mkdirpSync(this.targetDir); // Verify target directory if forceWrite is not enabled if (!this.forceWrite) { await this.verifyTargetDir(this.targetDir); } } /** * Sets the log level based on the debug option. * * If the debug option is enabled, the log level is set to 'debug'. * * @returns {void} */ setLogLevel() { if (this.debug) log.setLevel('debug'); } /** * Installs and sets up the template for code generation. * * This function installs the specified template using the provided installation option, * sets up the necessary directory paths, loads the template configuration, and returns * information about the installed template. * * @async * @returns {Promise<{ templatePkgName: string, templatePkgPath: string }>} * A promise that resolves to an object containing the name and path of the installed template. */ async installAndSetupTemplate() { const { name: templatePkgName, path: templatePkgPath } = await this.installTemplate(this.install); this.templateDir = templatePkgPath; this.templateName = templatePkgName; this.templateContentDir = path.resolve(this.templateDir, TEMPLATE_CONTENT_DIRNAME); await this.loadTemplateConfig(); return { templatePkgName, templatePkgPath }; } /** * Configures the template workflow based on provided parsing options. * * This function performs the following steps: * 1. Parses the input AsyncAPI document using the specified parse options. * 2. Validates the template configuration and parameters. * 3. Configures the template based on the parsed AsyncAPI document. * 4. Registers filters, hooks, and launches the 'generate:before' hook if applicable. * * @async * @param {*} parseOptions - Options for parsing the AsyncAPI document. * @returns {Promise<void>} A promise that resolves when the configuration is completed. */ async configureTemplateWorkflow(parseOptions) { // Parse input and validate template configuration await this.parseInput(this.asyncapi, parseOptions); validateTemplateConfig(this.templateConfig, this.templateParams, this.asyncapi); await this.configureTemplate(); if (!isReactTemplate(this.templateConfig)) { await registerFilters(this.nunjucks, this.templateConfig, this.templateDir, FILTERS_DIRNAME); } await registerHooks(this.hooks, this.templateConfig, this.templateDir, HOOKS_DIRNAME); await this.launchHook('generate:before'); } /** * Handles the logic for the template entrypoint. * * If an entrypoint is specified: * - Resolves the absolute path of the entrypoint file. * - Throws an error if the entrypoint file doesn't exist. * - Generates a file or renders content based on the output type. * - Launches the 'generate:after' hook if the output is 'fs'. * * If no entrypoint is specified, generates the directory structure. * * @async * @returns {Promise<void>} A promise that resolves when the entrypoint logic is completed. */ async handleEntrypoint() { if (this.entrypoint) { const entrypointPath = path.resolve(this.templateContentDir, this.entrypoint); if (!(await exists(entrypointPath))) { throw new Error(`Template entrypoint "${entrypointPath}" couldn't be found.`); } if (this.output === 'fs') { await this.generateFile(this.asyncapi, path.basename(entrypointPath), path.dirname(entrypointPath)); await this.launchHook('generate:after'); } else if (this.output === 'string') { return await this.renderFile(this.asyncapi, entrypointPath); } } else { await this.generateDirectoryStructure(this.asyncapi); } } /** * Executes the 'generate:after' hook. * * Launches the after-hook to perform additional actions after code generation. * * @async * @returns {Promise<void>} A promise that resolves when the after-hook execution is completed. */ async executeAfterHook() { await this.launchHook('generate:after'); } /** * Parse the generator input based on the template `templateConfig.apiVersion` value. */ async parseInput(asyncapiDocument, parseOptions = {}) { const isAlreadyParsedDocument = isAsyncAPIDocument(asyncapiDocument); // use the expected document API based on `templateConfig.apiVersion` value if (isAlreadyParsedDocument) { this.asyncapi = getProperApiDocument(asyncapiDocument, this.templateConfig); } else { /** @type {AsyncAPIDocument} Parsed AsyncAPI schema. See {@link https://github.com/asyncapi/parser-js/blob/master/API.md#module_@asyncapi/parser+AsyncAPIDocument|AsyncAPIDocument} for details on object structure. */ const { document, diagnostics } = await parse(asyncapiDocument, parseOptions, this); if (!document) { const err = new Error('Input is not a correct AsyncAPI document so it cannot be processed.'); err.diagnostics = diagnostics; for (const diag of diagnostics) { console.error( `Diagnostic err: ${diag['message']} in path ${JSON.stringify(diag['path'])} starting `+ `L${diag['range']['start']['line'] + 1} C${diag['range']['start']['character']}, ending `+ `L${diag['range']['end']['line'] + 1} C${diag['range']['end']['character']}` ); } throw err; } else { this.asyncapi = document; } } } /** * Configure the templates based the desired renderer. */ async configureTemplate() { if (isReactTemplate(this.templateConfig) && this.compile) { await configureReact(this.templateDir, this.templateContentDir, TRANSPILED_TEMPLATE_LOCATION); } else { this.nunjucks = configureNunjucks(this.debug, this.templateDir); } } /** * Generates files from a given template and AsyncAPI string. * * @example * const asyncapiString = ` * asyncapi: '2.0.0' * info: * title: Example * version: 1.0.0 * ... * `; * generator * .generateFromString(asyncapiString) * .then(() => { * console.log('Done!'); * }) * .catch(console.error); * * @example <caption>Using async/await</caption> * const asyncapiString = ` * asyncapi: '2.0.0' * info: * title: Example * version: 1.0.0 * ... * `; * * try { * await generator.generateFromString(asyncapiString); * console.log('Done!'); * } catch (e) { * console.error(e); * } * * @param {String} asyncapiString AsyncAPI string to use as source. * @param {Object} [parseOptions={}] AsyncAPI Parser parse options. Check out {@link https://www.github.com/asyncapi/parser-js|@asyncapi/parser} for more information. * @deprecated Use the `generate` function instead. Just change the function name and it works out of the box. * @return {Promise<TemplateRenderResult|undefined>} */ async generateFromString(asyncapiString, parseOptions = {}) { const isParsableCompatible = asyncapiString && typeof asyncapiString === 'string'; if (!isParsableCompatible) { throw new Error('Parameter "asyncapiString" must be a non-empty string.'); } return await this.generate(asyncapiString, parseOptions); } /** * Generates files from a given template and AsyncAPI file stored on external server. * * @example * generator * .generateFromURL('https://example.com/asyncapi.yaml') * .then(() => { * console.log('Done!'); * }) * .catch(console.error); * * @example <caption>Using async/await</caption> * try { * await generator.generateFromURL('https://example.com/asyncapi.yaml'); * console.log('Done!'); * } catch (e) { * console.error(e); * } * * @param {String} asyncapiURL Link to AsyncAPI file * @return {Promise<TemplateRenderResult|undefined>} */ async generateFromURL(asyncapiURL) { const doc = await fetchSpec(asyncapiURL); return await this.generate(doc, { path: asyncapiURL }); } /** * Generates files from a given template and AsyncAPI file. * * @example * generator * .generateFromFile('asyncapi.yaml') * .then(() => { * console.log('Done!'); * }) * .catch(console.error); * * @example <caption>Using async/await</caption> * try { * await generator.generateFromFile('asyncapi.yaml'); * console.log('Done!'); * } catch (e) { * console.error(e); * } * * @param {String} asyncapiFile AsyncAPI file to use as source. * @return {Promise<TemplateRenderResult|undefined>} */ async generateFromFile(asyncapiFile) { const doc = await readFile(asyncapiFile, { encoding: 'utf8' }); return await this.generate(doc, { path: asyncapiFile }); } /** * Returns the content of a given template file. * * @example * const Generator = require('@asyncapi/generator'); * const content = await Generator.getTemplateFile('@asyncapi/html-template', 'partials/content.html'); * * @example <caption>Using a custom `templatesDir`</caption> * const Generator = require('@asyncapi/generator'); * const content = await Generator.getTemplateFile('@asyncapi/html-template', 'partials/content.html', '~/my-templates'); * * @static * @param {String} templateName Name of the template to generate. * @param {String} filePath Path to the file to render. Relative to the template directory. * @param {String} [templatesDir=DEFAULT_TEMPLATES_DIR] Path to the directory where the templates are installed. * @return {Promise} */ static async getTemplateFile(templateName, filePath, templatesDir = DEFAULT_TEMPLATES_DIR) { return await readFile(path.resolve(templatesDir, templateName, filePath), 'utf8'); } /** * @private * @param {Object} arbOptions ArbOptions to intialise the Registry details. */ initialiseArbOptions(arbOptions) { let registryUrl = 'registry.npmjs.org'; let authorizationName = 'anonymous'; const providedRegistry = this.registry.url; if (providedRegistry) { arbOptions.registry = providedRegistry; registryUrl = providedRegistry; } const domainName = registryUrl.replace(/^https?:\/\//, ''); //doing basic if/else so basically only one auth type is used and token as more secure is primary if (this.registry.token) { authorizationName = `//${domainName}:_authToken`; arbOptions[authorizationName] = this.registry.token; } else if (this.registry.auth) { authorizationName = `//${domainName}:_auth`; arbOptions[authorizationName] = this.registry.auth; } //not sharing in logs neither token nor auth for security reasons log.debug(`Using npm registry ${registryUrl} and authorization type ${authorizationName} to handle template installation.`); } /** * Downloads and installs a template and its dependencies * * @param {Boolean} [force=false] Whether to force installation (and skip cache) or not. */ async installTemplate(force = false) { if (!force) { let pkgPath; let installedPkg; let packageVersion; try { installedPkg = getTemplateDetails(this.templateName, PACKAGE_JSON_FILENAME); pkgPath = installedPkg?.pkgPath; packageVersion = installedPkg?.version; log.debug(logMessage.templateSource(pkgPath)); if (packageVersion) log.debug(logMessage.templateVersion(packageVersion)); return { name: installedPkg.name, path: pkgPath }; } catch (e) { log.debug(logMessage.packageNotAvailable(installedPkg), e); // We did our best. Proceed with installation... } } const debugMessage = force ? logMessage.TEMPLATE_INSTALL_FLAG_MSG : logMessage.TEMPLATE_INSTALL_DISK_MSG; log.debug(logMessage.installationDebugMessage(debugMessage)); if (isFileSystemPath(this.templateName)) log.debug(logMessage.NPM_INSTALL_TRIGGER); const config = new Config({ definitions, flatten, shorthands, npmPath }); await config.load(); const arbOptions = {...{path: ROOT_DIR}, ...config.flat}; if (Object.keys(this.registry).length !== 0) { this.initialiseArbOptions(arbOptions); } const arb = new Arborist(arbOptions); try { const installResult = await arb.reify({ add: [this.templateName], saveType: 'prod', save: false }); const addResult = arb[Symbol.for('resolvedAdd')]; if (!addResult) throw new Error('Unable to resolve the name of the added package. It was most probably not added to node_modules successfully'); const packageName = addResult[0].name; const packageVersion = installResult.children.get(packageName).version; const packagePath = installResult.children.get(packageName).path; if (!isFileSystemPath(this.templateName)) log.debug(logMessage.templateSuccessfullyInstalled(packageName, packagePath)); if (packageVersion) log.debug(logMessage.templateVersion(packageVersion)); return { name: packageName, path: packagePath, }; } catch (err) { throw new Error(`Installation failed: ${ err.message }`); } } /** * Returns all the parameters on the AsyncAPI document. * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to use as the source. */ getAllParameters(asyncapiDocument) { const parameters = new Map(); if (usesNewAPI(this.templateConfig)) { asyncapiDocument.channels().all().forEach(channel => { channel.parameters().all().forEach(parameter => { parameters.set(parameter.id(), parameter); }); }); asyncapiDocument.components().channelParameters().all().forEach(parameter => { parameters.set(parameter.id(), parameter); }); } else { if (asyncapiDocument.hasChannels()) { asyncapiDocument.channelNames().forEach(channelName => { const channel = asyncapiDocument.channel(channelName); for (const [key, value] of Object.entries(channel.parameters())) { parameters.set(key, value); } }); } if (asyncapiDocument.hasComponents()) { for (const [key, value] of Object.entries(asyncapiDocument.components().parameters())) { parameters.set(key, value); } } } return parameters; } /** * Generates the directory structure. * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to use as the source. * @return {Promise} */ generateDirectoryStructure(asyncapiDocument) { return new Promise((resolve, reject) => { xfs.mkdirpSync(this.targetDir); const walker = xfs.walk(this.templateContentDir, { followLinks: false }); walker.on('file', async (root, stats, next) => { try { await this.filesGenerationHandler(asyncapiDocument, root, stats, next); } catch (e) { reject(e); } }); walker.on('directory', async (root, stats, next) => { try { await this.ignoredDirHandler(root, stats, next); } catch (e) { reject(e); } }); walker.on('errors', (root, nodeStatsArray) => { reject(nodeStatsArray); }); walker.on('end', async () => { resolve(); }); }); } /** * Makes sure that during directory structure generation ignored dirs are not modified * @private * * @param {String} root Dir name. * @param {String} stats Information about the file. * @param {Function} next Callback function */ async ignoredDirHandler(root, stats, next) { const relativeDir = path.relative(this.templateContentDir, path.resolve(root, stats.name)); const dirPath = path.resolve(this.targetDir, relativeDir); const conditionalEntry = this.templateConfig?.conditionalGeneration?.[relativeDir]; let shouldGenerate = true; if (conditionalEntry) { shouldGenerate = await isGenerationConditionMet( this.templateConfig, relativeDir, this.templateParams, this.asyncapiDocument ); if (!shouldGenerate) { log.debug(logMessage.conditionalGenerationMatched(relativeDir)); } } if (!shouldIgnoreDir(relativeDir) && shouldGenerate) { xfs.mkdirpSync(dirPath); } next(); } /** * Makes sure that during directory structure generation ignored dirs are not modified * @private * * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to use as the source. * @param {String} objectMap Map of schemas of type object * @param {String} root Dir name. * @param {String} stats Information about the file. * @param {Function} next Callback function */ async filesGenerationHandler(asyncapiDocument, root, stats, next) { let fileNamesForSeparation = {}; if (usesNewAPI(this.templateConfig)) { fileNamesForSeparation = { channel: convertCollectionToObject(asyncapiDocument.channels().all(), 'address'), message: convertCollectionToObject(asyncapiDocument.messages().all(), 'id'), securityScheme: convertCollectionToObject(asyncapiDocument.components().securitySchemes().all(), 'id'), schema: convertCollectionToObject(asyncapiDocument.components().schemas().all(), 'id'), objectSchema: convertCollectionToObject(asyncapiDocument.schemas().all().filter(schema => schema.type() === 'object'), 'id'), parameter: convertMapToObject(this.getAllParameters(asyncapiDocument)), everySchema: convertCollectionToObject(asyncapiDocument.schemas().all(), 'id'), }; } else { const objectSchema = {}; asyncapiDocument.allSchemas().forEach((schema, schemaId) => { if (schema.type() === 'object') objectSchema[schemaId] = schema; }); fileNamesForSeparation = { channel: asyncapiDocument.channels(), message: convertMapToObject(asyncapiDocument.allMessages()), securityScheme: asyncapiDocument.components() ? asyncapiDocument.components().securitySchemes() : {}, schema: asyncapiDocument.components() ? asyncapiDocument.components().schemas() : {}, objectSchema, parameter: convertMapToObject(this.getAllParameters(asyncapiDocument)), everySchema: convertMapToObject(asyncapiDocument.allSchemas()), }; } // Check if the filename dictates it should be separated let wasSeparated = false; for (const prop in fileNamesForSeparation) { if (Object.hasOwn(fileNamesForSeparation, prop) && stats.name.includes(`$$${prop}$$`)) { await this.generateSeparateFiles(asyncapiDocument, fileNamesForSeparation[prop], prop, stats.name, root); const templateFilePath = path.relative(this.templateContentDir, path.resolve(root, stats.name)); fs.unlink(path.resolve(this.targetDir, templateFilePath), next); wasSeparated = true; //The filename can only contain 1 specifier (message, scheme etc) break; } } // If it was not separated process normally if (!wasSeparated) { await this.generateFile(asyncapiDocument, stats.name, root); next(); } } /** * Generates all the files for each in array * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to use as the source. * @param {Array} array The components/channels to generate the separeted files for. * @param {String} template The template filename to replace. * @param {String} fileName Name of the file to generate for each security schema. * @param {String} baseDir Base directory of the given file name. * @returns {Promise} */ async generateSeparateFiles(asyncapiDocument, array, template, fileName, baseDir) { const promises = []; Object.keys(array).forEach((name) => { const component = array[name]; promises.push(this.generateSeparateFile(asyncapiDocument, name, component, template, fileName, baseDir)); }); return Promise.all(promises); } /** * Generates a file for a component/channel * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to use as the source. * @param {String} name The name of the component (filename to use) * @param {Object} component The component/channel object used to generate the file. * @param {String} template The template filename to replace. * @param {String} fileName Name of the file to generate for each security schema. * @param {String} baseDir Base directory of the given file name. * @returns {Promise} */ async generateSeparateFile(asyncapiDocument, name, component, template, fileName, baseDir) { const relativeBaseDir = path.relative(this.templateContentDir, baseDir); const setFileTemplateNameHookName = 'setFileTemplateName'; let filename = name; if (this.isHookAvailable(setFileTemplateNameHookName)) { const filenamesFromHooks = await this.launchHook(setFileTemplateNameHookName, { originalFilename: filename }); //Use the result of the first hook filename = filenamesFromHooks[0]; } else { filename = filenamify(filename, { replacement: '-', maxLength: 255 }); } const newFileName = fileName.replace(`\$\$${template}\$\$`, filename); const targetFile = path.resolve(this.targetDir, relativeBaseDir, newFileName); const relativeTargetFile = path.relative(this.targetDir, targetFile); const shouldOverwriteFile = await this.shouldOverwriteFile(relativeTargetFile); if (!shouldOverwriteFile) return; //Ensure the same object are parsed to the renderFile method as before. const temp = {}; const key = template === 'everySchema' || template === 'objectSchema' ? 'schema' : template; temp[`${key}Name`] = name; temp[key] = component; await this.renderAndWriteToFile(asyncapiDocument, path.resolve(baseDir, fileName), targetFile, temp); } /** * Renders a template and writes the result into a file. * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to pass to the template. * @param {String} templateFilePath Path to the input file being rendered. * @param {String} outputPath Path to the resulting rendered file. * @param {Object} [extraTemplateData] Extra data to pass to the template. */ async renderAndWriteToFile(asyncapiDocument, templateFilePath, outputpath, extraTemplateData) { const renderContent = await this.renderFile(asyncapiDocument, templateFilePath, extraTemplateData); if (renderContent === undefined) { return; } else if (isReactTemplate(this.templateConfig)) { await saveRenderedReactContent(renderContent, outputpath, this.noOverwriteGlobs); } else { await writeFile(outputpath, renderContent); } } /** * Generates a file. * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to use as the source. * @param {String} fileName Name of the file to generate for each channel. * @param {String} baseDir Base directory of the given file name. * @return {Promise} */ async generateFile(asyncapiDocument, fileName, baseDir) { const sourceFile = path.resolve(baseDir, fileName); const relativeSourceFile = path.relative(this.templateContentDir, sourceFile); const relativeSourceDirectory = relativeSourceFile.split(path.sep)[0] || '.'; const targetFile = path.resolve(this.targetDir, this.maybeRenameSourceFile(relativeSourceFile)); const relativeTargetFile = path.relative(this.targetDir, targetFile); let shouldGenerate = true; if (shouldIgnoreFile(relativeSourceFile)) return; if (!(await this.shouldOverwriteFile(relativeTargetFile))) return; // conditionalFiles becomes deprecated with this PR, and soon will be removed. // TODO: https://github.com/asyncapi/generator/issues/1553 let conditionalPath = ''; if ( this.templateConfig.conditionalFiles && this.templateConfig.conditionalGeneration ) { log.debug( 'Both \'conditionalFiles\' and \'conditionalGeneration\' are defined. Ignoring \'conditionalFiles\' and using \'conditionalGeneration\' only.' ); } if (this.templateConfig.conditionalGeneration?.[relativeSourceDirectory]) { conditionalPath = relativeSourceDirectory; } else if (this.templateConfig.conditionalGeneration?.[relativeSourceFile]) { conditionalPath = relativeSourceFile; } else if (this.templateConfig.conditionalFiles?.[relativeSourceFile]) { // conditionalFiles becomes deprecated with this PR, and soon will be removed. // TODO: https://github.com/asyncapi/generator/issues/1553 conditionalPath = relativeSourceDirectory; } if (conditionalPath) { shouldGenerate = await isGenerationConditionMet( this.templateConfig, conditionalPath, this.templateParams, asyncapiDocument ); } if (!shouldGenerate) { if (this.templateConfig.conditionalFiles?.[relativeSourceFile]) { // conditionalFiles becomes deprecated with this PR, and soon will be removed. // TODO: https://github.com/asyncapi/generator/issues/1553 return log.debug(logMessage.conditionalFilesMatched(relativeSourceFile)); } return log.debug(logMessage.conditionalGenerationMatched(conditionalPath)); } if (this.isNonRenderableFile(relativeSourceFile)) return await copyFile(sourceFile, targetFile); await this.renderAndWriteToFile(asyncapiDocument, sourceFile, targetFile); log.debug(`Successfully rendered template and wrote file ${relativeSourceFile} to location: ${targetFile}`); } /** * It may rename the source file name in cases where special names are not supported, like .gitignore or .npmignore. * * Since we're using npm to install templates, these files are never downloaded (that's npm behavior we can't change). * @private * @param {String} sourceFile Path to the source file * @returns {String} New path name */ maybeRenameSourceFile(sourceFile) { switch (path.basename(sourceFile)) { case GIT_IGNORE_FILENAME: return path.join(path.dirname(sourceFile), '.gitignore'); case NPM_IGNORE_FILENAME: return path.join(path.dirname(sourceFile), '.npmignore'); default: return sourceFile; } } /** * Renders the content of a file and outputs it. * * @private * @param {AsyncAPIDocument} asyncapiDocument AsyncAPI document to pass to the template. * @param {String} filePath Path to the file you want to render. * @param {Object} [extraTemplateData] Extra data to pass to the template. * @return {Promise<string|TemplateRenderResult|Array<TemplateRenderResult>|undefined>} */ async renderFile(asyncapiDocument, filePath, extraTemplateData = {}) { if (isReactTemplate(this.templateConfig)) { return await renderReact(asyncapiDocument, filePath, extraTemplateData, this.templateDir, this.templateContentDir, TRANSPILED_TEMPLATE_LOCATION, this.templateParams, this.debug, this.originalAsyncAPI); } const templateString = await readFile(filePath, 'utf8'); return renderNunjucks(asyncapiDocument, templateString, filePath, extraTemplateData, this.templateParams, this.originalAsyncAPI, this.nunjucks); } /** * Checks if a given file name matches the list of non-renderable files. * * @private * @param {string} fileName Name of the file to check against a list of glob patterns. * @return {boolean} */ isNonRenderableFile(fileName) { const nonRenderableFiles = this.templateConfig.nonRenderableFiles || []; return Array.isArray(nonRenderableFiles) && (nonRenderableFiles.some(globExp => minimatch(fileName, globExp)) || (isReactTemplate(this.templateConfig) && !isJsFile(fileName))); } /** * Checks if a given file should be overwritten. * * @private * @param {string} filePath Path to the file to check against a list of glob patterns. * @return {Promise<boolean>} */ async shouldOverwriteFile(filePath) { if (!Array.isArray(this.noOverwriteGlobs)) return true; const fileExists = await exists(path.resolve(this.targetDir, filePath)); if (!fileExists) return true; return !this.noOverwriteGlobs.some(globExp => minimatch(filePath, globExp)); } /** * Loads the template configuration. * @private */ async loadTemplateConfig() { this.templateConfig = {}; // Try to load config from .ageneratorrc try { const rcConfigPath = path.resolve(this.templateDir, '.ageneratorrc'); const yaml = await readFile(rcConfigPath, { encoding: 'utf8' }); const yamlConfig = require('js-yaml').load(yaml); this.templateConfig = yamlConfig || {}; await this.loadDefaultValues(); return; } catch (rcError) { // console.error('Could not load .ageneratorrc file:', rcError); log.debug('Could not load .ageneratorrc file:', rcError); // Continue to try package.json if .ageneratorrc fails } // Try to load config from package.json try { const configPath = path.resolve(this.templateDir, CONFIG_FILENAME); const json = await readFile(configPath, { encoding: 'utf8' }); const generatorProp = JSON.parse(json).generator; this.templateConfig = generatorProp || {}; } catch (packageError) { // console.error('Could not load generator config from package.json:', packageError); log.debug('Could not load generator config from package.json:', packageError); } await this.loadDefaultValues(); } /** * Loads default values of parameters from template config. If value was already set as parameter it will not be * overriden. * @private */ async loadDefaultValues() { const parameters = this.templateConfig.parameters; const defaultValues = Object.keys(parameters || {}).filter(key => parameters[key].default); defaultValues.filter(dv => this.templateParams[dv] === undefined).forEach(dv => Object.defineProperty(this.templateParams, dv, { enumerable: true, get() { return parameters[dv].default; } }) ); } /** * Launches all the hooks registered at a given hook point/name. * * @param {string} hookName * @param {*} hookArguments * @private */ async launchHook(hookName, hookArguments) { let disabledHooks = this.disabledHooks[hookName] || []; if (disabledHooks === true) return; if (typeof disabledHooks === 'string') disabledHooks = [disabledHooks]; const hooks = this.hooks[hookName]; if (!Array.isArray(hooks)) return; const promises = hooks.map(async (hook) => { if (typeof hook !== 'function') return; if (disabledHooks.includes(hook.name)) return; return await hook(this, hookArguments); }).filter(Boolean); return Promise.all(promises); } /** * Check if any hooks are available * * @param {string} hookName * @private */ isHookAvailable(hookName) { const hooks = this.hooks[hookName]; if (this.disabledHooks[hookName] === true || !Array.isArray(hooks) || hooks.length === 0) return false; let disabledHooks = this.disabledHooks[hookName] || []; if (typeof disabledHooks === 'string') disabledHooks = [disabledHooks]; return !!hooks.filter(h => !disabledHooks.includes(h.name)).length; } /** * Check if given directory is a git repo with unstaged changes and is not in .gitignore or is not empty * @private * @param {String} dir Directory that needs to be tested for a given condition. */ async verifyTargetDir(dir) { const isGitRepo = await git(dir).checkIsRepo(); if (isGitRepo) { //Need to figure out root of the repository to properly verify .gitignore const root = await git(dir).revparse(['--show-toplevel']); const gitInfo = git(root); //Skipping verification if workDir inside repo is declared in .gitignore const workDir = path.relative(root, dir); if (workDir) { const checkGitIgnore = await gitInfo.checkIgnore(workDir); if (checkGitIgnore.length !== 0) return; } const gitStatus = await gitInfo.status(); //New files are not tracked and not visible as modified const hasUntrackedUnstagedFiles = gitStatus.not_added.length !== 0; const stagedFiles = gitStatus.staged; const modifiedFiles = gitStatus.modified; const hasModifiedUstagedFiles = (modifiedFiles.filter(e => stagedFiles.indexOf(e) === -1)).length !== 0; if (hasModifiedUstagedFiles || hasUntrackedUnstagedFiles) throw new Error(`"${this.targetDir}" is in a git repository with unstaged changes. Please commit your changes before proceeding or add proper directory to .gitignore file. You can also use the --force-write flag to skip this rule (not recommended).`); } else { const isDirEmpty = (await readDir(dir)).length === 0; if (!isDirEmpty) throw new Error(`"${this.targetDir}" is not an empty directory. You might override your work. To skip this rule, please make your code a git repository or use the --force-write flag (not recommended).`); } } } Generator.DEFAULT_TEMPLATES_DIR = DEFAULT_TEMPLATES_DIR; Generator.TRANSPILED_TEMPLATE_LOCATION = TRANSPILED_TEMPLATE_LOCATION; module.exports = Generator;