apialize
Version:
Turn a database model into a production ready REST(ish) CRUD API in a few lines.
603 lines (516 loc) • 15.6 kB
JavaScript
const utils = require('./utils');
const { createHelpers } = require('./contextHelpers');
const defaultNotFound = utils.defaultNotFound;
function createBaseContext(params) {
const ctx = {};
ctx.req = params.req;
ctx.request = params.req;
ctx.res = params.res;
ctx.model = params.model;
ctx.options = params.options;
ctx.modelOptions = params.modelOptions;
ctx.idMapping = params.idMapping;
ctx.transaction = null;
ctx.preResult = undefined;
ctx.payload = null;
if (params.req && params.req.apialize) {
ctx.apialize = params.req.apialize;
}
return ctx;
}
function addHelpersToContext(ctx, params) {
if (params.model && params.req && params.req.apialize) {
const helpers = createHelpers(params.req, params.model);
Object.assign(params.req.apialize, helpers);
Object.assign(ctx, helpers);
} else if (params.req && params.req.apialize) {
const helpers = createHelpers(params.req);
Object.assign(params.req.apialize, helpers);
Object.assign(ctx, helpers);
}
}
function buildContext(params) {
const ctx = createBaseContext(params);
addHelpersToContext(ctx, params);
return ctx;
}
function hasSequelize(model) {
if (!model) {
return false;
}
if (!model.sequelize) {
return false;
}
if (typeof model.sequelize.transaction !== 'function') {
return false;
}
return true;
}
function optionsWithTransaction(opts, t) {
if (!t) {
if (opts) {
return opts;
}
return {};
}
let result;
if (opts) {
result = Object.assign({}, opts);
} else {
result = {};
}
result.transaction = t;
return result;
}
async function rollbackTransaction(transaction) {
if (transaction && typeof transaction.rollback === 'function') {
try {
await transaction.rollback();
} catch (error) {
// Ignore rollback errors
}
}
}
function markContextAsRolledBack(context) {
if (context) {
context._rolledBack = true;
context._responseSent = true;
}
}
async function notFoundWithRollback(context) {
const transaction = context && context.transaction;
await rollbackTransaction(transaction);
markContextAsRolledBack(context);
return defaultNotFound(context.res);
}
function normalizeId(row, idMapping) {
if (!row || typeof row !== 'object') {
return row;
}
if (!idMapping || idMapping === 'id') {
return row;
}
if (Object.prototype.hasOwnProperty.call(row, idMapping)) {
const next = Object.assign({}, row);
next.id = row[idMapping];
delete next[idMapping];
return next;
}
return row;
}
function normalizeRows(rows, idMapping) {
if (!Array.isArray(rows)) {
return rows;
}
const normalized = [];
for (let i = 0; i < rows.length; i++) {
const normalizedRow = normalizeId(rows[i], idMapping);
normalized.push(normalizedRow);
}
return normalized;
}
function isValidBelongsToAssociation(association) {
if (association.associationType !== 'BelongsTo') {
return false;
}
if (!association.foreignKey) {
return false;
}
if (!association.target) {
return false;
}
return true;
}
function findMappingForTargetModel(relationIdMapping, targetModel) {
for (let i = 0; i < relationIdMapping.length; i++) {
const mapping = relationIdMapping[i];
if (!mapping.model || !mapping.id_field) {
continue;
}
if (mapping.model === targetModel) {
return mapping;
}
if (mapping.model.name === targetModel.name) {
return mapping;
}
if (mapping.model.tableName === targetModel.tableName) {
return mapping;
}
}
return null;
}
function processSequelizeAssociations(model, relationIdMapping, fkMappings) {
if (!model.associations || typeof model.associations !== 'object') {
return;
}
const associationNames = Object.keys(model.associations);
for (let i = 0; i < associationNames.length; i++) {
const associationName = associationNames[i];
const association = model.associations[associationName];
if (!isValidBelongsToAssociation(association)) {
continue;
}
const fkField = association.foreignKey;
const targetModel = association.target;
const mapping = findMappingForTargetModel(relationIdMapping, targetModel);
if (mapping) {
fkMappings[fkField] = {
model: mapping.model,
id_field: mapping.id_field,
pk_field: 'id',
association: associationName,
};
}
}
}
function identifyForeignKeyFields(model, relationIdMapping) {
if (!model || !Array.isArray(relationIdMapping)) {
return {};
}
const fkMappings = {};
processSequelizeAssociations(model, relationIdMapping, fkMappings);
function generatePossibleForeignKeyNames(modelName) {
const lowerModelName = modelName.toLowerCase();
return [
`${lowerModelName}_id`,
`${lowerModelName}Id`,
`${lowerModelName}_key`,
`${lowerModelName}Key`,
];
}
function addPatternBasedForeignKeys(model, relationIdMapping, fkMappings) {
if (!model.rawAttributes) {
return;
}
const attributes = model.rawAttributes;
for (let i = 0; i < relationIdMapping.length; i++) {
const mapping = relationIdMapping[i];
if (!mapping.model || !mapping.id_field) {
continue;
}
const relatedModelName =
mapping.model.name || mapping.model.tableName || '';
const possibleFkNames = generatePossibleForeignKeyNames(relatedModelName);
for (let j = 0; j < possibleFkNames.length; j++) {
const fkName = possibleFkNames[j];
if (attributes[fkName] && !fkMappings[fkName]) {
fkMappings[fkName] = {
model: mapping.model,
id_field: mapping.id_field,
pk_field: 'id',
association: null,
};
}
}
}
}
addPatternBasedForeignKeys(model, relationIdMapping, fkMappings);
return fkMappings;
}
function isValidMappingInput(rows, relationIdMapping) {
if (!Array.isArray(rows)) {
return false;
}
if (!Array.isArray(relationIdMapping)) {
return false;
}
if (relationIdMapping.length === 0) {
return false;
}
return true;
}
function getModelForAssociations(sourceModel, relationIdMapping) {
if (sourceModel) {
return sourceModel;
}
for (let i = 0; i < relationIdMapping.length; i++) {
const mapping = relationIdMapping[i];
if (mapping.model) {
return mapping.model;
}
}
return null;
}
async function mapForeignKeyValues(rows, relationIdMapping, sourceModel) {
if (!isValidMappingInput(rows, relationIdMapping)) {
return rows;
}
const modelForAssociations = getModelForAssociations(
sourceModel,
relationIdMapping
);
if (!modelForAssociations) {
return rows;
}
function checkIfRowHasField(rows, fkField) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row && typeof row === 'object' && row.hasOwnProperty(fkField)) {
return true;
}
}
return false;
}
function filterMappingsByExistingFields(allFkMappings, rows) {
const fkMappings = {};
const mappingKeys = Object.keys(allFkMappings);
for (let i = 0; i < mappingKeys.length; i++) {
const fkField = mappingKeys[i];
const mapping = allFkMappings[fkField];
if (checkIfRowHasField(rows, fkField)) {
fkMappings[fkField] = mapping;
}
}
return fkMappings;
}
const allFkMappings = identifyForeignKeyFields(
modelForAssociations,
relationIdMapping
);
const fkMappings = filterMappingsByExistingFields(allFkMappings, rows);
if (Object.keys(fkMappings).length === 0) {
return rows;
}
function getModelKey(mapping) {
return mapping.model.name || mapping.model.tableName;
}
function initializeLookupEntry(mapping) {
return {
model: mapping.model,
id_field: mapping.id_field,
pk_field: mapping.pk_field,
values: new Set(),
};
}
function collectForeignKeyValues(rows, fkMappings) {
const lookupsNeeded = {};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row || typeof row !== 'object') {
continue;
}
const mappingKeys = Object.keys(fkMappings);
for (let j = 0; j < mappingKeys.length; j++) {
const fkField = mappingKeys[j];
const mapping = fkMappings[fkField];
if (row[fkField] != null) {
const modelKey = getModelKey(mapping);
if (!lookupsNeeded[modelKey]) {
lookupsNeeded[modelKey] = initializeLookupEntry(mapping);
}
lookupsNeeded[modelKey].values.add(row[fkField]);
}
}
}
return lookupsNeeded;
}
try {
const lookupsNeeded = collectForeignKeyValues(rows, fkMappings);
function getSequelizeOperator(model) {
if (model.sequelize?.constructor?.Op) {
return model.sequelize.constructor.Op;
}
if (model.sequelize?.Sequelize?.Op) {
return model.sequelize.Sequelize.Op;
}
return require('sequelize').Op;
}
function createIdMapping(records, pkField, idField) {
const mapping = {};
for (let i = 0; i < records.length; i++) {
const record = records[i];
mapping[record[pkField]] = record[idField];
}
return mapping;
}
async function performSingleModelLookup(lookup, modelKey) {
const values = Array.from(lookup.values);
if (values.length === 0) {
return {};
}
try {
const Op = getSequelizeOperator(lookup.model);
const records = await lookup.model.findAll({
where: {
[lookup.pk_field]: { [Op.in]: values },
},
attributes: [lookup.pk_field, lookup.id_field],
raw: true,
});
return createIdMapping(records, lookup.pk_field, lookup.id_field);
} catch (lookupError) {
throw new Error(
`[Apialize] Failed to lookup external IDs for ${modelKey}: ${lookupError.message}`
);
}
}
async function performBulkLookups(lookupsNeeded) {
const lookupResults = {};
const modelKeys = Object.keys(lookupsNeeded);
for (let i = 0; i < modelKeys.length; i++) {
const modelKey = modelKeys[i];
const lookup = lookupsNeeded[modelKey];
lookupResults[modelKey] = await performSingleModelLookup(
lookup,
modelKey
);
}
return lookupResults;
}
const lookupResults = await performBulkLookups(lookupsNeeded);
function applyMappingToSingleField(
mappedRow,
fkField,
mapping,
lookupResults
) {
if (mappedRow[fkField] == null) {
return;
}
const modelKey = mapping.model.name || mapping.model.tableName;
const lookupMap = lookupResults[modelKey] || {};
const externalId = lookupMap[mappedRow[fkField]];
if (externalId != null) {
mappedRow[fkField] = externalId;
}
}
function applyAllMappingsToRow(row, fkMappings, lookupResults) {
if (!row || typeof row !== 'object') {
return row;
}
const mappedRow = Object.assign({}, row);
const mappingKeys = Object.keys(fkMappings);
for (let i = 0; i < mappingKeys.length; i++) {
const fkField = mappingKeys[i];
const mapping = fkMappings[fkField];
applyMappingToSingleField(mappedRow, fkField, mapping, lookupResults);
}
return mappedRow;
}
function applyMappingsToAllRows(rows, fkMappings, lookupResults) {
const mappedRows = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const mappedRow = applyAllMappingsToRow(row, fkMappings, lookupResults);
mappedRows.push(mappedRow);
}
return mappedRows;
}
const mappedRows = applyMappingsToAllRows(rows, fkMappings, lookupResults);
return mappedRows;
} catch (error) {
throw new Error(`[Apialize] Foreign key mapping failed: ${error.message}`);
}
}
// Enhanced normalizeRows that supports foreign key mapping
async function normalizeRowsWithForeignKeys(
rows,
idMapping,
relationIdMapping,
sourceModel
) {
if (!Array.isArray(rows)) {
return rows;
}
// First apply standard ID normalization
const normalized = normalizeRows(rows, idMapping);
// Then apply foreign key mapping if configured
if (Array.isArray(relationIdMapping) && relationIdMapping.length > 0) {
return await mapForeignKeyValues(
normalized,
relationIdMapping,
sourceModel
);
}
return normalized;
}
async function withTransactionAndHooks(config, run) {
const model = config && config.model;
const req = config && config.req;
const res = config && config.res;
const options = (config && config.options) || {};
const modelOptions = (config && config.modelOptions) || {};
const idMapping = (config && config.idMapping) || 'id';
const useReqOptionsTransaction = !!(
config && config.useReqOptionsTransaction
);
const context = buildContext({
req: req,
res: res,
model: model,
options: options,
modelOptions: modelOptions,
idMapping: idMapping,
});
let t = null;
if (hasSequelize(model)) {
t = await model.sequelize.transaction();
context.transaction = t;
if (useReqOptionsTransaction && req && req.apialize) {
if (!req.apialize.options) {
req.apialize.options = {};
}
req.apialize.options.transaction = t;
}
}
try {
// Apply scopes from modelOptions before pre-hooks run
if (
modelOptions.scopes &&
Array.isArray(modelOptions.scopes) &&
model &&
context.applyScopes
) {
context.applyScopes(modelOptions.scopes);
}
if (options.pre) {
if (typeof options.pre === 'function') {
context.preResult = await options.pre(context);
} else if (Array.isArray(options.pre)) {
for (let i = 0; i < options.pre.length; i += 1) {
const preHook = options.pre[i];
if (typeof preHook === 'function') {
const result = await preHook(context);
context.preResult = result;
}
}
}
}
const result = await run(context);
if (!context._responseSent && options.post) {
if (typeof options.post === 'function') {
await options.post(context);
} else if (Array.isArray(options.post)) {
for (let i = 0; i < options.post.length; i += 1) {
const postHook = options.post[i];
if (typeof postHook === 'function') {
await postHook(context);
}
}
}
}
if (!context._rolledBack && t && typeof t.commit === 'function') {
await t.commit();
}
// Always return the latest payload from context when available so that
// post hooks that replace or mutate context.payload are reflected in the
// final response. Fall back to the original result when no payload exists.
return typeof context.payload !== 'undefined' ? context.payload : result;
} catch (err) {
if (t && typeof t.rollback === 'function') {
try {
await t.rollback();
} catch (_) {}
}
throw err;
}
}
module.exports = {
buildContext: buildContext,
withTransactionAndHooks: withTransactionAndHooks,
optionsWithTransaction: optionsWithTransaction,
notFoundWithRollback: notFoundWithRollback,
normalizeId: normalizeId,
normalizeRows: normalizeRows,
normalizeRowsWithForeignKeys: normalizeRowsWithForeignKeys,
};