@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
181 lines (156 loc) • 7.08 kB
JavaScript
const cds = require('../../cds')
const LOG = cds.log('remote')
const { Readable } = require('stream')
// process.env.destinations — CloudSDK convention for local destination resolution:
// a JSON array of { name, url, ... } objects, e.g. for local development or testing.
const findLocalDestinations = name => {
const raw = process.env.destinations
if (!raw) return []
try {
return JSON.parse(raw).filter(d => (d.name ?? d.Name) === name)
} catch (e) {
LOG.warn(`Failed to parse 'destinations' env variable:`, e.message)
return []
}
}
const _normalizeAuthentication = destination => {
if (destination.authentication) return destination
const authentication =
destination.username != null || destination.password != null ? 'BasicAuthentication' : 'NoAuthentication'
return { ...destination, authentication }
}
const _resolveDestination = destination => {
if (destination.url) return _normalizeAuthentication(destination)
const matches = findLocalDestinations(destination.destinationName)
if (matches.length > 1)
LOG.warn(
`'destinations' env variable contains multiple entries with name '${destination.destinationName}'. Only the first will be used.`
)
const found = matches[0]
if (found?.url) {
// Normalize PascalCase 'Name' to lowercase (BTP Destination Service API uses PascalCase)
if (found.Name && !found.name) found.name = found.Name
return _normalizeAuthentication({ ...destination, ...found })
}
// REVISIT: resolve named destination via BTP Destination Service
throw new Error(`Cannot resolve destination '${destination.destinationName}'.`)
}
// Closes parity gaps vs CloudSDK's csrf-token-middleware.js:
// 1. Slash retry — try preflight with trailing slash first, then without,
// to work around an S/4HANA redirect quirk (axios#3369).
// 2. Cookie forwarding — extract set-cookie from the preflight response and
// forward it as a cookie header on the actual request. Required by
// backends that bind the CSRF token to the session cookie.
// 3. Token in error response — also check error response headers for the
// token (some servers return it even on 4xx).
// CloudSDK additionally sets `content-length: 0` on the preflight, but that
// header is owned by undici for native fetch and cannot be set by hand; the
// runtime emits the correct value automatically for bodyless requests.
const _csrfPreflight = async (url, headers, method, signal) => {
let res
try {
res = await fetch(url, { method, headers: { ...headers, 'x-csrf-token': 'Fetch' }, signal })
} catch (e) {
LOG._debug && LOG.debug('CSRF token fetch failed:', e)
return
}
// Some servers return the token on a non-2xx response — accept it anyway.
const token = res.headers.get('x-csrf-token')
if (!token) return
// prettier-ignore
const cookie = res.headers.getSetCookie().map(c => c.split(';')[0]).join(';')
return cookie ? { token, cookie } : { token }
}
const _fetchCsrfToken = async (url, headers, csrf, signal) => {
const method = (csrf.method || 'head').toUpperCase()
// S/4 redirects when '/' is missing from the preflight URL — try with slash first, then without.
const withSlash = url.endsWith('/') ? url : url + '/'
const withoutSlash = url.endsWith('/') ? url.replace(/\/+$/, '') : url
return (
(await _csrfPreflight(withSlash, headers, method, signal)) ??
(await _csrfPreflight(withoutSlash, headers, method, signal))
)
}
const _readBody = async (response, responseType) => {
switch (responseType) {
case 'stream':
return Readable.fromWeb(response.body)
case 'json':
return response.json()
case 'text':
return response.text()
case 'arraybuffer':
return response.arrayBuffer().then(Buffer.from)
}
// Content-type fallback when no responseType was set
const ct = response.headers.get('content-type')
if (/stream|image|pdf|tar/.test(ct)) return Readable.fromWeb(response.body)
if (/xml|html/.test(ct)) return response.text()
const text = await response.text()
try {
return JSON.parse(text)
} catch {
return text
}
}
/**
* Native-fetch HTTP executor.
*/
const executeHttpRequest = async (destination, requestConfig) => {
// Resolve destination
destination = _resolveDestination(destination)
const baseUrl = (destination.url || '').replace(/\/$/, '')
const { method = 'GET', url = '', data, responseType, maxBodyLength, timeout, csrf } = requestConfig
const signal = timeout ? AbortSignal.timeout(timeout) : undefined // eslint-disable-line no-undef
// Assemble headers
const headers = { ...destination.headers, ...requestConfig.headers }
if (!headers.authorization && destination.authentication === 'BasicAuthentication') {
const { username = '', password = '' } = destination
headers.authorization = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64')
}
const urlWithoutQuery = baseUrl + (url && !url.startsWith('/') ? `/${url}` : url)
if (csrf && method !== 'GET' && method !== 'HEAD') {
const result = await _fetchCsrfToken(urlWithoutQuery, headers, csrf, signal)
if (result?.token) headers['x-csrf-token'] = result.token
if (result?.cookie) headers.cookie = headers.cookie ? `${headers.cookie};${result.cookie}` : result.cookie
}
// Serialize body
let body
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
if (typeof data === 'string' || Buffer.isBuffer(data) || typeof data.pipe === 'function') body = data
else body = JSON.stringify(data)
}
if (maxBodyLength && body) {
const len = Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body)
if (len > maxBodyLength)
throw new Error(`Request body length ${len} exceeds configured max_body_length ${maxBodyLength}.`)
}
// Build full URL
const { queryParameters } = destination
let fullUrl = urlWithoutQuery
if (queryParameters && Object.keys(queryParameters).length) {
const qs = new URLSearchParams(Object.entries(queryParameters).map(([k, v]) => [k, String(v)])).toString()
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs
}
if (LOG._debug) LOG.debug(`${method} ${fullUrl}`)
// Execute request
let response
try {
response = await fetch(fullUrl, { method, headers, body, signal })
} catch (e) {
const timedOut = e.name === 'TimeoutError'
const msg = timedOut ? `Exceeded timeout of ${timeout}ms.` : e.message
throw Object.assign(new Error(msg), { cause: e })
}
// Parse response
const { status, statusText } = response
const responseHeaders = Object.fromEntries(response.headers)
const responseData = await _readBody(response, response.ok ? responseType : undefined)
if (!response.ok) {
const err = new Error(`Request to remote service failed with status ${status}.`)
err.response = { status, statusText, headers: responseHeaders, data: responseData }
throw err
}
return { data: responseData, headers: responseHeaders, status }
}
module.exports = { executeHttpRequest, findLocalDestinations }