@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
265 lines (218 loc) • 9.95 kB
JavaScript
const cds = require('../../cds')
const LOG = cds.log('remote')
const { getCloudSdk } = require('./cloudSdkProvider')
const SANITIZE_VALUES = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
const { convertV2ResponseData, deepSanitize } = require('./data')
const _logRequest = (requestConfig, destination) => {
const req2log = { headers: _sanitizeHeaders({ ...requestConfig.headers }) }
if (requestConfig.data && Object.keys(requestConfig.data).length) {
// In case of auto batch (only done for `GET` requests) no data is part of batch and for debugging URL is crucial
if (SANITIZE_VALUES && !requestConfig._autoBatchedGet) req2log.data = deepSanitize(requestConfig.data)
else req2log.data = requestConfig.data
}
LOG.debug(
`${requestConfig.method} ${destination.url || `<${destination.destinationName}>`}${requestConfig.url}`,
req2log
)
}
const _sanitizeHeaders = headers => {
// REVISIT: is this in-place modification intended?
if (headers?.authorization) headers.authorization = headers.authorization.split(' ')[0] + ' ***'
return headers
}
const _executeHttpRequest = async ({ requestConfig, destination, destinationOptions, jwt }) => {
const { executeHttpRequestWithOrigin } = getCloudSdk()
if (typeof destination === 'string') {
destination = { destinationName: destination, ...destinationOptions }
if (destination.jwt === undefined) destination.jwt = jwt
if (!destination.jwt && destination.jwt !== undefined) delete destination.jwt
} else if (destination.forwardAuthToken) {
destination = {
...destination,
headers: destination.headers ? { ...destination.headers } : {},
authentication: 'NoAuthentication'
}
delete destination.forwardAuthToken
if (jwt) destination.headers.authorization = `Bearer ${jwt}`
else if (LOG._warn) LOG.warn('Missing JWT token for forwardAuthToken!')
}
// Cloud SDK throws error if useCache is activated and jwt is undefined
if (destination.jwt === undefined) destination.useCache = false
if (LOG._debug) _logRequest(requestConfig, destination)
// cloud sdk requires a new mechanism to differentiate the priority of headers
// "custom" keeps the highest priority as before
requestConfig = {
...requestConfig,
headers: { custom: { ...requestConfig.headers } }
}
const maxBodyLength = cds.env?.remote?.max_body_length
if (maxBodyLength) requestConfig.maxBodyLength = maxBodyLength
return executeHttpRequestWithOrigin(destination, requestConfig, { fetchCsrfToken: false })
}
function _defineProperty(obj, property, value) {
const props = {}
if (Array.isArray(obj)) {
const _map = obj.map
const map = (..._) => _defineProperty(_map.call(obj, ..._), property, value)
props.map = { value: map, enumerable: false, configurable: true, writable: true }
}
props[property] = { value: value, enumerable: false, configurable: true, writable: true }
for (const prop in props) {
Object.defineProperty(obj, prop, props[prop])
}
return obj
}
function _normalizeMetadata(prefix, data, results) {
const target = results !== undefined ? results : data
if (typeof target !== 'object' || target === null) return target
const metadataKeys = Object.keys(data).filter(k => prefix.test(k))
for (const k of metadataKeys) {
const $ = k.replace(prefix, '$')
_defineProperty(target, $, data[k])
delete target[k]
}
if (Array.isArray(target)) {
return target.map(row => _normalizeMetadata(prefix, row))
}
// check properties for all and prop.results for odata v2
for (const [key, value] of Object.entries(target)) {
if (value && typeof value === 'object') {
const nestedResults = (Array.isArray(value.results) && value.results) || value
target[key] = _normalizeMetadata(prefix, value, nestedResults)
}
}
return target
}
const _getPurgedRespActionFunc = (data, returnType) => {
// return type is primitive value or inline/complex type
if (returnType.kind === 'type' && !returnType.items && Object.values(data).length === 1) {
for (const key in data) {
return data[key]
}
}
return data
}
const _purgeODataV2 = (data, target, returnType) => {
if (typeof data !== 'object' || !data.d) return data
data = returnType ? _getPurgedRespActionFunc(data.d, returnType) : data.d
const purgedResponse = typeof data === 'object' && 'results' in data ? data.results : data
const convertedResponse = convertV2ResponseData(purgedResponse, target, returnType)
return _normalizeMetadata(/^__/, data, convertedResponse)
}
const _purgeODataV4 = data => {
if (typeof data !== 'object') return data
const purgedResponse = 'value' in data ? data.value : data
return _normalizeMetadata(/^@odata\./, data, purgedResponse)
}
const TYPES_TO_REMOVE = { function: 1, object: 1 }
const PROPS_TO_IGNORE = { cause: 1, name: 1 }
const _getSanitizedError = (e, reqOptions, options = { suppressRemoteResponseBody: false, batchRequest: false }) => {
const request = {
method: reqOptions.method,
url: e.config ? e.config.baseURL + e.config.url : reqOptions.url,
headers: e.config ? e.config.headers : reqOptions.headers
}
if (options.batchRequest) request.body = reqOptions.data
e.request = request
if (e.response) {
const response = { status: e.response.status, statusText: e.response.statusText, headers: e.response.headers }
if (e.response.data && !options.suppressRemoteResponseBody) response.body = e.response.data
e.response = response
}
const correlationId =
(cds.context && cds.context.id) || (reqOptions.headers && reqOptions.headers['x-correlation-id'])
if (correlationId) e.correlationId = correlationId
// sanitize authorization
_sanitizeHeaders(e.request.headers)
// delete functions and complex objects in config
for (const k in e) if (typeof e[k] === 'function') delete e[k]
if (e.config) for (const k in e.config) if (TYPES_TO_REMOVE[typeof e.config[k]]) delete e.config[k]
// REVISIT: ErrorWithCause log waaay to much -> copy what we want to new object (as delete e.cause doesn't work)
if (e.cause) {
let msg = ''
let cur = e.cause
while (cur) {
msg += ' Caused by: ' + cur.message
cur = cur.cause
}
const _e = { message: e.message + msg }
for (const k of [...Object.keys(e).filter(k => !PROPS_TO_IGNORE[k])]) _e[k] = e[k]
e = _e
}
// AxiosError's toJSON() method doesn't include the request and response objects
if (e.__proto__.toJSON) {
e.toJSON = function () {
return { ...e.__proto__.toJSON(), request: this.request, response: this.response }
}
}
return e
}
module.exports.run = async (requestConfig, options) => {
const { destination, destinationOptions, jwt, suppressRemoteResponseBody } = options
let response
try {
response = await _executeHttpRequest({ requestConfig, destination, destinationOptions, jwt })
} catch (e) {
// > axios received status >= 400 -> gateway error
const msg = e?.response?.data?.error?.message?.value ?? e?.response?.data?.error?.message ?? e.message
e.message = msg ? 'Error during request to remote service: ' + msg : 'Request to remote service failed.'
const sanitizedError = _getSanitizedError(e, requestConfig, { suppressRemoteResponseBody })
const err = Object.assign(new Error(e.message), { statusCode: 502, reason: sanitizedError })
cds.repl || LOG.warn(err)
throw err
}
// text/html indicates a redirect -> reject
if (
response.headers?.['content-type']?.includes('text/html') &&
!(
requestConfig.headers.accept.includes('text/html') ||
requestConfig.headers.accept.includes('text/*') ||
requestConfig.headers.accept.includes('*/*')
)
) {
const e = new Error("Received content-type 'text/html' which is not part of accepted content types")
e.response = response
const sanitizedError = _getSanitizedError(e, requestConfig, { suppressRemoteResponseBody })
const message = 'Error during request to remote service: ' + e.message
const err = Object.assign(new Error(message), { statusCode: 502, reason: sanitizedError })
LOG._warn && LOG.warn(err)
throw err
}
// get result of $batch
// does only support read requests as of now
if (requestConfig._autoBatchedGet) {
// response data splitted by empty lines
// 1. entry contains batch id and batch headers
// 2. entry contains request status code and request headers
// 3. entry contains data or error
const responseDataSplitted = response.data.split('\r\n\r\n')
// remove closing batch id
const [content] = responseDataSplitted[2].split('\r\n')
const contentJSON = JSON.parse(content)
if (responseDataSplitted[1].startsWith('HTTP/1.1 2')) {
response.data = contentJSON
}
if (responseDataSplitted[1].startsWith('HTTP/1.1 4') || responseDataSplitted[1].startsWith('HTTP/1.1 5')) {
const innerError = contentJSON.error || contentJSON
innerError.status = Number(responseDataSplitted[1].match(/HTTP.*(\d{3})/m)[1])
innerError.response = response
const sanitizedError = _getSanitizedError(innerError, requestConfig, { batchRequest: true })
const err = Object.assign(new Error('Request to remote service failed.'), {
statusCode: 502,
reason: sanitizedError
})
LOG._warn && LOG.warn(err)
throw err
}
}
const { kind, resolvedTarget, returnType } = options
if (kind === 'odata-v4') return _purgeODataV4(response.data)
if (kind === 'odata-v2') return _purgeODataV2(response.data, resolvedTarget, returnType)
if (kind === 'odata') {
if (typeof response.data !== 'object') return response.data
// try to guess if we need to purge v2 or v4
if (response.data.d) return _purgeODataV2(response.data, resolvedTarget, returnType)
return _purgeODataV4(response.data)
}
return response.data
}