@axway/api-builder-runtime
Version:
API Builder Runtime
906 lines (843 loc) • 27.6 kB
JavaScript
/**
* @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;