apialize
Version:
Turn a database model into a production ready REST(ish) CRUD API in a few lines.
830 lines (701 loc) • 19.1 kB
JavaScript
const {
express,
apializeContext,
ensureFn,
asyncHandler,
mergeReqOptionsIntoModelOptions,
} = require('./utils');
const {
withTransactionAndHooks,
normalizeRows,
normalizeRowsWithForeignKeys,
} = require('./operationUtils');
const {
validateColumnExists,
validateDataType,
buildResponse,
resolveIncludedAttribute,
getModelAttributes,
} = require('./listUtils');
function getSequelizeOp(model) {
if (model && model.sequelize) {
if (model.sequelize.constructor && model.sequelize.constructor.Op) {
return model.sequelize.constructor.Op;
}
if (model.sequelize.Sequelize && model.sequelize.Sequelize.Op) {
return model.sequelize.Sequelize.Op;
}
}
try {
return require('sequelize').Op;
} catch (error) {
return {};
}
}
const SEARCH_DEFAULTS = {
middleware: [],
defaultPageSize: 100,
defaultOrderBy: 'id',
defaultOrderDir: 'ASC',
metaShowOrdering: false,
pre: null,
post: null,
id_mapping: 'id',
relation_id_mapping: null,
path: '/search',
};
function getDatabaseDialect(model) {
if (
model &&
model.sequelize &&
typeof model.sequelize.getDialect === 'function'
) {
return model.sequelize.getDialect();
}
return null;
}
function getCaseInsensitiveOperators(dialect, Op) {
const isPostgres = dialect === 'postgres';
const caseInsensitiveLike = isPostgres ? Op.iLike || Op.like : Op.like;
const caseInsensitiveNotLike = isPostgres
? Op.notILike || Op.notLike
: Op.notLike;
return {
like: caseInsensitiveLike,
notLike: caseInsensitiveNotLike,
};
}
function findRelationMapping(relationIdMapping, foundModel) {
if (!Array.isArray(relationIdMapping)) {
return null;
}
return relationIdMapping.find((mapping) => {
if (mapping.model === foundModel) {
return true;
}
if (mapping.model && foundModel) {
if (mapping.model.name === foundModel.name) {
return true;
}
if (mapping.model.tableName === foundModel.tableName) {
return true;
}
}
return false;
});
}
function buildAliasPath(resolved, actualColumn) {
const aliasPrefix = resolved.aliasPath.split('.').slice(0, -1).join('.');
if (aliasPrefix) {
return `${aliasPrefix}.${actualColumn}`;
}
return actualColumn;
}
function resolveIncludedModelColumn(model, includes, key, relationIdMapping) {
const resolved = resolveIncludedAttribute(model, includes, key);
if (!resolved) {
return { error: `Invalid column '${key}'` };
}
const parts = key.split('.');
let actualColumn = parts[parts.length - 1];
let outKey;
let validateColumn = actualColumn;
let attribute = resolved.attribute;
if (actualColumn === 'id') {
const relationMapping = findRelationMapping(
relationIdMapping,
resolved.foundModel
);
if (relationMapping && relationMapping.id_field) {
actualColumn = relationMapping.id_field;
const newAliasPath = buildAliasPath(resolved, actualColumn);
outKey = `$${newAliasPath}$`;
validateColumn = actualColumn;
const attrs = getModelAttributes(resolved.foundModel);
attribute = attrs && attrs[actualColumn];
} else {
outKey = `$${resolved.aliasPath}$`;
}
} else {
outKey = `$${resolved.aliasPath}$`;
}
return {
outKey,
validateModel: resolved.foundModel,
validateColumn,
attribute,
};
}
function buildFieldPredicate(
model,
key,
rawVal,
Op,
includes,
relationIdMapping
) {
const dialect = getDatabaseDialect(model);
const operators = getCaseInsensitiveOperators(dialect, Op);
let outKey = key;
let validateModel = model;
let validateColumn = key;
let attribute;
if (typeof key === 'string' && key.includes('.')) {
const resolved = resolveIncludedModelColumn(
model,
includes,
key,
relationIdMapping
);
if (resolved.error) {
return resolved;
}
outKey = resolved.outKey;
validateModel = resolved.validateModel;
validateColumn = resolved.validateColumn;
attribute = resolved.attribute;
} else {
const attrs = getModelAttributes(validateModel);
attribute = attrs && attrs[validateColumn];
}
if (rawVal && typeof rawVal === 'object' && !Array.isArray(rawVal)) {
return buildObjectPredicate(
rawVal,
key,
outKey,
validateModel,
validateColumn,
Op,
operators
);
}
return buildEqualityPredicate(
rawVal,
key,
outKey,
validateModel,
validateColumn,
attribute,
operators
);
}
function getOperatorMapping(Op, operators) {
return {
eq: Op.eq,
'=': Op.eq,
ieq: operators.like,
neq: Op.ne,
'!=': Op.ne,
gt: Op.gt,
'>': Op.gt,
gte: Op.gte,
'>=': Op.gte,
lt: Op.lt,
'<': Op.lt,
lte: Op.lte,
'<=': Op.lte,
in: Op.in,
not_in: Op.notIn,
contains: Op.like,
icontains: operators.like,
not_contains: Op.notLike,
not_icontains: operators.notLike,
starts_with: Op.like,
ends_with: Op.like,
not_starts_with: Op.notLike,
not_ends_with: Op.notLike,
is_true: Op.eq,
is_false: Op.eq,
};
}
function transformOperatorValue(operatorKey, value) {
if (operatorKey === 'contains' || operatorKey === 'not_contains') {
return `%${value}%`;
}
if (operatorKey === 'icontains' || operatorKey === 'not_icontains') {
return `%${value}%`;
}
if (operatorKey === 'starts_with' || operatorKey === 'not_starts_with') {
return `${value}%`;
}
if (operatorKey === 'ends_with' || operatorKey === 'not_ends_with') {
return `%${value}`;
}
if (operatorKey === 'is_true') {
return true;
}
if (operatorKey === 'is_false') {
return false;
}
return value;
}
function buildObjectPredicate(
rawVal,
key,
outKey,
validateModel,
validateColumn,
Op,
operators
) {
if (!validateColumnExists(validateModel, validateColumn)) {
return { error: `Invalid column '${key}'` };
}
const operatorMapping = getOperatorMapping(Op, operators);
const sequelizeOperators = {};
for (const operatorKey of Object.keys(rawVal)) {
const value = rawVal[operatorKey];
if (!Object.prototype.hasOwnProperty.call(operatorMapping, operatorKey)) {
continue;
}
const sequelizeOp = operatorMapping[operatorKey];
const transformedValue = transformOperatorValue(operatorKey, value);
sequelizeOperators[sequelizeOp] = transformedValue;
}
if (Reflect.ownKeys(sequelizeOperators).length === 0) {
return {};
}
if (!Array.isArray(rawVal.in) && rawVal.in !== undefined) {
const valueToValidate = (rawVal && rawVal.in && rawVal.in[0]) || rawVal.in;
if (!validateDataType(validateModel, validateColumn, valueToValidate)) {
return { error: `Invalid value for '${key}'` };
}
}
return { [outKey]: sequelizeOperators };
}
function isStringType(attribute) {
if (!attribute || !attribute.type || !attribute.type.constructor) {
return false;
}
const typeName = String(attribute.type.constructor.name).toLowerCase();
const stringTypes = ['string', 'text', 'char', 'varchar'];
return stringTypes.includes(typeName);
}
function buildEqualityPredicate(
rawVal,
key,
outKey,
validateModel,
validateColumn,
attribute,
operators
) {
if (!validateColumnExists(validateModel, validateColumn)) {
return { error: `Invalid column '${key}'` };
}
if (!validateDataType(validateModel, validateColumn, rawVal)) {
return { error: `Invalid value for '${key}'` };
}
if (isStringType(attribute)) {
return { [outKey]: { [operators.like]: rawVal } };
}
return { [outKey]: rawVal };
}
function mergeObjectProperties(target, source) {
for (const key of Object.keys(source)) {
if (
target[key] &&
typeof target[key] === 'object' &&
typeof source[key] === 'object'
) {
target[key] = Object.assign({}, target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
function processAndFilters(filters, model, Op, includes, relationIdMapping) {
const parts = [];
for (const item of filters.and) {
const subWhere = buildWhere(model, item, Op, includes, relationIdMapping);
if (subWhere && Object.keys(subWhere).length) {
parts.push(subWhere);
}
}
const merged = {};
const orClauses = [];
for (const part of parts) {
if (part[Op.or]) {
const orArray = Array.isArray(part[Op.or]) ? part[Op.or] : [part[Op.or]];
orClauses.push(...orArray);
const { [Op.or]: omitted, ...rest } = part;
mergeObjectProperties(merged, rest);
} else {
mergeObjectProperties(merged, part);
}
}
if (orClauses.length) {
merged[Op.or] = orClauses;
}
return merged;
}
function processOrFilters(filters, model, Op, includes, relationIdMapping) {
const parts = [];
for (const item of filters.or) {
const subWhere = buildWhere(model, item, Op, includes, relationIdMapping);
if (subWhere && Object.keys(subWhere).length) {
parts.push(subWhere);
}
}
if (parts.length === 0) {
return {};
}
return { [Op.or]: parts };
}
function processImplicitAndFilters(
filters,
model,
Op,
includes,
relationIdMapping
) {
const keys = Object.keys(filters);
const andParts = [];
for (const key of keys) {
if (key === 'and' || key === 'or') {
continue;
}
const value = filters[key];
if (key === 'and' && Array.isArray(value)) {
continue;
}
if (key === 'or' && Array.isArray(value)) {
continue;
}
const predicate = buildFieldPredicate(
model,
key,
value,
Op,
includes,
relationIdMapping
);
if (predicate && predicate.error) {
return { __error: predicate.error };
}
if (predicate && Object.keys(predicate).length) {
andParts.push(predicate);
}
}
if (andParts.length === 0) {
return {};
}
if (andParts.length === 1) {
return andParts[0];
}
const merged = {};
for (const part of andParts) {
mergeObjectProperties(merged, part);
}
return merged;
}
function buildWhere(model, filters, Op, includes, relationIdMapping) {
if (!filters || typeof filters !== 'object') {
return {};
}
if (Array.isArray(filters.and)) {
return processAndFilters(filters, model, Op, includes, relationIdMapping);
}
if (Array.isArray(filters.or)) {
return processOrFilters(filters, model, Op, includes, relationIdMapping);
}
return processImplicitAndFilters(
filters,
model,
Op,
includes,
relationIdMapping
);
}
function normalizeOrderingItems(ordering) {
if (!ordering) {
return [];
}
if (Array.isArray(ordering)) {
return ordering;
}
return [ordering];
}
function extractOrderColumn(item) {
return item.order_by || item.orderby || item.column || item.field;
}
function normalizeOrderDirection(item, defaultOrderDir) {
const direction = item.direction || item.dir || defaultOrderDir || 'ASC';
return String(direction).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
}
function resolveOrderColumnName(column, idMapping) {
if (column === 'id' && idMapping) {
return idMapping;
}
return column;
}
function buildIncludeChain(resolved, parts) {
if (Array.isArray(resolved.includeChain)) {
return resolved.includeChain.map((c) => ({ model: c.model, as: c.as }));
}
return [
{
model: resolved.foundModel,
as: parts.slice(0, -1).join('.') || parts[0],
},
];
}
function processIncludedOrderColumn(
model,
columnName,
includes,
relationIdMapping
) {
const resolved = resolveIncludedAttribute(model, includes || [], columnName);
if (!resolved) {
return { error: `Invalid order column '${columnName}'` };
}
const parts = columnName.split('.');
let attribute = parts[parts.length - 1];
if (attribute === 'id') {
const relationMapping = findRelationMapping(
relationIdMapping,
resolved.foundModel
);
if (relationMapping && relationMapping.id_field) {
attribute = relationMapping.id_field;
}
}
const includeChain = buildIncludeChain(resolved, parts);
return { includeChain, attribute };
}
function buildOrdering(
model,
ordering,
defaultOrderBy,
defaultOrderDir,
idMapping,
includes,
relationIdMapping
) {
const items = normalizeOrderingItems(ordering);
const orderClauses = [];
for (const item of items) {
if (!item || typeof item !== 'object') {
continue;
}
const column = extractOrderColumn(item);
if (!column) {
continue;
}
const direction = normalizeOrderDirection(item, defaultOrderDir);
const columnName = resolveOrderColumnName(column, idMapping);
if (typeof columnName === 'string' && columnName.includes('.')) {
const result = processIncludedOrderColumn(
model,
columnName,
includes,
relationIdMapping
);
if (result.error) {
return result;
}
orderClauses.push([...result.includeChain, result.attribute, direction]);
} else {
if (!validateColumnExists(model, columnName)) {
return { error: `Invalid order column '${columnName}'` };
}
orderClauses.push([columnName, direction]);
}
}
if (orderClauses.length === 0) {
const effectiveOrderBy = resolveOrderColumnName(defaultOrderBy, idMapping);
orderClauses.push([effectiveOrderBy, defaultOrderDir || 'ASC']);
}
return orderClauses;
}
function extractSearchParameters(body) {
return {
filters: body.filtering || {},
ordering: body.ordering || null,
paging: body.paging || {},
};
}
function extractMergedOptions(searchOptions) {
const merged = Object.assign({}, SEARCH_DEFAULTS, searchOptions || {});
return {
defaultPageSize: merged.defaultPageSize,
defaultOrderBy: merged.defaultOrderBy,
defaultOrderDir: merged.defaultOrderDir,
metaShowOrdering: !!merged.metaShowOrdering,
idMapping: merged.id_mapping || 'id',
relationIdMapping: merged.relation_id_mapping,
pre: merged.pre,
post: merged.post,
};
}
async function executeSearchOperation(
model,
searchOptions,
modelOptions,
req,
res,
body = {}
) {
const options = extractMergedOptions(searchOptions);
const { filters, ordering, paging } = extractSearchParameters(body);
const mergedReqOptions = mergeReqOptionsIntoModelOptions(req, modelOptions);
req.apialize.options = mergedReqOptions;
const Op = getSequelizeOp(model);
return await withTransactionAndHooks(
{
model,
options: { ...searchOptions, pre: options.pre, post: options.post },
req,
res,
modelOptions,
idMapping: options.idMapping,
useReqOptionsTransaction: true,
},
async (context) => {
const { page, pageSize } = processPaging(paging, options.defaultPageSize);
req.apialize.options.limit = pageSize;
req.apialize.options.offset = (page - 1) * pageSize;
const includes = getIncludesFromContext(req, model);
const whereTree = buildWhere(
model,
filters || {},
Op,
includes,
options.relationIdMapping
);
if (whereTree && whereTree.__error) {
logBadRequest('Search bad request', whereTree.__error, body, req);
context.res.status(400).json({ success: false, error: 'Bad request' });
return;
}
if (Reflect.ownKeys(whereTree).length) {
req.apialize.options.where = Object.assign(
{},
req.apialize.options.where || {},
whereTree
);
}
const orderArray = buildOrdering(
model,
ordering,
options.defaultOrderBy,
options.defaultOrderDir,
options.idMapping,
includes,
options.relationIdMapping
);
if (orderArray && orderArray.error) {
logBadRequest('Search bad request', orderArray.error, body, req);
context.res.status(400).json({ success: false, error: 'Bad request' });
return;
}
req.apialize.options.order = orderArray;
const result = await model.findAndCountAll(req.apialize.options);
const normalizeRowsFn = async (rows, idMappingParam) => {
return await normalizeRowsWithForeignKeys(
rows,
idMappingParam,
options.relationIdMapping,
model
);
};
const response = await buildResponse(
result,
page,
pageSize,
undefined,
false,
options.metaShowOrdering,
false,
req,
options.idMapping,
normalizeRowsFn
);
context.payload = response;
return context.payload;
}
);
}
function processPaging(paging, defaultPageSize) {
let page = parseInt(paging.page, 10);
if (isNaN(page) || page < 1) {
page = 1;
}
let pageSize = parseInt(paging.size ?? paging.page_size, 10);
if (isNaN(pageSize) || pageSize < 1) {
pageSize = defaultPageSize;
}
return { page, pageSize };
}
function getIncludesFromContext(req, model) {
let includes = req.apialize.options.include || [];
if (model && model._scope && model._scope.include) {
const scopeIncludes = Array.isArray(model._scope.include)
? model._scope.include
: [model._scope.include];
if (Array.isArray(includes)) {
includes = [...includes, ...scopeIncludes];
} else {
includes = [...scopeIncludes, includes];
}
}
return includes;
}
function logBadRequest(message, error, body, req) {
if (process.env.NODE_ENV === 'development') {
console.warn(
`[Apialize] ${message}: ${error}. Body:`,
JSON.stringify(body, null, 2),
`URL: ${req.originalUrl}`
);
}
}
function getMiddlewareFunctions(middleware) {
if (!Array.isArray(middleware)) {
return [];
}
return middleware.filter((fn) => typeof fn === 'function');
}
function normalizePath(path) {
const basePath = (typeof path === 'string' && path.trim()) || '/search';
if (basePath.startsWith('/')) {
return basePath;
}
return `/${basePath}`;
}
function disableQueryFilters(req, res, next) {
req._apializeDisableQueryFilters = true;
next();
}
function search(model, options = {}, modelOptions = {}) {
ensureFn(model, 'findAndCountAll');
const merged = Object.assign({}, SEARCH_DEFAULTS, options || {});
const middlewareFunctions = getMiddlewareFunctions(merged.middleware);
const router = express.Router({ mergeParams: true });
const mountPath = normalizePath(merged.path);
router.post(
mountPath,
disableQueryFilters,
apializeContext,
...middlewareFunctions,
asyncHandler(async (req, res) => {
const body = (req && req.body) || {};
const payload = await executeSearchOperation(
model,
options,
modelOptions,
req,
res,
body
);
if (!res.headersSent) {
res.json(payload);
}
})
);
router.apialize = {};
return router;
}
module.exports = search;
module.exports.executeSearchOperation = executeSearchOperation;