@axway/api-builder-runtime
Version:
API Builder Runtime
562 lines (524 loc) • 17 kB
JavaScript
const _ = require('lodash');
const async = require('async');
const apiBuilderConfig = require('@axway/api-builder-config');
module.exports = (APIBuilder, options = {}) => {
const log = options.logger;
return { execComposite, checkParse };
function execComposite(params) {
log && log.trace('Composite connector executed. Method:', params.method);
const { Model, isWrite, isCollection, method } = params;
let arg = params.arg;
let next = params.next;
if (_.isFunction(arg)) {
next = arg;
arg = undefined;
}
const instances = {};
const joinedModels = getJoinedModelsInfo({ method, isWrite, Model, arg });
if (joinedModels instanceof Error) {
return next(joinedModels);
} else {
retrieveJoinedModels({
Model,
isWrite,
isCollection,
method,
arg,
instances,
joinedModels
}, (err, resp) => {
if (err) {
return next(err);
} else {
return next(null, resp);
}
});
}
}
function execModelMethod(MasterModel, model, method, arg, next) {
const SourceModel = APIBuilder.getModel(model.name);
if (arg) {
translateKeysForModel(MasterModel, SourceModel, arg, model.isNotJoined);
SourceModel[method](arg, next);
} else {
SourceModel[method](next);
}
}
function runJoin({ Model, isCollection, instances, joinedModels, isInnerJoin, next }) {
let retVal = null;
// instance0 is an instance of the parent model.
const instance0 = instances[Object.keys(instances)[0]];
const joinOneModel = (Object.keys(instances).length === 1);
if (joinOneModel) {
if (!instance0) {
retVal = false;
return next();
}
if (isCollection) {
async.map(
instance0,
(instance, cb) => {
runJoin({
Model: Model,
isCollection: false,
instances: { key0: instance },
joinedModels: joinedModels,
isInnerJoin: isInnerJoin,
next: cb
});
},
(err, results) => {
if (err) {
return next(err);
} else {
return next(
null,
Array.isArray(results) ? _.compact(results) : results
);
}
});
} else {
const id = instance0.getPrimaryKey ? instance0.getPrimaryKey() : instance0.id;
retVal = {
[Model.getPrimaryKeyName()]: id
};
populateValuesFromResult(Model, joinedModels[0], retVal, instance0.toJSON());
async.each(joinedModels.slice(1), queryModel, returnInstance);
}
} else {
// Join multiple models
Object.keys(Model.fields).forEach((key) => {
const field = Model.fields[key];
const collectionName = field.model;
if (instances[collectionName]) {
if (!retVal) {
retVal = {};
}
retVal[key] = instances[collectionName];
}
});
return next(null, retVal);
}
/**
* Queries one particular model for the data needed for the join.
*
* @param model
* @param next
*/
function queryModel(model, next) {
const SourceModel = APIBuilder.getModel(model.name);
const query = {};
const joinBy = model.left_join;
let hasJoin = false;
_.forIn(joinBy, (value, key) => {
query[key] = value === instance0.getModel().getPrimaryKeyName()
&& instance0.getPrimaryKey ? instance0.getPrimaryKey() : instance0[value];
const hasField
= (key === SourceModel.getPrimaryKeyName() || SourceModel.fields[key]);
if (!hasField && log) {
log.warn(`Skipping join on "${key}" because the model "${model.name}" has no matching field.`);
} else if (query[key] !== undefined) {
hasJoin = true;
}
});
if (!hasJoin) {
if (isInnerJoin) {
retVal = null;
}
return next();
}
const q = {
where: query,
limit: calculateLimit(model)
};
log && log.trace('Querying ' + model.name + ' where ' + JSON.stringify(q));
execModelMethod(Model, model, 'query', q, (err, result) => {
const isMultipleJoin = model.multiple;
if (err) {
return next(err);
}
log && log.trace('Performing', isInnerJoin ? 'inner join' : 'left join');
log && log.trace(`Multiple join set to ${isMultipleJoin}`);
if (isInnerJoin && (!result || (result instanceof Array && result.length === 0))) {
retVal = null;
}
if (isMultipleJoin) {
processMultipleJoin(model, result);
} else {
processSingleJoin(model, result);
}
return next();
});
}
function processMultipleJoin(model, result) {
if (!result.length) {
return;
}
// since the current field in the main model may have been aliased,
// we should get the name of the field being referred to in the joined model.
const key = Model.fields[model.fieldName].name;
// initialise the array to be returned.
// in the future if we actually want to return an empty array when nothing matched we
// can move this outside the result.length check (RDPP-1265)
retVal[model.fieldName] = [];
// loop through each result and build the array to be returned
for (let i = 0; i < result.length; i++) {
let item = result[i].toJSON();
// if the merge type is field at this point, it means we want an array containing
// values of a specific key rather than the whole result.
if (model.mergeType === 'field') {
item = item[key];
}
retVal[model.fieldName].push(item);
}
}
function processSingleJoin(model, result) {
if (!result) {
return;
}
// If the field only consists of a query for a single result then take
// all the fields that we want from the model that was queried and add to the retVal
populateValuesFromResult(Model, model, retVal, result);
}
/**
* Returns the limit calculated based on particular rules.
* @param model contains information for limit calculation
* @return limit the number of items in the query result
*/
function calculateLimit(model) {
var limit = 1;
if (model.multiple) {
// since multiple is set then we want to query for more than one instance of the
// model.
limit = (model.limit && (model.limit > 0 && model.limit < 1001)) ? model.limit : 10;
}
return limit;
}
/**
* Returns a composite instance based on the resultant queries.
*/
function returnInstance(err) {
if (err) {
return next(err);
}
if (!retVal) {
return next(null, null);
}
const instance = Model.instance(retVal, true);
instance.setPrimaryKey(retVal[Model.getPrimaryKeyName()]);
return next(null, instance);
}
}
function retrieveJoinedModels({
Model,
isWrite,
isCollection,
method,
arg,
instances,
joinedModels
}, next) {
async.each(
joinedModels,
(model, cb) => {
if (isWrite && model.readonly) {
return cb();
}
if (model.left_join || !model.name) {
return cb();
}
const localArg = arg ? calculateLocalArg(Model, model, arg, isWrite) : arg;
log && log.trace('Querying model:', model.name);
execModelMethod(Model, model, method, localArg, (err, instance) => {
if (err) {
cb(err);
} else {
if (instance) {
// sometimes we don't get back an array when we expect one
if (isCollection && !Array.isArray(instance)) {
instance = [ instance ];
}
instances[model.name] = instance;
}
cb();
}
});
},
(err) => {
if (err) {
return next(err);
} else if (Object.keys(instances).length === 0) {
// no instances to join with
return next(null, null);
} else {
const isInnerJoin = Model.getMeta('inner_join');
runJoin({ Model, isCollection, instances, joinedModels, isInnerJoin, next });
}
}
);
}
function getJoinedModelsInfo({ method, isWrite, Model, arg }) {
const modelFields = Model.fields;
const modelJoinsInfo = getJoinsInfo(Model);
const joinedModels = [];
const modelMap = {};
for (let fieldName in modelFields) {
if (modelFields.hasOwnProperty(fieldName)) {
const field = modelFields[fieldName];
const modelName = field.model;
if (!modelName || modelMap[modelName]) {
continue;
}
modelMap[modelName] = true;
if (!APIBuilder.getModel(modelName)) {
return new Error(`Unable to find model ${modelName}.`);
}
const modelJoinInfo = modelJoinsInfo[modelName];
if (modelJoinInfo) {
if (isWrite
&& (arg.getChangedFields ? arg.getChangedFields() : arg)[fieldName]) {
return new Error('API-354: Joined fields cannot be written to yet.');
}
if (method === 'query' && containsKey(arg, fieldName, [ 'sel', 'unsel' ])) {
return new Error('API-354: Joined fields cannot be queried on yet.');
}
const mergeInfo = getMergeInfo(field, modelJoinInfo);
joinedModels.push({
name: modelName,
readonly: true,
left_join: modelJoinInfo.join_properties,
fieldName: fieldName,
mergeType: mergeInfo.mergeType,
multiple: mergeInfo.multiple,
limit: field.limit
});
} else {
joinedModels.unshift({
name: modelName,
isNotJoined: true
});
}
}
}
log && log.trace('Joined models:', joinedModels);
return joinedModels;
}
/**
* @param {Object} Model the instance of the model
* @returns {Object} object that contains joins meta information for the model
*/
function getJoinsInfo(Model) {
const joinMeta = Model.getMeta('left_join') || Model.getMeta('inner_join');
if (!joinMeta) {
return {};
}
const modelMetas = {};
const multipleJoins = Array.isArray(joinMeta);
if (multipleJoins) {
joinMeta.forEach((meta) => {
modelMetas[meta.model] = meta;
});
} else {
modelMetas[joinMeta.model] = joinMeta;
}
return modelMetas;
}
/**
* The logic is left as it was before but could be improved to fix these if approved as issues:
*
* https://techweb.axway.com/jira/browse/RDPP-4454
* https://techweb.axway.com/jira/browse/RDPP-4453
*
* @param {Object} modelJoinInfo
*/
function getMergeInfo(field, modelJoinInfo) {
const hasAlias = !!field.name;
const fieldType = field.type;
const isMultipleJoin = modelJoinInfo.multiple;
// if a field has a name property then the value of that field
// will be assumed to come from a property of the joined model. (aka merge as fields)
// Otherwise it will be a join as object or array depending on the field type.
const mergeInfo = {
multiple: false
};
if (hasAlias) {
mergeInfo.mergeType = 'field';
} else if (fieldType === Object || fieldType === 'object') {
mergeInfo.mergeType = 'object';
} else {
// Note that the effect of this code is that if field has no alias and the field.type
// is String the logic behaves as it is Array. We keep it like before but there is
// ticket about this to discuss:
mergeInfo.mergeType = 'array';
mergeInfo.multiple = true;
}
// multiple is used when querying the joined model.
// If it is false, a limit of one will be queried.
// Normally if the merge type is not array, then only a single value will be returned.
// modelJoinInfo.multiple allows for many results to be returned from the joined model query
// but only of the field type is array. This will result in an array containing multiple
// values of the matching key of the joined model specified on field.name.
if (isMultipleJoin && (fieldType === Array || fieldType === 'array')) {
mergeInfo.multiple = true;
}
return mergeInfo;
}
function calculateLocalArg(Model, model, arg, isWrite) {
const fieldKey = fetchModelObjectFieldKey(Model, model);
let localArg = arg;
if (fieldKey) {
localArg = localArg[fieldKey]
|| (localArg.where && localArg.where[fieldKey])
|| localArg;
localArg = checkParse(localArg);
}
if (isWrite) {
const SourceModel = APIBuilder.getModel(model.name);
if (!localArg.getPrimaryKey) {
// Object
const mappedArgs = _.pick(localArg, Object.keys(SourceModel.fields));
// Pass along the primary key if set
if (localArg.hasOwnProperty(Model.getPrimaryKeyName())
&& SourceModel.getPrimaryKeyName()) {
mappedArgs[SourceModel.getPrimaryKeyName()]
= localArg[Model.getPrimaryKeyName()];
}
localArg = mappedArgs;
} else {
// Instance - convert instance to their source model instance and map aliases.
const instanceFields = {
cleanFields: {},
dirtyFields: {}
};
const allValues = localArg.values();
const allDirtyFields = localArg.values(true);
// Filter out instance fields not associated with this source model and unalias`
for (let valueKey in allValues) {
if (!allValues.hasOwnProperty(valueKey)) {
continue;
}
if (Model.fields[valueKey] && Model.fields[valueKey].model === model.name) {
const name = Model.fields[valueKey].name || valueKey;
if (allDirtyFields.hasOwnProperty(valueKey)) {
instanceFields.dirtyFields[name] = allValues[valueKey];
} else {
instanceFields.cleanFields[name] = allValues[valueKey];
}
}
}
// Create a new instance with the fields. Along these lines should be the fix for
// https://techweb.axway.com/jira/browse/RDPP-4452
const inst = SourceModel.instance(instanceFields.cleanFields);
inst.setPrimaryKey(localArg.getPrimaryKey());
inst.set(instanceFields.dirtyFields);
localArg = inst;
}
}
return localArg;
}
function fetchModelObjectFieldKey(Model, model) {
for (let key in Model.fields) {
/* istanbul ignore else */
if (Model.fields.hasOwnProperty(key)) {
const field = Model.fields[key];
const isObject = field.type === Object || field.type === 'object';
const isArray = field.type === Array || field.type === 'array';
if (field.model === model.name && (!field.name && (isObject || isArray))) {
return key;
}
}
}
return null;
}
function populateValuesFromResult(Model, model, retVal, result) {
for (var key in Model.fields) {
/* istanbul ignore else */
if (!Model.fields.hasOwnProperty(key)) {
continue;
}
var field = Model.fields[key],
isObject = field.type === Object || field.type === 'object',
isArray = field.type === Array || field.type === 'array';
// check of the field should come from the queried model
if (field.model === model.name) {
// if the field has no name property and is object or array this means we want to
// perform a join as object or array, setting the result of the query on the
// returned value rather than just a single field from the result
if (!field.name && (isObject || isArray)) {
retVal[key] = result;
} else {
// key is the key from the main model. this may be different on the queried
// model if it was aliased. field.name refers to the original name of the field
// on the joined model. If it exists in the field definition we will use that.
// Otherwise just use the name of the main model field.
retVal[key] = result[field.name || key];
}
}
}
}
function translateKeysForModel(MasterModel, SourceModel, arg, isSourceNotJoined) {
if (!_.isObject(arg)) {
return;
}
let mFields = MasterModel.fields;
let sFields = SourceModel.fields;
const masterPkName = MasterModel.getPrimaryKeyName();
const sourcePkName = SourceModel.getPrimaryKeyName();
// Operators supported by composite model
const operators = apiBuilderConfig.flags.enableAliasesInCompositeOperators
? [ '$like', '$eq', '$ne', '$in', '$nin', '$lt', '$gt', '$lte', '$gte' ]
: [ '$like' ];
for (var key in arg) {
/* istanbul ignore else */
if (arg.hasOwnProperty(key)) {
if (_.isObject(arg[key])
&& !(Object.keys(arg[key]).filter(v => operators.indexOf(v) !== -1)).length) {
translateKeysForModel(MasterModel, SourceModel, arg[key], isSourceNotJoined);
continue;
}
// if the source field is aliased and exists in the souce model, store it by the
// original name and delete the aliased.
if (mFields[key]
&& mFields[key].name
&& (mFields[key].name !== key)
&& sFields[mFields[key].name]) {
arg[mFields[key].name] = arg[key];
delete arg[key];
} else if (isSourceNotJoined
&& key === masterPkName
&& sourcePkName !== masterPkName) {
// This is a special case where the source model is not joined and the primary
// key has been aliased (see RDPP-4732-30).
arg[sourcePkName] = arg[key];
delete arg[key];
}
}
}
}
function checkParse(val) {
if (typeof val === 'string' && val[0] === '{') {
try {
return JSON.parse(val);
} catch (err) {
// Eat the parse error.
log && log.warn('Failed to parse JSON:', val, 'with error:', err, 'continuing on.');
}
}
return val;
}
function containsKey(obj, key, ignore) {
for (var sKey in obj) {
/* istanbul ignore else */
if (obj.hasOwnProperty(sKey)) {
if (ignore && ignore.indexOf(sKey) >= 0) {
continue;
}
if (sKey === key
|| (_.isObject(obj[sKey]) && containsKey(obj[sKey], key, ignore))) {
return true;
}
}
}
return false;
}
};