@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
696 lines (574 loc) • 26.1 kB
JavaScript
const cds = require('../../../')
const express = require('express')
const { STATUS_CODES } = require('http')
const qs = require('querystring')
const { URL } = require('url')
const multipartToJson = require('../parse/multipartToJson')
const { getBoundary } = require('../utils')
const FAILED_DEPENDENCY_ERROR = { code: '424', message: 'Failed Dependency' }
const ODATA_RESERVED = ['$metadata', '$batch', '$crossjoin', '$all', '$entity', '$root', '$id']
const HTTP_METHODS = { GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1 }
const CT = { JSON: 'application/json', MULTIPART: 'multipart/mixed' }
const CRLF = '\r\n'
/*
* deconstruct and validate
*/
const _deserializationError = message => cds.error({ status: 400, message: `Deserialization Error: ${message}` })
// Function must be called with an object containing exactly one key-value pair representing the property name and its value
const _validateProperty = (name, value, type) => {
if (value === undefined) throw _deserializationError(`Parameter '${name}' must not be undefined.`)
switch (type) {
case 'Array':
if (!Array.isArray(value)) throw _deserializationError(`Parameter '${name}' must be type of '${type}'.`)
break
default:
if (typeof value !== type) throw _deserializationError(`Parameter '${name}' must be type of '${type}'.`)
}
}
const _deconstructAndValidateBatchBody = body => {
const { requests } = body
_validateProperty('requests', requests, 'Array')
if (!requests.length) cds.error({ status: 400, message: 'There must be at least one request' })
if (requests.length > cds.env.odata.batch_limit) cds.error({ status: 429, message: 'BATCH_TOO_MANY_REQ' })
// We create a register of requests and atomicity groups, by their ids:
// > i.e.: { [requestId]: requestObject, ... [atomicityGroupId]: [requestObject1, requestObject2, ... ], ... }
const requestsById = {}
// We keep arrays of requests in the same atomicity group
// > Every request is added to one atomicity group (incl. group of one)
const atomicityGroups = []
let previousAtomicityGroup
for (let i = 0; i < requests.length; i++) {
const request = requests[i]
if (typeof request !== 'object')
throw _deserializationError(`Element of 'requests' array at index ${i} must be type of 'object'.`)
const { id, method, url, body, atomicityGroup } = request
if (atomicityGroup && atomicityGroups.length && atomicityGroup === atomicityGroups.at(-1)[0]?.atomicityGroup)
atomicityGroups.at(-1).push(request)
else atomicityGroups.push([request])
_validateProperty('id', id, 'string')
if (requestsById[id]) throw _deserializationError(`Request ID '${id}' is not unique.`)
else requestsById[id] = request
_validateProperty('method', method, 'string')
if (!(method.toUpperCase() in HTTP_METHODS))
throw _deserializationError(`Method '${method}' is not allowed. Only DELETE, GET, PATCH, POST or PUT are.`)
_validateProperty('url', url, 'string')
if (url.startsWith('/$batch')) throw _deserializationError('Nested batch requests are not allowed.')
if (body !== undefined && typeof body !== 'object')
throw _deserializationError('A Content-Type header has to be specified for a non JSON body.')
if (atomicityGroup) {
_validateProperty('atomicityGroup', atomicityGroup, 'string')
// All request objects with the same value for atomicityGroup MUST be adjacent in the requests array
if (atomicityGroup !== previousAtomicityGroup) {
if (requestsById[atomicityGroup])
throw _deserializationError(`Atomicity group ID '${atomicityGroup}' is not unique.`)
else requestsById[atomicityGroup] = []
}
requestsById[atomicityGroup].push(request)
}
// Resolve References into Dependencies
const dependsOnMatches = Array.from(url.matchAll(/^\$[^/]+/gi))
if (dependsOnMatches.length === 1) {
const dependsOnReference = dependsOnMatches[0]?.[0]?.slice(1) // Reference being $<requestId>
if (dependsOnReference && !ODATA_RESERVED.includes(`$${dependsOnReference}`)) {
if (!request.dependsOn) request.dependsOn = []
if (!request.dependsOn.includes(dependsOnReference)) request.dependsOn.push(dependsOnReference)
}
}
if (dependsOnMatches.length > 1) throw _deserializationError(`Can't reference more than one dependency!`)
// Validate Dependencies
if (request.dependsOn) {
_validateProperty('dependsOn', request.dependsOn, 'Array')
request.dependsOn.forEach(dependsOnId => {
_validateProperty('dependent request ID', dependsOnId, 'string')
const dep = requestsById[dependsOnId]
if (!dep) {
throw _deserializationError(
`"${dependsOnId}" does not match the id or atomicity group of any preceding request`
)
}
// automatically add the atomicityGroup of the dependency as a dependency (actually a client error)
const dag = dep.atomicityGroup
if (dag && dag !== atomicityGroup && !request.dependsOn.includes(dag)) request.dependsOn.push(dag)
})
}
// TODO: validate if, and headers
previousAtomicityGroup = atomicityGroup
}
return { requestsById, atomicityGroups }
}
/*
* subrequest (a.k.a. lookalike)
*/
// REVISIT: Why not simply use {__proto__:req, ...}?
const _createSubrequest = (request, _req, _res, error_mws) => {
const subrequest = { id: request.id }
// req
const req = (subrequest.req = new express.request.constructor())
req.__proto__ = express.request
req.app = _req.app
req.method = request.method.toUpperCase()
req.url = request.url
const u = new URL(request.url, 'http://cap')
req.query = qs.parse(u.search.slice(1))
req.headers = request.headers || {}
req.headers['content-type'] ??= _req.headers['content-type']
req.headers['accept-language'] ??= _req.headers['accept-language']
if (request.content_id) req.headers['content-id'] = request.content_id
req.body = request.body
if (_req._login) {
req._login = () => {
_req._login() //> sends 401 to client
req.res.sendStatus(401) //> fails the subrequest
req._login = () => 'done before' // > prevent multiple calls to _login in case of multiple dependencies
}
}
// REVISIT: mark as subrequest (only needed for logging)
req._subrequest = true
// res
const res = (req.res = subrequest.res = new express.response.constructor(req))
res.__proto__ = express.response
res.app = _res.app
// next
let _err
subrequest.next = err => {
_err = err
// add the error middlewares to the subrequest for execution during error serialization (i.e., after srv.on('error') + avoiding lost sanitization)
subrequest._next_chain = e => {
let _next_called
const _next = err => {
e = err
_next_called = true
}
for (const mw of error_mws) {
_next_called = false
mw(e, req, res, _next)
if (!_next_called) break //> next chain was interrupted -> done
}
if (_next_called) {
// here, final error middleware called next (which actually shouldn't happen!)
if (e.statusCode) res.status(e.statusCode)
if (typeof e === 'object') res.json({ error: e })
else res.send(e)
}
return res
}
res.end()
}
// resolve/reject promise for subrequest via res.end()
subrequest.promise = new Promise((resolve, reject) => {
res.json = function (obj) {
subrequest._json = obj
return this.__proto__.json.call(this, obj)
}
res.end = (chunk, encoding) => {
res._chunk = chunk
res._encoding = encoding
if (_err) return reject(_err)
resolve(subrequest)
}
})
return subrequest
}
/*
* multipart/mixed response
*/
const _tryParse = body => {
try {
return JSON.parse(body)
} catch {
return body
}
}
const _formatResponseMultipart = response => {
const { content_id, statusCode, headers } = response
let { body } = response
let txt = `content-type: application/http${CRLF}content-transfer-encoding: binary${CRLF}`
if (content_id) txt += `content-id: ${content_id}${CRLF}`
txt += CRLF
txt += `HTTP/1.1 ${statusCode} ${STATUS_CODES[statusCode]}${CRLF}`
for (const key in headers) txt += key + ': ' + headers[key] + CRLF
txt += CRLF
if (body) {
if (Buffer.isBuffer(body)) body = body.toString()
body = _tryParse(body)
if (body && typeof body === 'object') {
let meta = [],
data = []
for (const [k, v] of Object.entries(body)) {
if (k.startsWith('@')) meta.push(`"${k}":${typeof v === 'string' ? `"${v.replaceAll('"', '\\"')}"` : v}`)
else data.push(JSON.stringify({ [k]: v }).slice(1, -1))
}
const _json_as_txt = '{' + meta.join(',') + (meta.length && data.length ? ',' : '') + data.join(',') + '}'
body = _json_as_txt
}
txt += body
}
return [txt]
}
const _writeResponseMultipart = (responses, res, boundary) => {
res.write(`--${boundary}${CRLF}`)
const rejected = responses.find(r => r.status === 'fail')
if (rejected) {
res.write(`${_formatResponseMultipart(rejected)[0]}${CRLF}`)
return
}
const groupId = responses[0].atomicityGroup
if (groupId) res.write(`content-type: multipart/mixed;boundary=${groupId}${CRLF}${CRLF}`)
for (const response of responses) {
for (const each of _formatResponseMultipart(response)) {
if (groupId) res.write(`--${groupId}${CRLF}`)
res.write(`${each}${CRLF}`)
}
}
if (groupId) res.write(`--${groupId}--${CRLF}`)
}
/*
* application/json response
*/
const _formatStatics = {
comma: ','.charCodeAt(0),
body: Buffer.from('"body":'),
close: Buffer.from('}')
}
const _formatResponseJson = response => {
const { id, atomicityGroup, statusCode, headers, body } = response
const chunk = { id, status: statusCode, headers }
if (atomicityGroup) chunk.atomicityGroup = atomicityGroup
const raw = Buffer.from(JSON.stringify(chunk))
// body?
if (!body) return [raw]
// change last "}" into ","
raw[raw.byteLength - 1] = _formatStatics.comma
return [
raw,
_formatStatics.body,
// Stringify non-JSON bodies
!chunk.headers?.['content-type']?.includes('application/json')
? JSON.stringify(Buffer.isBuffer(body) ? body.toString() : body)
: body,
_formatStatics.close
]
}
const _writeResponseJson = (responses, res) => {
for (const response of responses) {
if (res._separator) res.write(res._separator)
else res._separator = Buffer.from(',')
for (const each of _formatResponseJson(response)) res.write(each)
}
}
/*
* helpers
*/
const _urlWithResolvedDependency = (dependsOnId, results, request, req, srv) => {
const resolvedDependency = results.find(r => r.value.id === dependsOnId)
const dependencyLocation = resolvedDependency.value.res.getHeader('location')
if (dependencyLocation) return request.url.replace(`$${dependsOnId}`, dependencyLocation)
const dependencyUrl = resolvedDependency.value.req.originalUrl
const dependencyResult = resolvedDependency.value._json ?? JSON.parse(resolvedDependency.value.res._chunk)
const cqn = cds.odata.parse(dependencyUrl, { service: srv, baseUrl: req.baseUrl, strict: true })
if (cqn.SELECT?.from?.ref?.at(-1)?.operation)
throw Error('Missing location header for action that is referenced as dependency')
const target = cds.infer.target(cqn)
const keyString =
'(' +
[...target.keys]
.filter(k => !k.isAssociation)
.map(k => {
let v = dependencyResult[k.name]
if (typeof v === 'string' && k._type !== 'cds.UUID') v = `'${v}'`
return k.name + '=' + v
})
.join(',') +
')'
return request.url.replace(`$${dependsOnId}`, dependencyUrl + keyString)
}
const _resolveDependencies = async (request, deps, responses, requestsById, req, srv) => {
const { url } = request
// Make sure all dependencies have resolved
const results = await Promise.allSettled(deps.map(d => d.promise))
let dependencyError
const dependedUponAgFailedOnCommit = deps.find(d => d._rejected_on_commit)
if (dependedUponAgFailedOnCommit) {
dependencyError = Object.create(FAILED_DEPENDENCY_ERROR)
dependencyError.innererror = `Dependency atomicity group ${dependedUponAgFailedOnCommit.atomicityGroup} failed on commit`
}
const dependedUponRequestFailed = results.find(r => r.status === 'rejected')
if (dependedUponRequestFailed) {
dependencyError = Object.create(FAILED_DEPENDENCY_ERROR)
dependencyError.innererror = dependedUponRequestFailed.reason
}
if (dependencyError) {
responses[request._index] = _getResponse(request, { res: { statusCode: 424 } }, dependencyError)
throw dependencyError
}
const dependsOnId = url.split('/')[0].replace(/^\$/, '')
if (dependsOnId in requestsById) {
try {
request.url = _urlWithResolvedDependency(dependsOnId, results, request, req, srv)
} catch (e) {
const dependencyError = Object.create(FAILED_DEPENDENCY_ERROR)
dependencyError.innererror = { message: e.message }
responses[request._index] = _getResponse(request, { res: { statusCode: 424 } }, dependencyError)
throw dependencyError
}
}
}
const _getResponse = (request, { res, _next_chain }, error) => {
const { id, content_id, atomicityGroup, _index } = request
return {
id,
content_id,
atomicityGroup,
_index,
_next_chain,
status: error ? 'fail' : 'ok',
statusCode: res.statusCode,
body: error ? { error } : res._chunk,
headers: res.getHeaders?.() ?? (error ? { 'content-type': 'application/json' } : {})
}
}
/**
* @param {import('../ODataAdapter')} adapter
* The HTTP adapter to bind the batch middleware to.
*/
module.exports = adapter => {
const { service: srv } = adapter
let subrouter, error_mws
const odata = adapter
const _replaceResponsesWithCommitErrors = (err, responses, ids) => {
err = odata.normalized(err)
err = odata.localized(err)
const error = odata.cleansed(err)
// adjust all responses to commit error and mark respective promises as failed
const body = JSON.stringify({ error })
const length = Buffer.byteLength(body, 'utf8')
for (const response of responses) {
response.status = 'fail'
response.statusCode = err.status // Note: status is cleansed from error
response.body = body
response.headers ??= {}
response.headers['content-length'] = length
ids[response.id]._rejected_on_commit = true
if (response.atomicityGroup) ids[response.atomicityGroup]._rejected_on_commit = true
}
}
const _serializeErrors = responses => {
const { locale } = cds.context
for (const response of responses) {
if (response.status === 'fail' && typeof response.body === 'object') {
// if present, execute the error middlewares chain which does localization, cleansing, etc.
if (response._next_chain) {
const res = response._next_chain(response.body.error)
response.statusCode = res.statusCode
response.body = res._chunk
response.headers = res.getHeaders()
continue
}
// else, do a late serialization of the error via odata's localized and cleansed
// cases: dependency failed error, 405, 404, ...
let { error } = response.body
if (error) {
let { status } = odata.localized(error, locale) // odata.localized modifies error in place
response.body.error = odata.cleansed(error) // odata.cleansed creates a new error object
if (status) response.statusCode = status
}
response.body = JSON.stringify(response.body)
}
}
}
/*
* process
*/
const _processBatch = async (req, res, next, body, ct, boundary) => {
// Extract requested content type (i.e., accept):
// > fallback to the content type used in the request
let isJson = ct === 'JSON'
if (req.headers?.accept?.includes('multipart/mixed')) isJson = false
else if (req.headers?.accept?.includes('application/json')) isJson = true
// continue-on-error defaults to true in json batch
let continue_on_error = req.headers.prefer?.match(/odata\.continue-on-error(=(\w+))?/)
if (!continue_on_error) continue_on_error = isJson ? true : false
else continue_on_error = continue_on_error[2] === 'false' ? false : true
try {
// Collect requests from the batch in two aggregations:
// 1. Each individual request accessible by its id
// 2. Arrays of requests in the same atomicity group
// > We will only process requests in atomicity groups
let { requestsById, atomicityGroups } = _deconstructAndValidateBatchBody(body)
const only_gets = body.requests.every(r => r.method === 'GET')
const max_parallel = only_gets ? cds.env.odata.max_batch_parallelization : 1
// experimental!
const _only_individual_requests = () =>
atomicityGroups.every(ag => ag.length === 1) && body.requests.every(r => !r.dependsOn?.length)
if (only_gets && cds.env.odata.group_parallel_gets && _only_individual_requests())
atomicityGroups = [atomicityGroups.reduce((acc, cur) => (acc.push(...cur), acc), [])]
// IMPORTANT: Avoid sending headers and responses too eagerly, as we might still have to send a 401
let sendPostlude = () => {} //> only if prelude was sent
let sendPreludeOnce = () => {
res.setHeader('Content-Type', isJson ? CT.JSON : CT.MULTIPART + ';boundary=' + boundary)
res.status(200)
res.write(isJson ? '{"responses":[' : '')
sendPreludeOnce = () => {} //> only once
sendPostlude = () => {
res.write(isJson ? ']}' : `--${boundary}--${CRLF}`)
res.end()
}
}
const queue = []
let _continue = true
const _queued_exec = (atomicityGroup, agIndex, responses = []) => {
const groupId = atomicityGroup[0].atomicityGroup
// Start the chain of promises that need to resolve
// > for this ATOMICITY GROUP to be processed at the right moment
return new Promise(resolve => queue.push(resolve)).then(() => {
if (!_continue) return
// To finish processing this atomicity group, when it is picked from the queue:
// > We have to handle all the requests it contains, within the same transaction.
// > We are done, once the transaction is resolved (committed or rolled back)
// > ... and we have collected the responses for all requests in the group
const promise = srv
.tx(tx => {
// Register tx for guaranteed cleanup
const _id = cds.utils.uuid()
_openAtomicityGroupTxs[_id] = tx
tx.context.on('done', () => delete _openAtomicityGroupTxs[_id])
const subrequests = []
for (let i = 0; i < atomicityGroup.length; i++) {
const request = atomicityGroup[i]
const { id, dependsOn } = request
request._index = i // Maintain request order for multipart compatibility
// Start the chain of promises that needs to resolve
// > for this request to be processed at the right moment
requestsById[id].promise = Promise.resolve()
const deps = dependsOn?.filter(id => id !== groupId).map(id => requestsById[id])
if (deps) {
// ... make the resolution of this requests wait for its dependencies
requestsById[id].promise = requestsById[id].promise.then(() =>
_resolveDependencies(request, deps, responses, requestsById, req, srv)
)
}
requestsById[id].promise = requestsById[id].promise.then(() => {
const subrequest = _createSubrequest(request, req, res, error_mws)
// > 'subrequest' being an express-req lookalike of this request
// > with a `.promise` that resolves when subrequest.res.end() is called
// Actually handle this request via express router:
subrouter.handle(subrequest.req, subrequest.res, subrequest.next)
return subrequest.promise
.then(res => {
responses[request._index] = _getResponse(request, subrequest)
return res
})
.catch(err => {
responses[request._index] = _getResponse(request, subrequest, err)
throw err
})
})
subrequests.push(requestsById[id].promise)
}
// Ensure all subrequests run in this tx:
// > When first subrequest fails without really opening the tx:
// > The rest are executed in a "dangling tx"
return Promise.allSettled(subrequests).then(ress => {
const failed = ress.filter(({ status }) => status === 'rejected')
if (!failed.length) return
// throw first error and call srv.on('error') for the others
const first = failed.shift()
if (srv.handlers._error?.length)
for (const other of failed)
for (const each of srv.handlers._error) each.handler.call(srv, other.reason, cds.context)
throw first.reason
})
})
.catch(err => {
responses._has_failure = true
// abort batch on first failure with odata.continue-on-error: false or if it was a 401
if (!continue_on_error || err.code == 401 || err.status == 401) {
_continue = false
while (queue.length) queue.shift()()
}
if (!responses.some(r => r.status === 'fail')) {
// here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
_replaceResponsesWithCommitErrors(err, responses, requestsById)
}
throw err
})
.finally(async () => {
// trigger next in queue
if (queue.length) queue.shift()()
// late error serialization
if (responses._has_failure) _serializeErrors(responses)
// wait for all previous atomicity groups (ignoring errors via allSettled) for odata v2
const prevs = []
for (let i = 0; i < agIndex; i++) prevs.push(promises[i])
await Promise.allSettled(prevs)
// don't write to res if already closed (e.g., due to 401 and login)
if (res.closed) return
sendPreludeOnce()
if (isJson) _writeResponseJson(responses, res)
else _writeResponseMultipart(responses, res, boundary)
})
if (requestsById[groupId]) requestsById[groupId].promise = promise
return promise
})
}
// Queue atomicity groups for execution:
// > Every atomicity is handled in a separate srv.tx
// > 'promises' contains all of these unresolved tx
const promises = []
for (let agIndex = 0; agIndex < atomicityGroups.length; agIndex++)
promises.push(_queued_exec(atomicityGroups[agIndex], agIndex))
// Trigger resolution of first 'max_parallel' promises in queue
for (let i = 0; i < max_parallel; i++) if (queue.length) queue.shift()()
await Promise.allSettled(promises)
sendPostlude()
} catch (e) {
next(e)
}
}
const textBodyParser = express.text({
...adapter.body_parser_options,
type: '*/*' // REVISIT: why do we need to override type here?
})
return function odata_batch(req, res, next) {
// REVISIT: very hackily avoid calling adapter.error twice per subrequest
// this needs to be done here because the router stack isn't yet finalized when the factory is called
if (!subrouter) {
// NOTE: error middlewares have 4 params
// create "subrouter" without the error middlewares for handling subrequests
subrouter = Object.create(adapter.router)
subrouter.stack = subrouter.stack.filter(l => l.handle.length === 3)
// combine adapter and global error middlewares to be invoked during error serialization
error_mws = adapter.router.stack.filter(l => l.handle.length === 4).map(l => l.handle)
error_mws = error_mws.concat(cds.middlewares.after.filter(mw => mw.length === 4).map(mw => mw.bind(adapter)))
}
if (req.method !== 'POST') {
cds.error({ status: 405, message: `Method ${req.method} is not allowed for calls to $batch endpoint` })
}
if (req.headers['content-type'].includes('application/json')) {
return _processBatch(req, res, next, req.body, 'JSON')
}
if (req.headers['content-type'].includes('multipart/mixed')) {
return textBodyParser(req, res, async function odata_batch_next(err) {
if (err) return next(err)
const boundary = getBoundary(req)
if (!boundary) return next(new cds.error({ status: 400, message: 'No boundary found in Content-Type header' }))
try {
const { requests } = await multipartToJson(req.body, boundary)
_processBatch(req, res, next, { requests }, 'MULTIPART', boundary)
} catch (e) {
// REVISIT: (how) handle multipart accepts?
next(e)
}
})
}
cds.error({
status: 400,
message: 'Batch requests must have content type multipart/mixed or application/json'
})
}
}
// Keep a register of all srv.tx we open for processing batches
const _openAtomicityGroupTxs = {}
cds.once('shutdown', () => {
return Promise.all(Object.values(_openAtomicityGroupTxs).map(tx => tx.rollback().catch(() => {})))
})