@axway/api-builder-runtime
Version:
API Builder Runtime
342 lines (311 loc) • 10 kB
JavaScript
const fs = require('fs');
const path = require('path');
const util = require('util');
const apiBuilderConfig = require('@axway/api-builder-config');
const { FlowManager } = require('@axway/flow');
const utils = require('./utils');
const readdirAsync = util.promisify(fs.readdir);
const accessAsync = util.promisify(fs.access);
// The regexes to determine the module type.
const PLUGIN_REGEX = /^(?:@(.+)\/)?api-builder-plugin-.+$/;
/**
* Plugin error message
* @param {string} name plugin name
* @param {object|string} err error or error message
*/
function errorMsg (name, err) {
return `Error loading plugin: ${name}. ${err && err.message || err}`;
}
/**
* Check if module is a dataconnector module.
* @private
* @param {*} mod The export from the module.
* @returns {boolean} true if this module is a data connector.
*/
function isDataConnector(module) {
return module && typeof module === 'object' && typeof module.create === 'function';
}
/**
* Check if a module is a plugin.
* @private
* @param {*} mod The export from the module.
* @returns {boolean} true if this module is a plugin.
*/
function isPlugin(module) {
return typeof module === 'function';
}
/**
* Determine the type of the plugin.
* @private
* @param {object} plugin Plugin object
* @returns {string} The type of the module. Possible values: ['dataconnector', 'plugin'].
*/
function classify(plugin, logger) {
if (isDataConnector(plugin.module)) {
return 'dataconnector';
} else if (isPlugin(plugin.module)) {
return 'plugin';
} else {
logger.warn(errorMsg(plugin.name, 'Plugins should export a function.'));
}
}
async function resolveModule(plugin, apibuilder) {
const { config = {}, logger } = apibuilder;
const scopedConfig = getScopedConfig(config, plugin);
try {
plugin.module = pluginsAPI.loadPlugin(plugin.path);
} catch (err) {
logger.error(errorMsg(plugin.name, err));
throw err;
}
if (isDataConnector(plugin.module)) {
plugin.type = 'dataconnector';
return plugin;
} else if (isPlugin(plugin.module)) {
plugin.type = 'plugin';
const options = {
appDir: config.dir,
service: apibuilder._internal.getServiceInfo(),
// Freeze the scoped logger, to prevent malicious changes
logger: Object.freeze(logger.scope(null, { prefix: `[${plugin.name}]` }))
};
try {
// execute the plugin function.
plugin.module = await plugin.module(scopedConfig, options);
} catch (err) {
let errResp;
if (err instanceof Error) {
errResp = err;
errResp.message
= `There was a problem loading '${plugin.path}':\n${err.message}`;
} else {
errResp = new Error(`There was a problem loading '${plugin.path}':\n${err}`);
}
// Reject the plugin even if only one of the swaggers fails to compile to
// components
throw errResp;
}
return plugin;
} else {
throw new Error(errorMsg(plugin.name, 'Plugins should export a function.'));
}
}
/**
* Classifies and resolves the plugin module.
* @param {object} plugin - The plugin descriptor
* @param {object} config - The service config
* @param {object} logger - The api builder config
* @returns {Promise} A promise that resolves the plugin's module and returns the resolved
* plugin object.
*/
async function resolveModuleOld(plugin, apibuilder) {
const { config = {}, logger } = apibuilder;
try {
plugin.module = pluginsAPI.loadPlugin(plugin.path);
} catch (err) {
logger.warn(errorMsg(plugin.name, err));
return plugin;
}
plugin.type = classify(plugin, logger);
if (plugin.type !== 'plugin') {
return plugin;
}
const scopedConfig = getScopedConfig(config, plugin);
const options = {
appDir: config.dir,
service: apibuilder._internal.getServiceInfo(),
// Freeze the scoped logger, to prevent malicious changes
logger: Object.freeze(logger.scope(null, { prefix: `[${plugin.name}]` }))
};
// execute the plugin function.
try {
plugin.module = await plugin.module(scopedConfig, options);
} catch (err) {
logger.warn(errorMsg(plugin.name, err));
plugin.module = undefined;
}
return plugin;
}
/**
* Classifies and resolves the plugin config.
* @param {object} plugin - The plugin descriptor
* @param {object} config - The service config
* @returns {object} The plugin config object.
*/
function getScopedConfig(config, plugin) {
if (apiBuilderConfig.flags.enableScopedConfig) {
config.pluginConfig = config.pluginConfig || {};
return Object.freeze({ proxy: config.proxy, ...config.pluginConfig[plugin.name] });
}
const scopedConfig = config.pluginConfig
? { proxy: config.proxy, ...config.pluginConfig[plugin.name] } : config;
return scopedConfig;
}
/**
* Walk the specified path and list the modules within the path, and within sub-scopes.
* @private
* @param {string} pathToWalk The folder to walk.
* @returns {Promise} When resolved a list containing module descriptors { name, path }
*/
async function walkPath(pathToWalk) {
const modules = [];
try {
await accessAsync(pathToWalk, fs.constants.R_OK);
} catch (e) {
return modules;
}
const dirs = await readdirAsync(pathToWalk);
for (const module of dirs) {
if (module.startsWith('@')) {
// Scoped
const scoped = await readdirAsync(path.join(pathToWalk, module));
modules.push(...scoped.map(sub => ({
name: `${module}/${sub}`,
path: path.join(pathToWalk, module, sub)
})));
}
// Not scoped
modules.push({
name: module,
path: path.join(pathToWalk, module)
});
}
return modules;
}
/**
* Find all the plugin modules on the search path.
* @param {object} config - The configuration to use when loading the plugin.
* @param {object} logger - The api builder logger.
* @returns {Promise} When resolved a map of all the available plugins grouped
* by type: { dataconnector: { name1: {name1, path1, version1, description1},
* ... nameN: {nameN, pathN, versionN, descriptionN} }, }.
*/
async function findPlugins(apibuilder) {
const { config = {}, logger } = apibuilder;
// Walk the node_modules and filter the results
const modules = await walkPath(path.join(config.dir || process.cwd(), 'node_modules'));
const apiBuilderPlugins = {};
pluginsAPI.emptyComponents = {
dataconnector: null,
schema: {},
flownodes: {},
triggers: {},
apiSpecs: {},
middlewares: {}
};
// NOTE: on some systems, dir entries can be in any order, which makes the
// order of plugins unpredictable. Currently, they are all independent and
// can be loaded in any order. By sorting alphabetically here, we are not
// solving the dependency problem - just making it a bit more predictable.
// In the event that we do ever have plugins that have dependencies either
// on plugins or specific start/stop tasks, please see RDPP-7203.
const sortedPlugins = modules
.filter(item => pluginsAPI.isPluginName(item.name))
.sort((a, b) => a.name.localeCompare(b.name));
for (const plugin of sortedPlugins) {
// Add metadata
let pkginfo;
try {
// Load metadata from package.json
pkginfo = require(`${plugin.path}/package.json`);
plugin.version = pkginfo.version;
// The repository property in package.json is optional
plugin.repository = pkginfo.repository && pkginfo.repository.url;
// The description property in package.json is optional
plugin.description = pkginfo.description || '';
plugin.tags = (
pkginfo.keywords
&& pkginfo.keywords.filter(a => a === 'community')
) || [];
plugin.support = (pkginfo.bugs && pkginfo.bugs.url)
|| pkginfo.homepage || plugin.repository || '';
// Will help us determine if a plugin is supported.
plugin.engines = pkginfo.engines || {};
} catch (ex) {
logger.trace(ex);
plugin.version = '';
plugin.description = '';
logger.warn(`Unable to find package.json in ${plugin.path}.`);
}
if (pkginfo) {
// validate the plugin is compatible with this version of runtime
try {
utils.validateModuleCompatibility(pkginfo, apibuilder);
} catch (err) {
// Keep respect the exitOnPluginFailure flag while it exists.
if (apiBuilderConfig.flags.exitOnPluginFailure) {
throw err;
} else {
// Warn and go to the next plugin
logger.warn(err.message);
continue;
}
}
}
// Classify and resolve module as plugin
if (apiBuilderConfig.flags.exitOnPluginFailure) {
await resolveModule(plugin, apibuilder);
} else {
await resolveModuleOld(plugin, apibuilder);
}
// check if the plugins were loaded correctly;
if (!plugin.module || !plugin.type) {
continue;
}
// Restructure the module into components. The component structure has
// moved to `pluginsAPI.emptyComponents` for easier maintenance.
plugin.components
= JSON.parse(JSON.stringify(pluginsAPI.emptyComponents));
if (plugin.type === 'plugin') {
const {
flownodes,
schema,
triggers,
apiSpecs,
middlewares
} = plugin.module;
if (flownodes) {
for (const name in flownodes) {
const node = flownodes[name];
node.type = FlowManager.formatNodeHandlerUri(plugin.name, name);
plugin.components.flownodes[node.type] = node;
}
}
if (schema) {
for (const item of schema) {
// schema without id will clash here with an "undefined" key
// axway-schema will catch it out later.
plugin.components.schema[item.id] = item;
}
}
if (triggers) {
plugin.components.triggers = triggers;
}
if (apiSpecs) {
plugin.components.apiSpecs = apiSpecs;
}
if (middlewares) {
plugin.components.middlewares = middlewares;
}
} else {
// data connector plugins only contain a data connector
plugin.components.dataconnector = plugin.module;
}
delete plugin.module;
apiBuilderPlugins[plugin.name] = plugin;
logger.info(`Registered plugin: ${plugin.name}`);
}
return apiBuilderPlugins;
}
function loadPlugin(name) {
return require(name);
}
function isPluginName(name) {
return PLUGIN_REGEX.test(name);
}
const pluginsAPI = {
findPlugins,
loadPlugin,
isPluginName
};
module.exports = pluginsAPI;