UNPKG

@axway/api-builder-runtime

Version:

API Builder Runtime

906 lines (843 loc) 27.6 kB
/** * @class APIBuilder.API */ const apiBuilderConfig = require('@axway/api-builder-config'); const uriUtils = require('@axway/api-builder-uri-utils'); const { pathToRegexp } = require('path-to-regexp'); // Collections for storing the bounds API paths - non parametrised and parametrised. let apiPaths = {}; let apiPathsParams = {}; const respCodeExp = /^([1-5][0-9][0-9]|default)$/; var _ = require('lodash'), util = require('util'), fs = require('fs'), events = require('events'), async = require('async'), APIBuilder = require('./apibuilder'), pluralize = require('pluralize'), Response = require('./response'), formatters = require('./formatters'), utils = require('./utils'), apis = [], APIClass = new events.EventEmitter(); util.inherits(API, events.EventEmitter); const fieldOptionalDeprecation = util.deprecate(() => {}, 'Using the \'optional\' property in API parameters and Model fields has been deprecated. Use \'required\' instead. See https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D034', 'D034' ); const sortDeprecation = util.deprecate(() => {}, 'Using the \'sort\' property in APIs and Routes has been deprecated in favor of a more robust internal sort mechanism. See https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D037', 'D037' ); const responseDeprecation = util.deprecate(() => {}, 'Using the \'response\' API property has been deprecated. Use \'model\' instead. See https://docs.axway.com/bundle/api-builder/page/docs/deprecations/index.html#D044', 'D044' ); function API(impl, config, apibuilder) { // Recursively merge `impl` properties into `this`, and specifically merge in // `constructor` if provided. impl && utils.mergeWithConstructor(this, impl); this.apibuilder = apibuilder; // incoming constructor config should overwrite implementation const initialConfig = impl && impl.config || {}; this.config = _.merge({}, initialConfig, apibuilder.config, config); this.enabled = impl.enabled === undefined ? true : impl.enabled; if (impl.sort !== undefined) { sortDeprecation(); this.sort = impl.sort; } else if (impl.generated) { this.sort = impl.path.indexOf(':') < 0 ? -1 : -2; } else { this.sort = 1; } // if we provided a constructor in our impl, use it if (this.constructor && this.constructor !== API && !this.constructor.super_) { this.constructor.call(this); API.constructor.call(this); } if (!this.group) { throw new Error('required group property missing on API'); } if (!this.path) { throw new Error('required path property missing on API'); } if (!this.action) { throw new Error('required action property missing on API'); } if (!this.description) { throw new Error('required description property missing on API'); } // turn the model into a Model object if (this.model && typeof (this.model) === 'string') { this.model = this.apibuilder.getModel(this.model, true); } if (!this.response) { // response is used to generate the api doc so we need to set it if not // specified this.response = this.model; } else { // get the response model responseDeprecation(); this.response = this.apibuilder.getModel(this.response, true); } const { singular, plural } = this; if (this.model) { this.singular = makeResponseKey(singular || this.model.singular || this.model.name, 1); this.plural = makeResponseKey(plural || this.model.plural || this.model.name); } if (this.response) { // use the response if multiple models this.singular = makeResponseKey(singular || this.response.singular || this.response.name, 1); this.plural = makeResponseKey(plural || this.response.plural || this.response.name); } // pre/post -> before/after. you pick. this.pre = this.pre || this.before; this.post = this.post || this.after; function getBlock(name) { if (_.isFunction(name)) { return function blockMiddleware(req, resp, next) { try { var blockFn = name; if (blockFn) { if (blockFn.length === 3) { blockFn(req, resp, next); } else { blockFn(req, resp); next(); } } else { return next(); } } catch (e) { next(e); } }; } else { return this.apibuilder.getBlock(name, true).getMiddleware(); } } // load the pre block(s) if (this.pre) { if (typeof (this.pre) === 'string') { this.pre = [ this.apibuilder.getBlock(this.pre, true).getMiddleware() ]; } else if (Array.isArray(this.pre)) { this.pre = this.pre.map(getBlock.bind(this)); } else { throw new Error('unknown block type (pre)'); } if (this.pre.length === 0) { delete this.pre; } } // load the post block(s) if (this.post) { if (typeof (this.post) === 'string') { this.post = [ this.apibuilder.getBlock(this.post, true).getMiddleware() ]; } else if (Array.isArray(this.post)) { this.post = this.post.map(getBlock.bind(this)); } else { throw new Error('unknown block type (post)'); } if (this.post.length === 0) { delete this.post; } } // programmers are lazy, allow both this.parameters = this.parameters || this.params; for (const name in this.parameters) { const param = this.parameters[name]; // Since we set both optional and required on models and APIs // You're always going to see this deprecation warning if you have // APIs generated from models, or extended models. // Checking for optional exclusively should get around this problem // and assume this is the source of the parameter definition rather // than a duplicated definition. if (param.optional !== undefined && param.required === undefined) { fieldOptionalDeprecation(); break; } } var method = (this.method || 'GET').toUpperCase(); if (!this.parameters && this.model && /(PUT|POST)/.test(method)) { var bodyfields = {}, model = this.model, localSingular = this.singular; // we pull out our fields so that we can generate the PUT/POST parameters Object.keys(model.fields).forEach(function (name) { const field = model.fields[name]; const required = (field.optional === undefined && field.required === undefined && field.default) || field.required || !field.optional; const description = field.description || `the ${localSingular} ${name} field`; if (!field.readonly) { bodyfields[name] = { description, type: 'body', optional: !required, required }; } }); if (method === 'PUT') { bodyfields.id = { description: 'primary key id', optional: false, required: true, type: 'body' }; } this.parameters = bodyfields; } var params = this.path.split('/'), parts = [], foundParams = []; params.forEach(function (name) { if (name.charAt(0) === ':') { name = name.substring(1); var required = true; if (name.charAt(name.length - 1) === '?') { name = name.substring(0, name.length - 1); required = false; } var param = this.parameters && this.parameters[name]; if (!param) { throw new Error('missing parameters definition for path parameter: ' + name); } param.optional = !required; param.required = required; param.name = name; param.type = 'path'; foundParams.push(name); } else { parts.push(name); } }.bind(this)); // Creating two collections of path with // and without params in the path if (this.path.indexOf('/:') !== -1) { // Param case addParam(this, apiPathsParams); } else { addParam(this, apiPaths); } apibuilder.logger.debug( 'registering' + (this.enabled ? '' : ' disabled') + ' api', this.method, this.path); var key = parts.join('/'); // register by nickname as well as path if (this.nickname) { if (this.nickname in apiPaths) { apiPaths[this.nickname].push(this); } else { apiPaths[this.nickname] = [ this ]; } apibuilder.logger.debug('registering api (nickname)', this.nickname); } else { // the nickname should just be the path so we have one this.nickname = method + ' ' + key; } this.key = key; this.parameters && Object.keys(this.parameters).forEach(function (name) { var param = this.parameters[name]; if (!param.description) { throw new Error('missing description for parameter: ' + name); } // RDPP-6721: This is broken. // optional is deprecated, and required isn't taken into account // this means if required is used, it will always be ignored, and if // false, it will conflict and have optional: false, required: false // and always be required. Additionally, this current behavior means that // optional is set to false by default meaning that all parameters are // required by default. param.optional = !!param.optional; param.name = name; param.type = param.type || 'query'; if (!/^(query|body|path)$/.test(param.type)) { throw new Error(`invalid type: ${param.type} for ${name} parameter. only 'body', 'path' and 'query' are accepted`); } if (param.type === 'path' && foundParams.indexOf(name) === -1) { throw new Error('found path parameter: ' + name + ' but not defined in route'); } }.bind(this)); // Validating custom api responses if (this.responses) { for (const prop in this.responses) { if (!respCodeExp.test(prop)) { throw new Error(`Invalid \`responses\` key ${prop} in the path ${this.path} for ${this.method} method. Response keys can only be valid HTTP status codes or 'default'`); } } const { apiPrefixSecurity } = this.apibuilder.config.accessControl; if (apiPrefixSecurity !== 'none' && this.responses.hasOwnProperty('401')) { apibuilder.logger .warn(`\`apiPrefixSecurity\` is enabled. responses['401'] in the path ${this.path} for ${this.method} method will be ignored in place of API Builder's own UnauthorizedError`); } } } /** * Utility for adding params into paths collections. * * @param {object} api the API to be added * @param {object} coll the collection to be added */ function addParam(api, coll) { const path = api.path; if (path in coll) { coll[path].push(api); } else { coll[path] = [ api ]; } } function makeResponseKey(value, count) { // remove any slashes in the case of something like appc.foo/modelname var i = value.lastIndexOf('/'); if (i > 0) { value = value.substring(i + 1); } return pluralize(value.toLowerCase(), count); } API.on = function on() { APIClass.on.apply(APIClass, arguments); }; API.removeListener = function removeListener() { APIClass.removeListener.apply(APIClass, arguments); }; API.removeAllListeners = function removeAllListeners() { APIClass.removeAllListeners.apply(APIClass, arguments); }; /** * Clears internal cache of API. Should only be called by tests, or by * internal APIBuilder shutdown. */ API.clearAPIs = function clearAPIs() { apis.length = 0; apiPaths = {}; apiPathsParams = {}; }; /** * Returns an API for the given path. * @static * @param {String} path API path. * @returns {Array<APIBuilder.API>} */ API.getAPIsForPath = function (path) { if (apiPaths[path]) { return apiPaths[path]; } const paths = Object.keys(apiPathsParams); let matchedPath; for (let i = 0; i < paths.length; i++) { const newRegexPath = pathToRegexp(paths[i], [], { end: true }); if (newRegexPath.test(path)) { matchedPath = paths[i]; break; } } return apiPathsParams[matchedPath]; }; /** * Returns a constructor function to generate a new API endpoint. * Pass the constructor an APIBuilder configuration object, APIBuilder instance, and optionally a * filename. * @static * @param {Dictionary<APIBuilder.API>} impl Implementation object. * @returns {Function} * @throws {Error} Missing required parameter. */ API.extend = function classExtend(impl) { return function APIConstructor(config, apibuilder, fn) { if (!apibuilder) { throw new Error('invalid constructor. must be called with apibuilder instance as 2nd argument'); } var api = new API(impl, config, apibuilder); api.filename = fn; fn && (api.timestamp = fs.statSync(fn).mtime); return api; }; }; /** * Returns a constructor function to generate a new API by extending this instance. * Pass the constructor an APIBuilder configuration object, APIBuilder instance, and optionally a * filename. * @param {Dictionary<APIBuilder.API>} impl Implementation object. * @returns {Function} * @throws {Error} Missing required parameter. */ API.prototype.extend = function instanceExtend(impl) { // This is creating a constructor for a new extended type. We do not want // to modify `this`. return API.extend(utils.mergeWithConstructor({}, this, impl)); }; function InteruptedError() {} function invoke(api, fn, req, resp, next, isBlock) { // if we've already invoked an api, we only run it once if (!isBlock && req._alreadyCalled) { return next(); } // if we have a callback, handle it async if (fn.length === 3) { var alreadyCalled = false; function invokeCallback(err, results) { // Did we receive our results from an async waterfall? if (results && Array.isArray(results) && results.length === 2) { // If so, one will be truthy, and the other falsy, or both will be falsy. if ((!results[0] && results[1]) || (results[0] && !results[1]) || (!results[0] && !results[1])) { results = results[0]; } } // only run the callback once and only invoke the first API available, otherwise fall // through to the next middleware in the stack if (!isBlock && (alreadyCalled || req._alreadyCalled)) { if (next) { try { next(); } catch (e) { req.logger.trace(e); } } next = null; return; } alreadyCalled = true; // this is an interrupted chain. stop processing if (err === false) { return next(new InteruptedError()); } if (resp._sendCalled || isBlock) { return next(err); } if (err) { const error = 'trying to remove, couldn\'t find record with primary key:'; if (err.message && err.message.includes(error)) { return resp.notFound(next); } return next(err); } else { var method = api.method, noResult = (results === undefined || results === null); switch (method) { case 'GET': if (!isBlock) { req._alreadyCalled = !noResult; } return noResult ? next() : resp.success(results, next); case 'POST': if (results && _.isFunction(results.getMeta) && results.getMeta('includeResponseBody')) { if (!isBlock) { req._alreadyCalled = true; } return resp.success(results, next); } else if (results && results.getPrimaryKey && api.generated) { if (!isBlock) { req._alreadyCalled = true; } if (api.nickname === 'Upsert' && api.generated && req.params.id) { return resp.noContent(next); } else if (apiBuilderConfig.flags.enableModelsWithNoPrimaryKey && results.getPrimaryKey() === undefined) { return resp.withStatus(next, 201); } else { return resp.redirectPath(results.getPrimaryKey(), next, 201); } } break; case 'PUT': case 'DELETE': if (!isBlock) { req._alreadyCalled = true; } return resp.noContent(next); default: break; } // indicate we've already done an API so only one API is ever matched if (!isBlock) { req._alreadyCalled = !noResult; } if (noResult) { return next(); } else if (results) { return resp.success(results, next); } else { resp.noContent(next); } } } fn.apply(null, [ req, resp, invokeCallback ]); } else { // else if we don't have a callback we can just deal with it synchronously fn.apply(null, [ req, resp ]); next(); } } /* * Gets the middleware block that provides access to the * API's action implementation. * Pass the function returned by this method an Express request object, * Express response object, APIBuilder Response object and the function to call next. * @returns {Function} */ API.prototype.getMiddleware = function getMiddleware() { var api = this, apibuilder = this.apibuilder; return function MiddlewareConstructor(req, resp, r, next) { if (req._middlewareExecuted) { return next(); } req._middlewareExecuted = true; var _next = next; if (!api.enabled) { return r.notAllowed(next); } try { const reqlog = apibuilder.logger.scope(req); next = function next() { // ensure next only ever gets called once if (_next) { var fn = _next; _next = null; return fn.apply(this, arguments); } }; apibuilder._internal.countTransaction(); // we use our hidden variable from our main middleware because express // overwrites for each call to next. however, in route params it's there in params // so we have to merge them req.params = _.merge({}, req._params, req.params); const bodyIsObject = req.body && [ 'object', 'function' ].includes(typeof req.body); var qkeys = req.query && Object.keys(req.query), bkeys = bodyIsObject && !(req.body instanceof Buffer) && Object.keys(req.body); // validate incoming parameters (query only) if (qkeys && qkeys.length && !this.parameters && !this.wildcardParameters) { return r.badRequest('query parameters sent but missing in API definition', next); } if (bkeys && bkeys.length && !this.parameters && !this.wildcardParameters) { return r.badRequest('body parameters sent but missing in API definition', next); } reqlog.trace('┌ Processing Request:'); Object.keys(req) .filter(a => [ 'body', 'query', 'params', 'files' ].includes(a)) .forEach((key, i, keys) => { let value; if (key === 'body' && req.body instanceof Buffer) { if (!req.body.length) { return; } value = `<Buffer length: ${req.body.length}>`; } else { if (!Object.keys(req[key]).length) { return; } value = JSON.stringify(req[key]); } const longBranch = '├'; const shortBranch = '└'; const ch = (i < keys.length - 1) ? longBranch : shortBranch; reqlog.trace(`${ch} ${key} ${value}`); }); if (this.parameters) { var params = Object.keys(this.parameters); for (var c = 0; c < params.length; c++) { const key = params[c]; const param = this.parameters[key]; const required = ( param.required === undefined && param.optional === undefined && param.default === undefined ) || !param.optional || param.required; if (req.query && key in req.query) { qkeys.splice(qkeys.indexOf(key), 1); if (!(key in req.params)) { req.params[key] = req.query[key]; } } else if (req.body && key in req.body) { bkeys.splice(bkeys.indexOf(key), 1); if (!(key in req.params)) { req.params[key] = req.body[key]; } } else if (req.files && key in req.files) { req.params[key] = req.files[key].file; } else if (required && param.type !== 'path' && !(key in req.params)) { reqlog.trace('required params key not found:', key, param); return r.badRequest( `required ${param.type} parameter: ${key} missing`, next); } } if (qkeys && qkeys.length && !this.wildcardParameters) { reqlog.trace('required query key not found:', qkeys); return r.badRequest( `query parameters: ${qkeys.join(', ')} missing in API definition`, next); } if (bkeys && bkeys.length && !this.wildcardParameters) { reqlog.trace('required body key not found:', bkeys); return r.badRequest( `body parameters: ${bkeys.join(', ')} missing in API definition`, next); } } // NOTE: we don't need to validate path parameters since we get that for free // as part of express routing // map in our model if we have one if (this.model) { req.model = r.model = this.model.createRequest(req, resp); } if (this.response) { req.responseModel = r.responseModel = this.response.createRequest(req, resp); } // if we have blocks, we need to execute them if (this.pre || this.post) { var tasks = []; if (this.pre) { this.pre.forEach(function preIterator(block) { tasks.push(function preIteratorTask(next_) { try { invoke(api, block, req, r, next_, true); } catch (e) { next_(e); } }); }); } var action_ = this.action; tasks.push(function actionTask(next_) { try { invoke(api, action_, req, r, next_); } catch (e) { next_(e); } }); if (this.post) { this.post.forEach(function postIterator(block) { tasks.push(function postIteratorTask(next_) { try { invoke(api, block, req, r, next_, true); } catch (e) { next_(e); } }); }); } return async.series(tasks, function callback(err) { if (err && err instanceof InteruptedError) { // this is OK, just used to stop processing err = null; } if (err) { r.error(err); } r.flushBody(); if (!err) { next(); } }); } else { invoke(api, this.action, req, r, function (err) { if (err && err instanceof InteruptedError) { // this is OK, just used to stop processing err = null; } if (err) { if (err.stack) { req.log.trace(err.stack); } r.error(err, next); } r.flushBody(); if (!err) { next(); } }); } } catch (e) { // eslint-disable-next-line no-console console.error(e.stack); req.logger.error(e, e.stack); next(e); } }.bind(this); }; /** * Removes the API from the APIBuilder instance. * @param {Object} apibuilder APIBuilder instance. * @param {Function} [callback] Callback passed an Error object (or null if successful) and the * @removed API. */ API.prototype.remove = function (apibuilder, callback) { apibuilder.apis = apibuilder.apis.filter(api => api !== this); // remove the routing entry from express var array = apibuilder.app._router.stack; var keys = Object.keys(formatters.extensions).join('|'); var removeIndices = []; for (var c = 0; c < array.length; c++) { var entry = array[c], re = new RegExp('^' + this.path.replace(/\//g, '\\/') + '\\.(' + keys + ')$'); if (entry.route && (entry.route.path === this.path || re.test(entry.route.path))) { var stack = entry.route.stack; for (var i = 0; i < stack.length; i++) { if (stack[i].method.toLowerCase() === this.method.toLowerCase()) { stack.splice(i, 1); break; } } // no more routes, mark to later remove it if (stack.length === 0) { removeIndices.push(entry); } } } if (removeIndices.length) { removeIndices.forEach(function (entry) { for (var c = 0; c < array.length; c++) { if (entry === array[c]) { array.splice(c, 1); } } }); } callback && callback(null, this); }; /** * Reloads the API for the APIBuilder instance. * @param {Object} apibuilder APIBuilder instance. * @param {Function} [callback] Callback passed an Error object (or null if successful) and the * @reloaded API. */ API.prototype.reload = function (apibuilder, callback) { if (this.filename) { this.timestamp = fs.statSync(this.filename).mtime; var old = this; // remove the route this.remove(apibuilder, function () { // remove it from the apibuilder apibuilder.loadApi(old.filename, function (err, api) { callback && callback(err, old, api); }); }); } else { callback && callback(null, this); } }; /** * Binds this API to the app instance * @param {Object} app App instance. * @throws {Error} Missing app instance. */ API.prototype.bind = function (app) { if (!app) { throw new Error('app required for bind'); } var method = (this.method || 'GET').toLowerCase(), middleware = this.middleware = this.getMiddleware(), api = this; this.app = app; // if flag enabled, path is already encoded, so no escaping necessary const expPath = apiBuilderConfig.flags.enableModelNameEncoding ? this.path : uriUtils.escapeExpressRegex(this.path); const disabled = this.enabled ? '' : ' disabled'; const type = this.generated ? 'model' : 'api'; app.logger.debug(`binding ${disabled} api (${method}) ${expPath} [${type}] sort: ${this.sort}`); function createAPIBinding(preCallback) { return function apiBinding(req, resp, next) { req.logger = resp.logger = req.log = resp.log = app.logger.scope(req); if (preCallback) { preCallback(req); } var status = resp.statusCode, r = new Response(req, resp, api); middleware(req, resp, r, function middlewareCallback(err, result) { if (err) { return next(err); } if (result) { return next(null, result); } if (status === resp.statusCode) { next(); } else { r.flushBody(); } r.done(); }); }; } // add a route for each extension Object.keys(formatters.extensions).forEach(function (extension) { var mimeType = formatters.extensions[extension]; app[method]( uriUtils.escapeExpressRegex(`${api.path}.${extension}`), createAPIBinding(function (req) { req.headers.accept = mimeType; }) ); }); // save the unescaped path because we will need it later for generating swagger. we don't want // this path because it's been modified for express. this.pathUnescaped = this.path; // add the explicit route this.route = app[method](uriUtils.escapeExpressRegex(this.path), createAPIBinding()); }; function emptyFn() { } /** * Executes this API with the specified parameters. * Results are sent to to the callback. * @param {Object} params API parameters. * @param {Function} callback Callback passed an Error object (or null if successful) and the * @results. */ API.prototype.execute = function (params, callback) { if (_.isFunction(params)) { callback = params; params = {}; } var request = { __proto__: this.app.request, app: this.app }; var response = { __proto__: this.app.response, app: this.app }; request.url = this.path; request.params = params; request.method = this.method; request.headers = { accept: 'application/json' }; response.headers = response._headers = {}; response.setHeader = response.set = emptyFn; request.APIBuilder = {}; request.session = response.session = request.APIBuilder; request.session.destroy = response.session.destroy = emptyFn; request.logger = response.logger = request.log = response.log = this.apibuilder.logger.scope(request); request.skipSecurityCheck = true; request.server = this.apibuilder; var r = new Response(request, response, this); r.flushBody = emptyFn; r.skipFormatting = true; try { // execute the middleware this.middleware(request, response, r, function (err, result) { // handle error scenarios if (err) { return callback(err); } result = r.rawbody || result; if (result && result.success === false) { err = new Error(result.message || 'error'); err.result = result; err.code = result.code; return callback(err); } else if (response.statusCode > 299) { var http = require('http'); err = new Error(http.STATUS_CODES[response.statusCode]); err.body = r.rawbody; err.code = response.statusCode; return callback(err); } // make sure to convert any collection, models into JSON var value = result && result.key && result[result.key]; if (value && ( value instanceof APIBuilder.Instance || value instanceof APIBuilder.Collection || _.isFunction(value.toJSON))) { result[result.key] = value.toJSON(); } return callback(null, result); }); } catch (e) { callback(e); } }; module.exports = API;