UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

265 lines (218 loc) 9.95 kB
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 }