@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
449 lines (368 loc) • 14.3 kB
JavaScript
/*
* 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 }
}
// List of validation error codes that should cause an early rejection of the request
// > If one of these codes is found, the request will be rejected right after validation
const EARLY_REJECT_CODES = { ASSERT_DATA_TYPE: 1, ASSERT_MANDATORY: 1, ASSERT_NOT_NULL: 1 }
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) {
if (errs.some(e => e.message in EARLY_REJECT_CODES)) {
// ensure to use the orginal req.error, not the one monkey patched for draft messages
// REVISIT: this is an ugly workaround -> fix in lean draft please!
const errorFn = cds.Request.prototype.error.bind(req)
errs.forEach(err => errorFn(err))
req.reject()
}
errs.forEach(err => req.error(err))
return
}
// -------------------------------------------------
// 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) {
errs.forEach(err => req.error(err))
if (errs.some(e => e.message in EARLY_REJECT_CODES)) req.reject()
else return
}
// 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)
if (entity.drafts) this.before(a, entity.drafts, validate_action)
}
})
// needed for testing
module.exports.commonGenericInput = validate_input