UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

696 lines (574 loc) 26.1 kB
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(() => {}))) })