UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

341 lines (289 loc) 7.98 kB
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); }