UNPKG

@sap/cds-mtxs

Version:

SAP Cloud Application Programming Model - Multitenancy library

256 lines (233 loc) 10 kB
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 }