UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

181 lines (156 loc) 7.08 kB
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 }