UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

427 lines (347 loc) 13.5 kB
/* * Input handler on application service layer * * - remove readonly fields * - remove immutable fields on update * - add UUIDs * - asserts */ const cds = require('../../cds') const LOG = cds.log('app') const { Readable } = require('node:stream') const { enrichDataWithKeysFromWhere } = require('../utils/keys') const { DRAFT_COLUMNS_MAP } = require('../constants/draft') const propagateForeignKeys = require('../utils/propagateForeignKeys') const getTemplate = require('../utils/template') const getRowUUIDGeneratorFn = require('../utils/rowUUIDGenerator') const templatePathSerializer = require('../utils/templateProcessorPathSerializer') const _shouldSuppressErrorPropagation = (event, value) => { return ( event === 'NEW' || event === 'PATCH' || (event === 'UPDATE' && value.val === undefined) || (value.val == null && !value.mandatory) ) } const _sliceBase64 = function* (str) { const chunkSize = 1 << 16 for (let i = 0; i < str.length; i += chunkSize) { yield Buffer.from(str.slice(i, i + chunkSize), 'base64') } } const _getSimpleCategory = category => { if (typeof category === 'object') { category = category.category } return category } const _preProcessAssertTarget = (assocInfo, assertMap) => { const { element: assoc, row } = assocInfo const assocTarget = assoc._target // it is expected that the associated entities be defined in the same service if (assoc.parent._service !== assocTarget._service) { LOG._warn && LOG.warn('Cross-service checks for the @assert.target constraint are not supported.') return } const foreignKeys = assoc._foreignKeys let mapKey = `${assocTarget.name}(` const hasOwn = Object.prototype.hasOwnProperty const parentKeys = [] foreignKeys.forEach(keyMap => { const { childElement, parentElement } = keyMap // don't assert target if the foreign key isn't in the payload if (!hasOwn.call(row, parentElement.name)) return const foreignKeyValue = row[parentElement.name] // don't assert target if the foreign key value is null if (foreignKeyValue === null) return mapKey += `${childElement.name}=${foreignKeyValue},` parentKeys.push({ [childElement.name]: foreignKeyValue }) }) mapKey += `)` if (parentKeys.length === 0) return foreignKeys.forEach(keyMap => { const clonedAssocInfo = Object.assign({}, assocInfo, { pathSegmentsInfo: assocInfo.pathSegmentsInfo.slice(0) }) const target = { key: mapKey, entity: assocTarget, keys: parentKeys, assocInfo: clonedAssocInfo, foreignKey: keyMap.parentElement } if (!assertMap.targets.has(mapKey)) { assertMap.targets.set(mapKey, target) } assertMap.allTargets.push(target) }) } const _enumValues = element => { return Object.keys(element).map(enumKey => { const enum_ = element[enumKey] const enumValue = enum_ && enum_.val if (enumValue !== undefined) { if (enumValue['=']) return enumValue['='] if (enum_ && enum_.literal && enum_.literal === 'number') return Number(enumValue) return enumValue } return enumKey }) } // REVISIT: this needs a cleanup! const _assertError = (code, element, value, key, path) => { let args if (typeof code === 'object') { args = code.args code = code.code } const { name, type, precision, scale } = element const error = new Error() const errorEntry = { code, message: code, target: path ?? element.name ?? key, args: args ?? [name ?? key] } const assertError = Object.assign(error, errorEntry) Object.assign(assertError, { entity: element.parent && element.parent.name, element: name, // > REVISIT: when is error.element needed? type: element.items ? element.items._type : type, status: 400, value }) if (element.enum) assertError.enum = _enumValues(element) if (precision) assertError.precision = precision if (scale) assertError.scale = scale if (element.target) { // REVISIT: when does this case apply? assertError.target = element.target } return assertError } /** * Check whether the target entity referenced by the association (the reference's target) exists and assert an error if * the the reference's target doesn't exist. * * In other words, use this annotation to check whether a non-null foreign key input in a table has a corresponding * primary key (also known as a parent key) in the associated/referenced target table (also known as a parent table). * * @param {object} assertMap - Map containing the targets to assert. * @param {array} errors - Array to collect errors. * @see {@link https://cap.cloud.sap/docs/guides/providing-services#assert-target @assert.target} for further information. */ const _assertTargets = async (assertMap, errors) => { const { targets: targetsMap, allTargets } = assertMap if (targetsMap.size === 0) return const targets = Array.from(targetsMap.values()) const transactions = targets.map(({ keys, entity }) => { const where = Object.assign({}, ...keys) return cds.db.exists(entity, where).forShareLock() }) const targetsExistsResults = await Promise.allSettled(transactions) targetsExistsResults.forEach((txPromise, index) => { const isPromiseRejected = txPromise.status === 'rejected' const shouldAssertError = (txPromise.status === 'fulfilled' && txPromise.value == null) || isPromiseRejected if (!shouldAssertError) return const target = targets[index] const { element } = target.assocInfo if (isPromiseRejected) { LOG._debug && LOG.debug( `The transaction to check the @assert.target constraint for foreign key "${element.name}" failed`, txPromise.reason ) throw new Error(txPromise.reason.message) } allTargets .filter(t => t.key === target.key) .forEach(target => { const { row, pathSegmentsInfo } = target.assocInfo const key = target.foreignKey.name let path if (pathSegmentsInfo?.length) path = templatePathSerializer(key, pathSegmentsInfo) const error = _assertError('ASSERT_TARGET', target.foreignKey, row[key], key, path) errors.push(error) }) }) } const _processCategory = (req, category, value, elementInfo, assertMap) => { const { row, key, element, isRoot } = elementInfo category = _getSimpleCategory(category) if (category === 'propagateForeignKeys') { propagateForeignKeys(key, row, element._foreignKeys, element.isComposition) return } // remember mandatory if (category === 'mandatory') { value.mandatory = true return } const event = req.event // remove readonly (can also be complex, so do first) if (category === 'readonly') { // preserve computed values if triggered by draftActivate and not managed const managed = `@cds.on.${event === 'CREATE' ? 'insert' : 'update'}` if (cds.env.features.preserve_computed !== false && req._?.event === 'draftActivate' && !element[managed]) return // read-only values are already deleted before `NEW` (and they can be set in a `NEW` handler!) if (event === 'CREATE' && req.target.isDraft) return delete row[key] value.val = undefined return } // remove immutable (can also be complex, so do first) // for new db drivers (cds.db.cqn2sql is defined), deep immutable values are handled in differ // otherwise they're not supported and always filtered out here. if (category === 'immutable' && event === 'UPDATE' && (isRoot || !cds.db.cqn2sql)) { delete row[key] value.val = undefined return } // generate UUIDs if ( category === 'uuid' && !value.val && ((event !== 'UPDATE' && event !== 'PATCH') || !isRoot) && !element.parent.elements[element._foreignKey4]?._isAssociationStrict ) { value.val = row[key] = cds.utils.uuid() } // @assert.target if ((event === 'UPDATE' || event === 'CREATE') && category === '@assert.target') { _preProcessAssertTarget(elementInfo, assertMap) } if (category === 'binary' && typeof row[key] === 'string') { row[key] = Buffer.from(row[key], 'base64') return } if (category === 'largebinary' && typeof row[key] === 'string') { row[key] = Readable.from(_sliceBase64(row[key]), { objectMode: false }) return } } const _getProcessorFn = (req, errors, assertMap) => { const event = req.event return elementInfo => { const { row, key, plain } = elementInfo // ugly pointer passing for sonar const value = { mandatory: false, val: row && row[key] } for (const category of plain.categories) { _processCategory(req, category, value, elementInfo, assertMap) } if (_shouldSuppressErrorPropagation(event, value)) return } } // params: element, target, parent const _pick = element => { // collect actions to apply const categories = [] // REVISIT: element._foreignKeys.length seems to be a very broad check if (element.isAssociation && element._foreignKeys.length) { categories.push({ category: 'propagateForeignKeys' }) } // REVISIT: cleanse @Core.Immutable // should be a db feature, as we cannot handle completely on service level (cf. deep update) // -> add to attic env behavior once new dbs handle this // also happens in validate but because of draft activate we have to do it twice (where cleansing is suppressed) if (element['@Core.Immutable'] && !element.key) { categories.push('immutable') } if (element.key && !DRAFT_COLUMNS_MAP[element.name] && element.isUUID) { categories.push('uuid') } if ( element['@assert.target'] && element.isAssociation && element.is2one && !element.on // managed assoc ) { categories.push('@assert.target') } if (element.type === 'cds.Binary' && !cds.env.features.base64_binaries) { categories.push('binary') } if (element.type === 'cds.LargeBinary' && !cds.env.features.base64_binaries) { categories.push('largebinary') } if (categories.length) return { categories } } async function validate_input(req) { if (!req.target || req.target._unresolved) return // Validation requires resolved targets if (req.event === 'CREATE' && req.target.isDraft) return // already handled in `NEW`, no need in `EDIT` // REVISIT: remove once mtxs fixed its modeling if (!req.query && req.target.name.match(/^cds\.xt\./)) return // skip validation for mtxs // REVISIT: can we get rid of this? const _is_activate = req._?.event === 'draftActivate' && cds.env.features.preserve_computed !== false // validate data const assertOptions = { mandatories: req.event === 'CREATE' || req.method === 'PUT', cleanse: _is_activate ? false : true, protocol: req.protocol, rejectIgnore: req.protocol && !_is_activate ? true : undefined } if (req.event === 'NEW') assertOptions.insert = true // treat NEW as INSERT even though no mandatories are checked // REVISIT: initialize path if necessary (currently only done in lean-draft -> correct?) const { actions } = req.target if (actions) { const bound = actions[req.event] || actions[req._.event] if (bound) assertOptions.path = [bound['@cds.odata.bindingparameter.name'] || 'in'] } const errs = cds.validate(req.data, req.target, assertOptions) if (errs) return errs.forEach(err => req.error(err)) // ------------------------------------------------- // REVISIT: is the below still needed? const template = getTemplate('app-input', this, req.target, { pick: _pick, ignore: element => element._isAssociationStrict }) if (template.elements.size === 0) return if (req.query) enrichDataWithKeysFromWhere(req.data, req, this) // REVISIT: ^^^^ quite questionable to do something like this in a validate function const errors = [] const assertMap = { targets: new Map(), allTargets: [] } template.process(req.data, _getProcessorFn(req, errors, assertMap), { rowUUIDGenerator: getRowUUIDGeneratorFn(req.event), includeKeyValues: true, pathSegmentsInfo: [] }) if (assertMap.targets.size > 0) { await _assertTargets(assertMap, errors) } if (errors.length) for (const error of errors) req.error(error) } function validate_action(req) { const operation = this.actions?.[req.event] || req.target?.actions?.[req.event] if (!operation) return const data = req.data || {} // validate data const assertOptions = { mandatories: true, cleanse: false, protocol: req.protocol } let errs = cds.validate(data, operation, assertOptions) if (errs) return errs.forEach(err => req.error(err)) // convert binaries operation.params && !cds.env.features.base64_binaries && Object.keys(operation.params).forEach(key => { if (operation.params[key].type === 'cds.Binary' && typeof data[key] === 'string') data[key] = Buffer.from(data[key], 'base64') }) } // FIXME: we should remove the below, but then 6 tests fail. // 4 in cds/tests/_runtime // 2 in cap/incidents/test validate_input._initial = true validate_action._initial = true module.exports = cds.service.impl(function () { this.before(['CREATE', 'UPDATE', 'NEW'], '*', validate_input) for (const each of this.actions) this.before(each, validate_action) for (const entity of this.entities) for (let a in entity.actions) this.before(a, entity, validate_action) }) // needed for testing module.exports.commonGenericInput = validate_input