@sap/cds-mtxs
Version:
SAP Cloud Application Programming Model - Multitenancy library
256 lines (233 loc) • 10 kB
JavaScript
const cds = require('@sap/cds')
const LOG = cds.log('mtx'), DEBUG = cds.debug('mtx'), { read, readdir, path, tar } = cds.utils
const { inspect, promisify } = require('node:util')
const readData = async function (extension, root) {
await tar.xvz(extension).to(root)
let extCsn = {}
try { extCsn = JSON.parse(await read(path.join(root, 'extension.csn'))) }
catch(e) { if (e.code !== 'ENOENT') throw e }
let bundles
try { bundles = await read(path.join(root, 'i18n', 'i18n.json')) }
catch(e) { if (e.code !== 'ENOENT') throw e }
const csvs = []
try {
const dirents = await readdir(path.join(root, 'data'))
for (const dirent of dirents) {
const basename = path.basename(dirent)
csvs[basename] = await read(path.join(root, 'data', dirent))
}
}
catch(e) { if (e.code !== 'ENOENT') throw e }
return { extCsn, bundles, csvs }
}
const readDataCsv = async function (extension, root) {
await tar.xvz(extension).to(root)
const csvs = []
try {
const dirents = await readdir(path.join(root, 'data'))
for (const dirent of dirents) {
const basename = path.basename(dirent)
csvs[basename] = await read(path.join(root, 'data', dirent))
}
}
catch(e) { if (e.code !== 'ENOENT') throw e }
return { csvs }
}
// REVISIT: opt-in/out retry mechanism on runtime layer?
// Sketch: cds.tx({ tenant: 't1', retries: 2 }, ...)
// Not for all error cases a retry is an appropriate handling mechanism (e.g. 403)
// -> error code allow/blocklist
/**
* @template T
* @param {() => Promise<T>} fn
* @param {number} [retryCount=5]
* @param {number} [initialRetryGap=5000]
* @returns {Promise<T>}
*/
const retry = async (fn, retryCount = cds.requires.multitenancy?.retries ?? 10, initialRetryGap = 5000) => {
let errorCount = 0
let finalError
let retryGap = initialRetryGap
while (errorCount < retryCount - 1) {
try {
return await fn()
} catch (e) {
if (e instanceof TypeError) throw e
// db unique constraint failure, previously masked as code 400
if (e.code === 301 || e.message.match(/unique constraint/i)) throw e
if (e.code === 400) throw e
if (e.code === 404) throw e
if (e.status === 404) throw e
// REVISIT: ugly -> shouldn't have to code for specific DBs
if (e.code === 'SQLITE_ERROR') throw e
if (e.code === 259 && e.sqlState === 'HY000' && /could not find table/i.test(e.message)) throw e
finalError = e
errorCount++
LOG.error('attempt', errorCount, 'errored with', e, '- retrying attempt', errorCount + 1, 'of', retryCount)
await promisify(setTimeout)(retryGap)
retryGap *= 1.5
}
}
LOG.error('exceeded maximum number of', retryCount, 'retries')
throw finalError
}
const t0 = cds.env.requires.multitenancy?.t0 ?? 't0'
let _t0Csn
const t0_ = async (query) => retry(async () => {
_t0Csn ??= cds.compile.for.nodejs(
await cds.load(`${__dirname}/../db/t0.cds`, { silent: true })
)
return cds.tx({ tenant: t0 }, tx => { tx.model = _t0Csn; return tx.run(query) })
})
async function token(credentials, { query = {}, form = {}, subdomain } = {}) {
const { clientid, clientsecret, certurl, url, certificate, key } = credentials ?? {}
const u = new URL(certurl || url)
if (subdomain) u.hostname = u.hostname.replace(/^[^.]+/, subdomain)
u.pathname = '/oauth/token'
Object.entries(query).forEach(([k, v]) => u.searchParams.append(k, v))
const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientid, ...form })
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }
let raw
if (certificate) {
raw = await new Promise((resolve, reject) => {
const req = require('node:https').request({
hostname: u.hostname,
port: u.port || 443,
path: u.pathname + u.search,
cert: certificate, key,
method: 'POST',
headers
}, res => {
const chunks = []
res.on('data', x => chunks.push(x))
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(Buffer.concat(chunks).toString())
else reject(new Error(Buffer.concat(chunks).toString(), { status: res.statusCode }))
})
})
req.on('error', reject)
req.end(body.toString())
})
} else {
headers.Authorization = 'Basic ' + Buffer.from(`${clientid}:${clientsecret}`).toString('base64')
const res = await fetchResiliently(u.toString(), { method: 'POST', headers, body: body.toString() })
raw = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
}
return raw
}
const maxRetries = cds.requires?.multitenancy?.serviceManager?.retries ?? 3 // compat
async function fetchResiliently(url, req, { retriesLeft = maxRetries, maxRetryAfter = 5000, failures = 0, retryUntil } = {}) {
const _backoff = async (attempt, minDelay = 200) => {
const maxDelay = 32_000
const delay = Math.max(200, Math.min(maxDelay, minDelay * 2 ** attempt))
if (retryUntil && Date.now() + delay > retryUntil) {
throw new Error('Retry delay threshold exceeded')
}
LOG.info('attempt', attempt, 'failed – retrying with', delay / 1000, 's delay')
await new Promise(resolve => setTimeout(resolve, delay))
}
if (failures) await _backoff(failures)
req.method ??= 'GET'
try {
DEBUG?.('>', req.method.toUpperCase(), url, inspect({
...(req.headers && { headers: { ...req.headers, Authorization: req.headers.Authorization?.split(' ')[0] + ' ...' } }),
...(req.params && { params: req.params }),
...(req.data && { data: req.data })
}, { depth: 11, compact: false, colors: cds.utils.colors.enabled }))
let finalUrl = url
if (req.params && Object.keys(req.params).length) {
const query = Object.entries(req.params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
finalUrl += (url.includes('?') ? '&' : '?') + query
}
const body = req.body !== undefined ? req.body : (req.data && !['GET', 'HEAD'].includes(req.method) ? JSON.stringify(req.data) : undefined)
const fetchOptions = { ...req, body }
const res = await fetch(finalUrl, fetchOptions)
const { status, statusText } = res
const headers = Object.fromEntries(res.headers.entries())
const contentType = res.headers.get('content-type') || ''
const data = contentType.includes('application/json') ? await res.json() : await res.text()
if (!res.ok) {
let msg = [status, statusText, data?.message, typeof data?.error === 'object' ? JSON.stringify(data.error) : data?.error, data?.description, data?.cause].filter(Boolean).join(' – ')
if (res.status === 429 && res.headers.get('retry-after')) msg += ` – retry after ${res.headers.get('retry-after')}`
throw Object.assign(new Error(msg), { status, statusText, headers, code: data?.error?.code })
}
const response = { status, statusText, headers, data }
DEBUG?.('<', req.method.toUpperCase(), url, status, statusText, inspect(_redacted(headers)), inspect(_redacted(data), { depth: 11, colors: cds.utils.colors.enabled }))
return response
} catch (error) {
const { status } = error ?? { status: 500 }
if (status in { 400: 1, 401: 1, 403: 1, 404: 1, 409: 1 } || retriesLeft === 0) return Promise.reject(error)
const minDelay = parseRetryAfter(error.headers || {})
if (status === 429 && (minDelay < 0 || minDelay > maxRetryAfter) || retriesLeft === 0) return Promise.reject(error)
const attempt = Math.max(maxRetries - retriesLeft + 1, failures)
if (LOG._debug) {
const e = error.toJSON?.() ?? error
DEBUG(`fetching ${url} attempt ${attempt} failed with`, {
...(e.name && { name: e.name }),
...(e.message && { message: e.message }),
...(e.description && { description: e.description })
})
}
await _backoff(attempt, minDelay)
return fetchResiliently(url, req, { retriesLeft: retriesLeft - 1, failures, retryUntil })
}
}
// TODO define max acceptable retry-after value?
function parseRetryAfter(headers) {
const retryAfter = headers['retry-after']
// Handle seconds format (most common)
if (/^\d+$/.test(retryAfter)) {
return parseInt(retryAfter, 10) * 1000 // Convert to milliseconds
}
// Handle HTTP date format
const retryDate = new Date(retryAfter)
if (!isNaN(retryDate.getTime())) {
return Math.max(0, retryDate.getTime() - Date.now())
}
return -1 // No valid Retry-After header found
}
const SECRETS = /(passw)|(cert)|(ca)|(secret)|(key)|(access_token)|(imageUrl)/i
/**
* Masks password-like strings, also reducing clutter in output
* @param {any} cred - object or array with credentials
* @returns {any}
*/
function _redacted(cred) {
if (!cred) return cred
if (Array.isArray(cred)) return cred.map(c => _redacted(c))
if (typeof cred === 'object') {
const newCred = Object.assign({}, cred)
Object.keys(newCred).forEach(k => (typeof newCred[k] === 'string' && SECRETS.test(k)) ? (newCred[k] = '...') : (newCred[k] = _redacted(newCred[k])))
return newCred
}
return cred
}
function getAppId() {
if (this.appid !== undefined) return this.appid
const main = require('../srv/config')
if (typeof main.env.appid === 'string' && main.env.appid.length > 0) {
return this.appid = main.env.appid
} else if (main.env.appid === true) {
return this.appid = require(main.root + '/package.json').name
}
return this.appid = null
}
// TODO stolen from cds.utils.merge, remove with cds 10
function deepMerge(o,...xs) {
let v; for (let x of xs) for (let k in x)
if (k === '__proto__' || k === 'constructor') continue //> avoid prototype pollution
else o[k] = is_object(v=x[k]) ? deepMerge(o[k]??={},v) : v
return o
}
const is_object = x => typeof x === 'object' && x !== null && !is_array(x)
const is_array = Array.isArray
module.exports = {
t0_,
retry,
readData,
readDataCsv,
token,
fetchResiliently,
getAppId,
deepMerge
}