@getanthill/datastore
Version:
Event-Sourced Datastore
341 lines (289 loc) • 7.98 kB
text/typescript
import type { NextFunction, Request, Response } from 'express';
import type {
AccessLevel,
AnyObject,
ApiErrorsObject,
GenericType,
ModelConfig,
Services,
} from '../../typings';
import type { ParsedQs } from 'qs';
import has from 'lodash/has';
import isObject from 'lodash/isObject';
import mapValues from 'lodash/mapValues';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
import { deepCoerce, getDate } from '../../utils';
const DEFAULT_ERRORS = {
message: {
'Invalid Model': 400,
'Event schema validation error': 400,
'Not Found': 404,
'Entity is readonly': 405,
'Imperative condition failed': 412,
'Entity must be created first': 422,
'State schema validation error': 422,
},
code: {
11000: 409,
},
};
export function controllerBuilder(
services: Services,
meter: (labels: { state: string; model: string }) => void,
handler: (req: Request, res: Response, next: NextFunction) => Promise<any>,
errors: ApiErrorsObject = {},
) {
const _errors: {
message: { [key: string]: any };
code: { [key: string]: any };
[key: string]: any;
} = {
message: {
...DEFAULT_ERRORS.message,
...errors.message,
},
code: {
...DEFAULT_ERRORS.code,
...errors.code,
},
};
return async (req: Request, res: Response, next: NextFunction) => {
// @ts-ignore
if (res.body) {
return next();
}
try {
res.locals.meter = meter;
res.locals.attributes = {
model: req.params.model,
};
meter({
state: 'request',
model: req.params.model,
});
await handler(req, res, next);
} catch (err: any) {
for (const key in _errors) {
if (err[key] in _errors[key]) {
let status = _errors[key][err[key]];
if (typeof status === 'function') {
status = await status(err, req, res, next);
}
err.status = status;
err.details = err.details || [];
if (req?.params?.model) {
err.details.push({
model: req.params.model,
});
}
return next(err);
}
}
next(err);
}
};
}
const FORMATTER_REGEXP = /^([a-z]+)\((.*)\)$/;
export function mapQueryValues(
Model: GenericType,
properties: AnyObject,
obj: AnyObject,
mustHash = true,
): AnyObject | null {
const propNames = Object.getOwnPropertyNames(obj);
for (const name of propNames) {
let prop = obj[name];
if (!Array.isArray(prop) && isObject(prop)) {
obj[name] = mapQueryValues(
Model,
properties?.[name]?.properties ?? {},
prop,
);
continue;
}
let key = name;
/**
* @deprecated Backward compatibility for the previous
* implementation of the `_must_hash` logic applied
* globally to the query result.
*/
if (mustHash === true && Model.isEncryptedField(key)) {
key = `hash(${name})`;
}
const [, formatter, _name] = FORMATTER_REGEXP.exec(key) ?? [];
if (formatter) {
key = _name;
switch (formatter) {
case 'date':
prop = Array.isArray(prop)
? prop.map((p) => getDate(p))
: getDate(prop);
break;
case 'hash':
key = `${_name}.hash`;
prop = Array.isArray(prop)
? prop.map((p) => Model.hashValue(p))
: Model.hashValue(prop);
break;
default:
// ...
}
delete obj[name];
obj[key] = prop;
}
const { type } = properties[key] || {};
if (type !== 'array' && Array.isArray(prop)) {
if (prop.length === 0) {
return null;
}
obj[key] = {
$in: prop,
};
}
}
return obj;
}
export function mapFindQuery(Model: GenericType, query: ParsedQs) {
const schema = Model.getSchema().model;
const properties = schema.properties;
const q = 'q' in query ? JSON.parse(query.q as string) : query;
let _query: { [key: string]: any } | null = pickBy(
q,
(_v, k) => !k.startsWith('_'),
);
const _options = {
sort: {
...mapValues(
q._sort || {
created_at: 1,
},
(v) => parseInt(v, 10),
),
_id: q?._sort?._id ?? 1,
},
projection: mapValues(q._fields || {}, (v) => parseInt(v, 10)),
};
if ('_fields' in q && Object.keys(_options.projection).length > 0) {
_options.projection = {
..._options.projection,
// Add mandatory fields for find and walk pagination logic
[Model.getCorrelationField()]: 1,
created_at: 1,
updated_at: 1,
version: 1,
_id: 1,
};
}
/**
* @deprecated in favor of v2 request with `hash()`
* formatter
*/
const mustHash = q._must_hash === true;
_query = mapQueryValues(Model, properties, _query, mustHash);
if (_query === null) {
return {
query: null,
options: _options,
};
}
_query = deepCoerce(_query, schema);
/**
* @deprecated in favor of v2 request without special
* field `_q`
*/
if (q._q) {
try {
q._q = JSON.parse(q._q);
q._q = JSON.parse(q._q);
} catch (err: any) {
// ..
}
_query = {
..._query,
...deepCoerce(q._q, schema),
};
}
return {
query: _query,
options: _options,
};
}
export function getQueryFromCursorLastId(
Model: GenericType,
options: any,
cursorLastId: string,
cursorLastCorrelationId: string,
): any {
const _revertId = JSON.parse(Buffer.from(cursorLastId, 'hex').toString());
const { query: _lastIdQuery } = mapFindQuery(Model, _revertId);
const cursorQuery = mapValues(_lastIdQuery, (val: any, key: string) => {
if (options.sort[key] === 1) {
// Following condition for backward compatibility:
// `2024-06-26`: Nominal case should be `$gte` with `cursorLastCorrelationId`
return { [cursorLastCorrelationId ? '$gte' : '$gt']: val };
}
// Following condition for backward compatibility:
// `2024-06-26`: Nominal case should be `$gte` with `cursorLastCorrelationId`
return { [cursorLastCorrelationId ? '$lte' : '$lt']: val };
});
return cursorQuery;
}
export function buildCursorLastId(entity: any, options: any): any {
return entity
? Buffer.from(
JSON.stringify(pick(entity, Object.keys(options.sort))),
).toString('hex')
: '';
}
export function checkProcessingAuthorization(
services: Services,
tokenId: string,
modelConfig: ModelConfig,
data: AnyObject,
): void {
if (services?.config?.features?.api?.checkProcessingAuthorization !== true) {
return;
}
if (Array.isArray(modelConfig.processings)) {
const authorizedProcessings = modelConfig.processings.filter((processing) =>
(processing.tokens ?? [tokenId]).includes(tokenId),
);
for (const encryptedField of modelConfig.encrypted_fields ?? []) {
if (has(data, encryptedField) === false) {
continue;
}
const isProcessingAuthorizedOnField = authorizedProcessings.find(
(processing) => processing.field === encryptedField,
);
if (!isProcessingAuthorizedOnField) {
throw new Error('Unauthorized field processing');
}
}
}
}
export async function decrypt(
services: Services,
access: { id: string; level?: AccessLevel },
modelName: string,
data: AnyObject,
fields?: string[],
): Promise<AnyObject> {
const Model = services.models.getModel(modelName);
const modelConfig = Model.getModelConfig();
checkProcessingAuthorization(services, access.id, modelConfig, data);
services.models?.log(
50,
modelConfig.name,
data[Model.getCorrelationField()],
'[decrypt] Entity decrypted',
{
model: modelConfig.name,
correlation_id: data[Model.getCorrelationField()],
id: access.id,
level: access.level,
persist: true,
},
);
return Model.decrypt(data, fields);
}