api-query-params
Version:
Convert query parameters from API urls to MongoDB queries
340 lines (269 loc) • 9.18 kB
JavaScript
;
var _querystring = _interopRequireDefault(require("querystring"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) { symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); } keys.push.apply(keys, symbols); } return keys; }
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
const builtInCasters = {
boolean: val => val === 'true',
date: val => new Date(val),
null: () => null,
number: val => Number(val),
regex: (val, flags) => new RegExp(val, flags),
string: val => String(val)
};
const parseValue = (value, key, options) => {
// Match type casting operators like string(true)
const casters = _objectSpread(_objectSpread({}, builtInCasters), options.casters);
const castersList = Object.keys(casters).join('|');
const castersRegexp = new RegExp(`^(${castersList})\\(([^)]*)\\)$`);
const casting = value.match(castersRegexp);
if (casting && casters[casting[1]]) {
return casters[casting[1]](casting[2]);
} // Handle comma-separated values
const regexes = value.match(/\/.*?\/(?:[igm]*)/g);
const parts = regexes || value.split(',');
if (parts && parts.length > 1) {
return parts.map(part => parseValue(part, key, options));
} // Apply casters per params
if (options.castParams && options.castParams[key] && casters[options.castParams[key]]) {
return casters[options.castParams[key]](value);
} // Match regex operators like /foo_\d+/i
const regex = value.match(/^\/(.*)\/([igm]*)$/);
if (regex) {
return casters.regex(regex[1], regex[2]);
} // Match boolean values
if (value === 'true' || value === 'false') {
return casters.boolean(value);
} // Match null
if (value === 'null') {
return casters.null(value);
} // Match numbers (strings greater than MAX_SAFE_INTEGER or padded with zeros are not numbers)
if (!Number.isNaN(Number(value)) && Math.abs(value) <= Number.MAX_SAFE_INTEGER && !/^0[0-9]+/.test(value)) {
return casters.number(value);
} // Match YYYY-MM-DDTHH:mm:ssZ format dates
const date = value.match(/^[12]\d{3}(-(0[1-9]|1[0-2])(-(0[1-9]|[12][0-9]|3[01]))?)(T| )?(([01][0-9]|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/);
if (date) {
return casters.date(value);
} // Default to string
return casters.string(value);
};
const parseOperator = operator => {
if (operator === '=') {
return '$eq';
}
if (operator === '!=') {
return '$ne';
}
if (operator === '>') {
return '$gt';
}
if (operator === '>=') {
return '$gte';
}
if (operator === '<') {
return '$lt';
}
if (operator === '<=') {
return '$lte';
}
return '$exists';
};
/**
* Map/reduce helper to transform list of unaries
* like '+a,-b,c' to {a: 1, b: -1, c: 1}
*/
const parseUnaries = (unaries, values = {
plus: 1,
minus: -1
}) => {
const unariesAsArray = typeof unaries === 'string' ? unaries.split(',') : unaries;
return unariesAsArray.map(unary => unary.match(/^(\+|-)?(.*)/)).reduce((result, [, val, key]) => {
result[key.trim()] = val === '-' ? values.minus : values.plus;
return result;
}, {});
};
const parseJSONString = string => {
try {
return JSON.parse(string);
} catch (err) {
return false;
}
};
const getProjection = projection => {
const fields = parseJSONString(projection) || parseUnaries(projection, {
plus: 1,
minus: 0
});
/*
From the MongoDB documentation:
"A projection cannot contain both include and exclude specifications,
except for the exclusion of the _id field."
*/
const hasMixedValues = Object.keys(fields).reduce((set, key) => {
if (key !== '_id' && (fields[key] === 0 || fields[key] === 1)) {
set.add(fields[key]);
}
return set;
}, new Set()).size > 1;
if (hasMixedValues) {
Object.keys(fields).forEach(key => {
if (fields[key] === 1) {
delete fields[key];
}
});
}
return fields;
};
const getPopulation = population => {
const cache = {};
function iterateLevels(levels, prevLevels = []) {
let populate;
let path;
const topLevel = levels.shift();
prevLevels.push(topLevel);
const cacheKey = prevLevels.join('.');
if (cache[cacheKey]) {
path = cache[cacheKey];
} else {
path = {
path: topLevel
};
}
cache[cacheKey] = path;
if (levels.length) {
populate = iterateLevels(levels, prevLevels);
if (populate) {
path.populate = populate;
}
}
return path;
}
const populations = population.split(',').map(path => {
return iterateLevels(path.split('.'));
});
return [...new Set(populations)]; // Deduplicate array
};
const getSort = sort => parseUnaries(sort);
const getSkip = skip => Number(skip);
const getLimit = limit => Number(limit);
const parseFilter = filter => {
if (typeof filter === 'object') {
return filter;
}
const jsonFilter = parseJSONString(filter);
if (jsonFilter) {
return jsonFilter;
}
throw new Error(`Invalid JSON string: ${filter}`);
};
const getFilter = (filter, params, options) => {
const parsedFilter = filter ? parseFilter(filter) : {};
return Object.keys(params).map(val => {
const join = params[val] ? `${val}=${params[val]}` : val; // Separate key, operators and value
const [, prefix, key, op, value] = join.match(/(!?)([^><!=]+)([><]=?|!?=|)(.*)/);
return {
prefix,
key,
op: parseOperator(op),
value: parseValue(value, key, options)
};
}).filter(({
key
}) => options.blacklist.indexOf(key) === -1 && (!options.whitelist || options.whitelist.indexOf(key) !== -1)).reduce((result, {
prefix,
key,
op,
value
}) => {
if (!result[key]) {
result[key] = {};
} else if (typeof result[key] === 'string') {
result[key] = {
$eq: result[key]
};
}
if (Array.isArray(value)) {
result[key][op === '$ne' ? '$nin' : '$in'] = value;
} else if (op === '$exists') {
result[key][op] = prefix !== '!';
} else if (op === '$eq' && Object.entries(result[key]).length === 0) {
result[key] = value;
} else if (op === '$ne' && typeof value === 'object' && value !== null) {
result[key].$not = value;
} else {
result[key][op] = value;
}
return result;
}, parsedFilter);
};
const mergeProjectionAndPopulation = result => {
function iteratePopulation(population, prevPrefix = '') {
population.forEach(row => {
const prefix = `${prevPrefix}${row.path}.`;
Object.keys(result.projection).forEach(key => {
if (key.startsWith(prefix)) {
const unprefixedKey = key.replace(prefix, '');
if (unprefixedKey.indexOf('.') === -1) {
row.select = _objectSpread(_objectSpread({}, row.select), {}, {
[unprefixedKey]: result.projection[key]
});
delete result.projection[key];
}
}
});
if (row.populate) {
iteratePopulation([row.populate], prefix);
}
});
}
if (result.projection && result.population) {
iteratePopulation(result.population);
}
};
const operators = [{
operator: 'population',
method: getPopulation,
defaultKey: 'populate'
}, {
operator: 'projection',
method: getProjection,
defaultKey: 'fields'
}, {
operator: 'sort',
method: getSort,
defaultKey: 'sort'
}, {
operator: 'skip',
method: getSkip,
defaultKey: 'skip'
}, {
operator: 'limit',
method: getLimit,
defaultKey: 'limit'
}, {
operator: 'filter',
method: getFilter,
defaultKey: 'filter'
}];
const aqp = (query = '', options = {}) => {
const result = {};
const params = typeof query === 'string' ? _querystring.default.parse(query) : query;
options.blacklist = options.blacklist || [];
operators.forEach(({
operator,
method,
defaultKey
}) => {
const key = options[`${operator}Key`] || defaultKey;
const value = params[key];
options.blacklist.push(key);
if (value || operator === 'filter') {
result[operator] = method(value, params, options);
}
});
mergeProjectionAndPopulation(result);
return result;
};
module.exports = aqp;
module.exports.default = aqp;