apialize
Version:
Turn a database model into a production ready REST(ish) CRUD API in a few lines.
592 lines (553 loc) • 19.6 kB
JavaScript
const utils = require('./utils');
const express = utils.express;
const apializeContext = utils.apializeContext;
const ensureFn = utils.ensureFn;
const asyncHandler = utils.asyncHandler;
const defaultNotFound = utils.defaultNotFound;
const list = require('./list');
const create = require('./create');
const update = require('./update');
const patch = require('./patch');
const destroy = require('./destroy');
const operationUtils = require('./operationUtils');
const withTransactionAndHooks = operationUtils.withTransactionAndHooks;
const optionsWithTransaction = operationUtils.optionsWithTransaction;
const normalizeId = operationUtils.normalizeId;
const notFoundWithRollback = operationUtils.notFoundWithRollback;
function getErrorMessage(err) {
if (process.env.NODE_ENV === 'development') {
return String((err && err.message) || err);
}
return 'Internal Error';
}
function setupRelatedEndpoints(
router,
parentModel,
related,
parentIdMapping,
parentModelOptions,
parentParamName = 'id'
) {
related.forEach(function (relatedConfig) {
if (!relatedConfig.model) {
throw new Error(
"Related model configuration must include a 'model' property"
);
}
const relatedModel = relatedConfig.model;
const relatedOptions = relatedConfig.options || {};
const perOperation = relatedConfig.perOperation || {};
const foreignKey = relatedConfig.foreignKey;
const path = relatedConfig.path;
const operations = relatedConfig.operations || ['list', 'get'];
const endpointPath = path || modelNameToPath(relatedModel.name);
const relatedForeignKey =
foreignKey || parentModel.name.toLowerCase() + '_id';
const resolveParentInternalId = async function (
req,
currentParamName,
preferStored
) {
const effectiveParamName =
typeof currentParamName === 'string'
? currentParamName
: parentParamName;
const useStoredFirst = Boolean(preferStored);
var parentParamId = null;
if (useStoredFirst) {
if (req.apialize && req.apialize.parentId != null) {
parentParamId = req.apialize.parentId;
} else if (req.params && req.params[effectiveParamName] != null) {
parentParamId = req.params[effectiveParamName];
} else if (req.params && req.params['id'] != null) {
parentParamId = req.params['id'];
}
} else {
if (req.params && req.params[effectiveParamName] != null) {
parentParamId = req.params[effectiveParamName];
} else if (req.params && req.params['id'] != null) {
parentParamId = req.params['id'];
} else if (req.apialize && req.apialize.parentId != null) {
parentParamId = req.apialize.parentId;
}
}
if (!parentParamId) return null;
if ((parentIdMapping || 'id') === 'id') return parentParamId;
const where = {};
where[parentIdMapping || 'id'] = parentParamId;
const queryOptions = Object.assign(
{
where: where,
attributes: ['id'],
},
parentModelOptions || {}
);
try {
const parent = await parentModel.findOne(queryOptions);
return parent ? (parent.get ? parent.get('id') : parent.id) : null;
} catch (_e) {
return null;
}
};
const parentFilterMiddlewareFactory = function (
paramName,
preferStoredArg
) {
const effectiveParamName =
typeof paramName === 'string' ? paramName : parentParamName;
const preferStoredVal = Boolean(preferStoredArg);
return asyncHandler(async function (req, res, next) {
if (!req.apialize) req.apialize = {};
if (!req.apialize.options) req.apialize.options = {};
if (!req.apialize.options.where) req.apialize.options.where = {};
const parentInternalId = await resolveParentInternalId(
req,
effectiveParamName,
preferStoredVal
);
if (
parentInternalId === null ||
typeof parentInternalId === 'undefined'
) {
req.apialize.options.where[relatedForeignKey] = '__apialize_none__';
} else {
req.apialize.options.where[relatedForeignKey] = parentInternalId;
}
next();
});
};
const setForeignKeyMiddlewareFactory = function (
paramName,
preferStoredArg
) {
const effectiveParamName =
typeof paramName === 'string' ? paramName : parentParamName;
const preferStoredVal = Boolean(preferStoredArg);
return asyncHandler(async function (req, res, next) {
const writeMethods = ['POST', 'PUT', 'PATCH'];
if (writeMethods.indexOf(req.method) !== -1) {
if (!req.apialize) req.apialize = {};
if (!req.apialize.values) req.apialize.values = {};
const parentInternalId = await resolveParentInternalId(
req,
effectiveParamName,
preferStoredVal
);
if (parentInternalId == null) return defaultNotFound(res);
req.apialize.values[relatedForeignKey] = parentInternalId;
}
next();
});
};
const parentFilterForRead = parentFilterMiddlewareFactory(
parentParamName,
false
);
const parentFilterForWrite = parentFilterMiddlewareFactory(
parentParamName,
true
);
const baseReadMiddleware = []
.concat(parentFilterForRead)
.concat(
Array.isArray(relatedOptions.middleware)
? relatedOptions.middleware
: []
);
const baseWriteMiddleware = []
.concat(parentFilterForWrite)
.concat(setForeignKeyMiddlewareFactory(parentParamName, true))
.concat(
Array.isArray(relatedOptions.middleware)
? relatedOptions.middleware
: []
);
const resolveOpConfig = function (opName) {
const op =
perOperation && perOperation[opName] ? perOperation[opName] : {};
const isWrite =
opName === 'post' ||
opName === 'put' ||
opName === 'patch' ||
opName === 'delete';
const baseMw = isWrite ? baseWriteMiddleware : baseReadMiddleware;
const opMiddleware = Array.isArray(op.middleware) ? op.middleware : [];
const mergedMiddleware = [].concat(baseMw).concat(opMiddleware);
const mergedOptions = Object.assign({}, relatedOptions, op);
mergedOptions.middleware = mergedMiddleware;
return {
options: mergedOptions,
modelOptions: op.modelOptions || relatedOptions.modelOptions || {},
id_mapping: op.id_mapping || relatedOptions.id_mapping || 'id',
middleware: mergedMiddleware,
allow_bulk_delete:
typeof op.allow_bulk_delete === 'boolean'
? op.allow_bulk_delete
: false,
};
};
const relatedRouter = express.Router({ mergeParams: true });
const storeParentIdMiddleware = function (req, _res, next) {
req.apialize = req.apialize || {};
req.apialize.parentId = req.params[parentParamName];
next();
};
if (operations.indexOf('list') !== -1) {
const { options: listOptions, modelOptions: listModelOptions } =
resolveOpConfig('list');
const relatedListRouter = list(
relatedModel,
listOptions,
listModelOptions
);
relatedRouter.use('/', relatedListRouter);
}
if (
operations.indexOf('post') !== -1 ||
operations.indexOf('create') !== -1
) {
const { options: createOptions, modelOptions: postModelOptions } =
resolveOpConfig('post');
const relatedCreateRouter = create(
relatedModel,
createOptions,
postModelOptions
);
relatedRouter.use('/', relatedCreateRouter);
}
if (operations.indexOf('get') !== -1) {
const {
options: getOptions,
modelOptions: getModelOptions,
id_mapping: relatedIdMapping,
} = resolveOpConfig('get');
const childSingleOptions = Object.assign({}, getOptions);
// Use a parameter name that matches the actual URL parameter for this model
const relatedParamName = relatedModel.name.toLowerCase() + 'Id';
childSingleOptions.param_name = relatedParamName;
childSingleOptions.id_mapping = relatedIdMapping;
childSingleOptions.related = Array.isArray(relatedConfig.related)
? relatedConfig.related
: [];
const childSingleRouter = single(
relatedModel,
childSingleOptions,
getModelOptions
);
const nested = express.Router({ mergeParams: true });
// Create a custom store parent middleware that uses the correct parameter name
const storeRelatedParentIdMiddleware = function (req, _res, next) {
req.apialize = req.apialize || {};
req.apialize.parentId = req.params[relatedParamName];
next();
};
nested.use('/', storeRelatedParentIdMiddleware, childSingleRouter);
relatedRouter.use('/', nested);
}
if (
operations.indexOf('put') !== -1 ||
operations.indexOf('update') !== -1
) {
const { options: updateOptions, modelOptions: putModelOptions } =
resolveOpConfig('put');
const relatedUpdateRouter = update(
relatedModel,
updateOptions,
putModelOptions || {}
);
relatedRouter.use('/', storeParentIdMiddleware, relatedUpdateRouter);
}
if (operations.indexOf('patch') !== -1) {
const { options: patchOptions, modelOptions: patchModelOptions } =
resolveOpConfig('patch');
const relatedPatchRouter = patch(
relatedModel,
patchOptions,
patchModelOptions || {}
);
relatedRouter.use('/', storeParentIdMiddleware, relatedPatchRouter);
}
if (
operations.indexOf('delete') !== -1 ||
operations.indexOf('destroy') !== -1
) {
const { options: destroyOptions, modelOptions: deleteModelOptions } =
resolveOpConfig('delete');
const relatedDestroyRouter = destroy(
relatedModel,
destroyOptions,
deleteModelOptions || {}
);
relatedRouter.use('/', storeParentIdMiddleware, relatedDestroyRouter);
// Bulk DELETE: when confirm!=true, dry-run and return ids; when confirm==true, delete and return count and ids
const {
modelOptions: bulkDelModelOptions,
id_mapping: bulkDelIdMapping,
middleware: bulkDelMiddleware,
allow_bulk_delete,
} = resolveOpConfig('delete');
if (allow_bulk_delete) {
relatedRouter.delete(
'/',
storeParentIdMiddleware,
apializeContext,
...bulkDelMiddleware,
asyncHandler(async (req, res) => {
const q = req.query || {};
const confirmVal = String(q.confirm).toLowerCase();
const confirmed =
['true', '1', 'yes', 'y'].indexOf(confirmVal) !== -1;
const baseWhere =
(req.apialize &&
req.apialize.options &&
req.apialize.options.where) ||
{};
if (Object.prototype.hasOwnProperty.call(baseWhere, 'confirm')) {
delete baseWhere.confirm;
}
const findOptions = Object.assign({}, bulkDelModelOptions, {
where: baseWhere,
attributes: [bulkDelIdMapping],
});
const rows = await relatedModel.findAll(findOptions);
const ids = [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
if (r && typeof r.get === 'function') {
ids.push(r.get(bulkDelIdMapping));
} else if (r) {
ids.push(r[bulkDelIdMapping]);
}
}
if (!confirmed) {
return res.json({ success: true, confirm_required: true, ids });
}
try {
const destroyOptions = Object.assign({}, bulkDelModelOptions, {
where: baseWhere,
});
const deleted = await relatedModel.destroy(destroyOptions);
return res.json({ success: true, deleted, ids });
} catch (err) {
console.error('[Apialize] Bulk delete error:', err);
return res.status(500).json({
success: false,
error: getErrorMessage(err),
});
}
})
);
}
}
relatedRouter.use(function (err, _req, res, _next) {
// eslint-disable-next-line no-console
console.error('[Apialize] Related route error:', err);
res.status(500).json({ success: false, error: getErrorMessage(err) });
});
router.use(`/:${parentParamName}/${endpointPath}`, relatedRouter);
});
}
function modelNameToPath(modelName) {
const snakeCase = modelName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
return pluralize(snakeCase);
}
function pluralize(word) {
if (word.endsWith('y')) {
return word.slice(0, -1) + 'ies';
} else if (
word.endsWith('s') ||
word.endsWith('sh') ||
word.endsWith('ch') ||
word.endsWith('x') ||
word.endsWith('z')
) {
return word + 'es';
} else {
return word + 's';
}
}
function single(model, options = {}, modelOptions = {}) {
ensureFn(model, 'findOne');
const middleware = Array.isArray(options.middleware)
? options.middleware
: [];
const id_mapping =
typeof options.id_mapping === 'string' ? options.id_mapping : 'id';
const param_name =
typeof options.param_name === 'string' ? options.param_name : 'id';
const related = Array.isArray(options.related) ? options.related : [];
const pre = options.pre || null;
const post = options.post || null;
const member_routes = Array.isArray(options.member_routes)
? options.member_routes
: [];
const inline = middleware.filter(function (fn) {
return typeof fn === 'function';
});
const router = express.Router({ mergeParams: true });
router.get(
`/:${param_name}`,
apializeContext,
...inline,
asyncHandler(async (req, res) => {
const payload = await withTransactionAndHooks(
{
model,
options: Object.assign({}, options, { pre: pre, post: post }),
req,
res,
modelOptions,
idMapping: id_mapping,
useReqOptionsTransaction: true,
},
async (context) => {
// Setup query parameters after pre-hooks (so pre-hooks can modify them)
const paramValue = req.params[param_name];
req.apialize.id = paramValue;
if (!req.apialize.where) req.apialize.where = {};
if (typeof req.apialize.where[id_mapping] === 'undefined')
req.apialize.where[id_mapping] = paramValue;
req.apialize.options = Object.assign(
{},
modelOptions,
req.apialize.options || {}
);
const modelWhere = (modelOptions && modelOptions.where) || {};
const reqOptionsWhere =
(req.apialize.options && req.apialize.options.where) || {};
const fullWhere = Object.assign(
{},
modelWhere,
reqOptionsWhere,
req.apialize.where
);
req.apialize.options.where = fullWhere;
const result = await model.findOne(req.apialize.options);
if (result == null) {
return notFoundWithRollback(context);
}
context.record = result;
let recordPayload = result;
if (recordPayload && typeof recordPayload === 'object')
recordPayload = recordPayload.get
? recordPayload.get({ plain: true })
: Object.assign({}, recordPayload);
recordPayload = normalizeId(recordPayload, id_mapping);
context.payload = { success: true, record: recordPayload };
return context.payload;
}
);
if (!res.headersSent) {
res.json(payload);
}
})
);
const loadSingleRecord = async function (req, res, next) {
const paramValue = req.params[param_name];
req.apialize = req.apialize || {};
req.apialize.id = paramValue;
req.apialize.where = req.apialize.where || {};
if (typeof req.apialize.where[id_mapping] === 'undefined') {
req.apialize.where[id_mapping] = paramValue;
}
req.apialize.options = Object.assign(
{},
modelOptions,
req.apialize.options || {}
);
const modelWhere = (modelOptions && modelOptions.where) || {};
const reqOptionsWhere =
(req.apialize.options && req.apialize.options.where) || {};
const fullWhere = Object.assign(
{},
modelWhere,
reqOptionsWhere,
req.apialize.where
);
req.apialize.options.where = fullWhere;
try {
const result = await model.findOne(req.apialize.options);
if (result == null) return defaultNotFound(res);
let recordPayload = result;
if (recordPayload && typeof recordPayload === 'object') {
recordPayload = recordPayload.get
? recordPayload.get({ plain: true })
: Object.assign({}, recordPayload);
}
recordPayload = normalizeId(recordPayload, id_mapping);
req.apialize.rawRecord = result;
req.apialize.record = recordPayload;
req.apialize.singlePayload = { success: true, record: recordPayload };
return next();
} catch (err) {
return next(err);
}
};
if (Array.isArray(member_routes) && member_routes.length > 0) {
const allowedList = ['get', 'post', 'put', 'patch', 'delete'];
function ensureLeadingSlash(p) {
if (typeof p !== 'string' || p.length === 0) return null;
if (p.charAt(0) === '/') return p;
return '/' + p;
}
member_routes.forEach(function (route, idx) {
if (
!route ||
typeof route !== 'object' ||
typeof route.handler !== 'function'
) {
throw new Error(
`[Apialize] member_routes[${idx}] must be an object with a 'handler' function and a 'path' string`
);
}
const method = String(route.method || 'get').toLowerCase();
if (allowedList.indexOf(method) === -1) {
throw new Error(
`[Apialize] member_routes[${idx}].method must be one of ${allowedList.join(', ')}`
);
}
const subPath = ensureLeadingSlash(route.path || '');
if (!subPath) {
throw new Error(
`[Apialize] member_routes[${idx}] requires a non-empty 'path' (e.g., 'stats' or '/stats')`
);
}
const fullPath = `/:${param_name}${subPath}`;
const perRouteMw = Array.isArray(route.middleware)
? route.middleware
: [];
router[method](
fullPath,
apializeContext,
...inline,
asyncHandler(loadSingleRecord),
...perRouteMw,
asyncHandler(async (req, res) => {
const out = await route.handler(req, res);
if (!res.headersSent) {
if (typeof out === 'undefined') {
return res.json(req.apialize.singlePayload);
}
return res.json(out);
}
})
);
});
}
if (Array.isArray(related) && related.length > 0) {
setupRelatedEndpoints(
router,
model,
related,
id_mapping,
modelOptions,
param_name
);
}
router.apialize = {};
return router;
}
module.exports = single;