@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
198 lines (160 loc) • 7.79 kB
JavaScript
const cds = require('../../cds')
const LOG = cds.log('remote')
const { convertV2PayloadData } = require('./data')
const { Readable } = require('stream')
/**
* Normalizes server path.
*
* Adds / in the beginning of the path if not exists.
* Removes / in the end of the path if exists.
*
* @param {*} path - to be normalized
*/
const _formatPath = (path = '') => {
let formattedPath = path
if (!path.startsWith('/')) formattedPath = `/${formattedPath}`
if (path.endsWith('/')) formattedPath = formattedPath.substring(0, formattedPath.length - 1)
return formattedPath
}
const _cqnWithPublicEntries = query => {
return Object.fromEntries(Object.entries(query).filter(([key]) => !key.startsWith('_')))
}
const _cqnToHcqlRequestConfig = cqn => {
if (cqn.SELECT && cqn.SELECT.from) return { method: 'GET', data: { SELECT: _cqnWithPublicEntries(cqn.SELECT) } }
if (cqn.INSERT && cqn.INSERT.into) return { method: 'POST', data: { INSERT: _cqnWithPublicEntries(cqn.INSERT) } }
if (cqn.UPDATE && cqn.UPDATE.entity) return { method: 'PATCH', data: { UPDATE: _cqnWithPublicEntries(cqn.UPDATE) } }
if (cqn.UPSERT && cqn.UPSERT.into) return { method: 'PUT', data: { UPSERT: _cqnWithPublicEntries(cqn.UPSERT) } }
if (cqn.DELETE && cqn.DELETE.from) return { method: 'DELETE', data: { DELETE: _cqnWithPublicEntries(cqn.DELETE) } }
cds.error(400, 'Invalid CQN object can not be processed.', JSON.stringify(cqn), _cqnToHcqlRequestConfig)
}
const _cqnToRequestConfig = (query, service, req) => {
const { kind, model } = service
const queryObject = cds.odata.urlify(query, { kind, model, method: req.method })
const requestConfig = {
method: queryObject.method,
url: queryObject.path
// REVISIT: remove when we can assume that number of remote services running Okra is negligible
// ugly workaround for Okra not allowing spaces in ( x eq 1 )
.replace(/\( /g, '(')
.replace(/ \)/g, ')')
}
if (queryObject.method !== 'GET' && queryObject.method !== 'HEAD') {
requestConfig.data = kind === 'odata-v2' ? convertV2PayloadData(queryObject.body, req.target) : queryObject.body
}
return requestConfig
}
const _stringToRequestConfig = (query, data, target) => {
const cleanQuery = query.trim()
const blankIndex = cleanQuery.substring(0, 8).indexOf(' ')
const requestConfig = {
method: cleanQuery.substring(0, blankIndex).toUpperCase() || 'GET',
url: encodeURI(_formatPath(cleanQuery.substring(blankIndex, cleanQuery.length).trim()))
}
if (data && requestConfig.method !== 'GET' && requestConfig.method !== 'HEAD') {
requestConfig.data = this.kind === 'odata-v2' ? Object.assign({}, convertV2PayloadData(data, target)) : data
}
return requestConfig
}
const _pathToRequestConfig = (method, path, data, target, srvName) => {
let url = path
if (!url.startsWith('/')) {
// extract entity name and instance identifier (either in "()" or after "/") from fully qualified path
const parts = path.match(/([\w.]*)([\W.]*)(.*)/)
if (!parts) url = '/' + path.match(/\w*$/)[0]
else if (url.startsWith(srvName)) url = '/' + parts[1].replace(srvName + '.', '') + parts[2] + parts[3]
else url = '/' + parts[1].match(/\w*$/)[0] + parts[2] + parts[3]
// normalize in case parts[2] already starts with /
url = url.replace(/^\/\//, '/')
}
const requestConfig = { method, url }
if (data && requestConfig.method !== 'GET' && requestConfig.method !== 'HEAD') {
requestConfig.data = this.kind === 'odata-v2' ? Object.assign({}, convertV2PayloadData(data, target)) : data
}
return requestConfig
}
const _hasHeader = (headers, header) =>
Object.keys(headers || [])
.map(k => k.toLowerCase())
.includes(header)
const KINDS_SUPPORTING_BATCH = { odata: true, 'odata-v2': true, 'odata-v4': true }
const _extractRequestConfig = (req, query, service) => {
if (service.kind === 'hcql' && typeof query === 'object') return _cqnToHcqlRequestConfig(query)
if (service.kind === 'hcql') throw new Error('The request has no query and cannot be served by HCQL remote services!')
if (typeof query === 'object') return _cqnToRequestConfig(query, service, req)
if (typeof query === 'string') return _stringToRequestConfig(query, req.data, req.target)
//> no model, no service.definition
return _pathToRequestConfig(req.method, req.path, req.data, req.target, service.definition?.name || service.namespace)
}
const _batchEnvelope = (requestConfig, maxGetUrlLength) => {
if (LOG._debug) {
LOG.debug(
`URL of remote request exceeds the configured max length of ${maxGetUrlLength}. Converting it to a $batch request.`
)
}
const batchedRequest = [
'--batch1',
'Content-Type: application/http',
'Content-Transfer-Encoding: binary',
'',
`${requestConfig.method} ${requestConfig.url.replace(/^\//, '')} HTTP/1.1`,
...Object.keys(requestConfig.headers).map(k => `${k}: ${requestConfig.headers[k]}`),
'',
'',
'--batch1--',
''
].join('\r\n')
requestConfig.method = 'POST'
requestConfig.url = '/$batch'
requestConfig.headers['accept'] = 'multipart/mixed'
requestConfig.headers['content-type'] = 'multipart/mixed; boundary=batch1'
requestConfig._autoBatchedGet = true
requestConfig.data = batchedRequest
}
module.exports.extractRequestConfig = (req, query, service) => {
const requestConfig = _extractRequestConfig(req, query, service)
if (service.kind === 'odata-v2' && req.event === 'READ' && requestConfig.url?.match(/(\/any\()|(\/all\()/)) {
req.reject(501, 'Lambda expressions are not supported in OData v2')
}
requestConfig.url = _formatPath(requestConfig.url)
requestConfig.headers = { accept: 'application/json,text/plain' }
// Forward the locale properties from the original request (including region variants or weight factors)
if (!_hasHeader(req.headers, 'accept-language') && req._locale) {
// If not given, it's taken from the user's locale (normalized and simplified)
requestConfig.headers['accept-language'] = req._locale
}
// Forward all dwc-* headers
if (service.options.forward_dwc_headers) {
const originalHeaders = req.context?.http.req.headers || {}
for (const k in originalHeaders) if (k.match(/^dwc-/)) requestConfig.headers[k] = originalHeaders[k]
}
// Ensure stream request body & appropriate content headers
if (
requestConfig.data &&
requestConfig.method !== 'GET' &&
requestConfig.method !== 'HEAD' &&
!(requestConfig.data instanceof Readable)
) {
if (typeof requestConfig.data === 'object' && !Buffer.isBuffer(requestConfig.data)) {
requestConfig.headers['content-type'] = 'application/json'
requestConfig.headers['content-length'] = Buffer.byteLength(JSON.stringify(requestConfig.data))
} else if (typeof requestConfig.data === 'string') {
requestConfig.headers['content-length'] = Buffer.byteLength(requestConfig.data)
} else if (Buffer.isBuffer(requestConfig.data)) {
requestConfig.headers['content-length'] = Buffer.byteLength(requestConfig.data)
if (!_hasHeader(req.headers, 'content-type')) requestConfig.headers['content-type'] = 'application/octet-stream'
}
}
// Add batch envelope if needed
const maxGetUrlLength = service.options.max_get_url_length ?? cds.env.remote?.max_get_url_length ?? 1028
if (
requestConfig.method === 'GET' &&
KINDS_SUPPORTING_BATCH[service.kind] &&
requestConfig.url.length > maxGetUrlLength
) {
_batchEnvelope(requestConfig, maxGetUrlLength)
}
if (service.path) requestConfig.url = `${encodeURI(service.path)}${requestConfig.url}`
// Set axios responseType to 'arraybuffer' if returning binary in rest
if (req._binary) requestConfig.responseType = 'arraybuffer'
return requestConfig
}