@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
553 lines (481 loc) • 19.3 kB
JavaScript
const { isdir, isfile, fs, path } = require('../utils/cds-utils')
const DEFAULTS = require('./defaults'), defaults = require.resolve ('./defaults')
const compat = require('./compat')
const DEBUG = /\b(y|all|env)\b/.test(process.env.DEBUG) ? console.debug : undefined
/**
* Both a config instance as well as factory for.
*/
class Config {
/**
* This is the one and only way to construct new instances.
* Public API is through `cds.env.for (<context>)`
* @returns {Config & typeof DEFAULTS}
*/
for (context, _cwd, _defaults=true) {
let cds = global.cds; if (typeof context === 'object') [ cds, context ] = [ context, 'cds' ]
let cwd = _cwd || this._home || cds?.root || process.cwd()
DEBUG?.('[cds.env] - loading config for', {context,cwd})
let env = new Config (context, cwd, _defaults)
cds?.emit?.('env',env)
return env
}
/**
* Only used internally, i.e. through cds.env.for(<context>)
*/
constructor (_context, _home, _defaults=true) {
Object.assign (this, { _context, _home, _sources:[] })
// Capture stack trace to report cds.env usage before cds.test()
if (global.test) Error.captureStackTrace(this,Config.prototype.for)
// Determine profiles from NODE_ENV + CDS_ENV
const { NODE_ENV, CDS_ENV } = process.env, profiles = []
if (NODE_ENV) profiles.push (NODE_ENV)
if (CDS_ENV) profiles.push (...CDS_ENV.split(/\s*,\s*/))
if (_home) _add_static_profiles (_home, profiles);
if (_home && this['project-nature'] === 'java') profiles.push('java')
if (!profiles.includes('production')) profiles.push('development')
this._profiles = new Set (profiles)
this._profiles._defined = new Set
this._profiles._important = []
// Set compat requires default values
if (_context === 'cds' && _defaults) this.add (DEFAULTS, defaults)
if (_context === 'cds' && _defaults) compat (this)
if (!_home) return
// Read config sources in reverse order of precedence -> last one wins
if (_context !== 'cds') {
this.#import (_home,'package.json', { get: p => p[_context] })
} else {
for (let {impl} of Object.values(this.plugins)) {
const _plugin = path.dirname(impl)
this.#import (_plugin,'.cdsrc.yaml', { load: _readYaml })
this.#import (_plugin,'.cdsrc.json')
this.#import (_plugin,'.cdsrc.js')
this.#import (_plugin,'package.json', { get: p => p.cds })
}
const user_ = process.env.CDS_USER_HOME || require('os').homedir()
this.#import (user_,'.cdsrc.json')
this.#import (user_,'.cdsrc.js')
this.#import (_home,'.cdsrc.yaml', { load: _readYaml })
this.#import (_home,'.cdsrc.json')
this.#import (_home,'.cdsrc.js')
this.#import (_home,'package.json', { get: _ext_package_json })
this.#import (_home,'.cdsrc-private.json')
}
// Apply important (!) profiles from config sources
for (let each of this._profiles._important) each()
delete this._profiles._important
// Add process env before linking to allow things like CDS_requires_db=sql
this._add_process_env()
// Link cds.requires services to cds.requires.kinds
this._link_required_services()
// Add compatibility and correlations for mtx
const db = this.requires?.db
if (this.requires?.db) {
if (this.requires.multitenancy !== undefined)
Object.defineProperty (db, 'multiTenant', { value: !!this.requires.multitenancy })
else if (db.multiTenant !== undefined)
this.requires.multitenancy = db.multiTenant
}
if (this.requires?.multitenancy && this.requires.db?.kind === 'hana' && !this.requires.db.vcap) Object.assign(this.requires.db, { vcap: { label: 'service-manager' } })
// Complete service configurations from cloud service bindings
this._add_cloud_service_bindings(process.env)
this.appid ??= null
if (typeof this.appid === 'boolean') {
if (this.appid) this.#import(_home, 'package.json', { get: p => ({ appid: p.name }) })
else this.appid = null
}
// Only if feature is enabled
if (this.features && this.features.emulate_vcap_services) {
this._emulate_vcap_services()
}
}
#import (dir, file, etc) {
let conf = this.load (dir, file, etc)
if (conf) this.add (conf)
}
load (dir, file, { load=_readJson, get = x => x.cds||x } = {}) {
file = path.join (dir, file)
DEBUG?.('[cds.env] - checking', {file})
let cont = load(file); if (!cont) return
let conf = get(cont); if (!conf) return
DEBUG?.('[cds.env] - importing', file)
this._sources.push (file)
return conf
}
get plugins() {
return super.plugins = require('../plugins').fetch()
}
add (conf, /*from:*/ _src, profiles = this._profiles) {
if (!conf) return this
if (_src) this._sources.push (_src)
const reqs = conf.requires
if (reqs) { // normalize requires.x = kind to requires.x = {kind}
for (let each in reqs) {
if (typeof reqs[each] === 'string') reqs[each] = {kind:conf.requires[each]}
}
}
_merge (this, conf, profiles)
return this
}
/**
* Retrieves the value for a config option, specified as a property path.
*/
get (option) {
if (!option) return
let path = option.includes('/') ? option.split('/') : option.split('.')
return path.reduce ((p,n)=> p && p[n], this)
}
get profiles() {
return super.profiles = Array.from (this._profiles)
}
get roots() {
return super.roots = Object.values(this.folders) .concat ([ 'schema', 'services' ])
}
get tmp() { return super.tmp = require('os').tmpdir() }
/**
* Provides access to system defaults for cds env.
*/
get defaults() { return DEFAULTS }
/**
* Get effective options for .odata
*/
get effective(){
return super.effective = require('..').compiler._options.for.env()
}
/**
* For BAS only: to find out whether this is a Java or Node.js project
*/
get "project-nature" () {
const has_pom_xml = [this.folders?.srv,'.'] .some (
f => f && isfile (path.join (this._home, f, 'pom.xml'))
)
return has_pom_xml ? 'java' : 'nodejs'
}
/**
* For BAS only: get all defined profiles (could include some from the defaults)
*/
get "defined-profiles" () {
return [...this._profiles._defined]
}
//////////////////////////////////////////////////////////////////////////
//
// DANGER ZONE!
// The following are internal APIs which can always change!
//
_add_to_env (filename, env = process.env) {
const _env = this.load (this._home, filename, { load: _readEnv })
for (const key in _env) {
if (key in env) continue // do not change existing env vars
const val = _env[key]
env[key] = typeof val === 'string' ? val : JSON.stringify(val)
}
}
_add_process_env() {
const prefix = this._context, cwd = this._home
const {env} = process
this._add_to_env ('default-env.json', env)
if (this._profiles.has('development')) {
for (let each of this._profiles)
each === 'development' || this._add_to_env (`.${each}.env`, env)
this._add_to_env ('.env', env)
}
const PREF = prefix.toUpperCase(), my = { CONFIG: PREF+'_CONFIG', ENV: PREF+'_ENV' }
let config
let val = env[my.CONFIG]
if (val) try {
// CDS_CONFIG={ /* json */}
config = JSON.parse (val)
} catch {
// CDS_CONFIG=/path/to/config.json *OR* CDS_CONFIG=/path/to/config/dir
if (typeof val === "string") {
// Load from JSON file or directory; No profile support!
if (cwd && !path.isAbsolute(val)) val = path.join(cwd, val)
const json = _readJson(val) || _readFromDir(val)
if (json) this.add (json, val, false)
}
}
if (!config) config = {}
const pref_ = RegExp('^'+prefix+'[._]','i')
for (let p in env) if (!(p in my) && pref_.test(p)) {
const pEsc = p.replace(/__/g, '!!') // escaping of _ by __ : protect __ so that it's not split below
const key = /[a-z]/.test(pEsc) ? pEsc : pEsc.toLowerCase() //> CDS_FOO_BAR -> cds_foo_bar
let path = key.slice(prefix.length+1) .split (key[prefix.length]) //> ['foo','bar']
for (var o=config,next;;) {
next = path.shift()
next = next.replace(/!!/g, '_') // undo !! protection and reduce __ to _
if (!path.length) break
if (!path[0]) next = next+'-'+path.shift()+path.shift() // foo__bar -> foo-bar
o = o[next] || (o[next] = {})
}
o[next] = _value4(env[p])
}
if (Object.keys(config).length) this.add (config, 'process.env')
}
_link_required_services () {
const { requires, _profiles } = this; if (!requires) return
const kinds = requires.kinds || {}
Object.defineProperty (requires, 'kinds', { value:kinds, enumerable:false }) // for cds env
// Object.setPrototypeOf (requires, kinds)
for (let each in kinds) kinds[each] = _linked (each, kinds[each])
for (let each in requires) requires[each] = _linked (each, requires[each])
function _linked (key, val) {
if (!val || val._is_linked) return val
if (val === true) {
let x = kinds[key]
if (x) val = x; else if (key+'-defaults' in kinds) val = {kind:key+'-defaults'}; else return val
}
if (typeof val === 'string') {
let x = kinds[val] || kinds[val+'-'+key] || kinds[key+'-'+val]
if (x) val = {kind:val}; else return val
}
let k = val.kind, p, preset = kinds[p=k] || kinds[p=k+'-'+key] || kinds[p=key+'-'+k]
if (!preset?.$root) {
const preset1 = kinds[key]
if (typeof preset1 === 'object' && preset1 !== val) {
const top = val, base = _merge ({},_linked(key,preset1)), {kind} = base
val = _merge (base, top) // apply/override with top-level data
if (kind) val.kind = kind // but inherited kind wins
}
}
if (typeof preset === 'object' && preset !== val) {
const top = val, base = _merge ({},_linked(p,preset), _profiles), {kind} = base
val = _merge (base, top, _profiles) // apply/override with top-level data
if (kind) val.kind = kind // but inherited kind wins
}
if (typeof val === 'object') Object.defineProperty (val, '_is_linked', {value:true})
return val
}
}
_add_vcap_services (VCAP_SERVICES) {
if (this.features && this.features.vcaps === false) return
if (!this.requires) return
if (!VCAP_SERVICES) return
try {
const vcaps = JSON.parse (VCAP_SERVICES)
const any = this._add_vcap_services_to (vcaps)
if (any) this._sources.push ('process.env.VCAP_SERVICES')
} catch(e) {
throw new Error ('[cds.env] - failed to parse VCAP_SERVICES:\n '+ e.message)
}
}
_add_cloud_service_bindings({ VCAP_SERVICES, VCAP_SERVICES_FILE_PATH, SERVICE_BINDING_ROOT }) {
let bindings, bindingsSource
if (!this.requires) return
if (this.features?.vcaps === false) return
if (VCAP_SERVICES_FILE_PATH) {
try {
bindings = JSON.parse (fs.readFileSync(VCAP_SERVICES_FILE_PATH,'utf-8'))
bindingsSource = VCAP_SERVICES_FILE_PATH
} catch(e) {
throw new Error ('[cds.env] - failed to read/parse VCAP_SERVICES_FILE_PATH', {cause: e})
}
}
else if (VCAP_SERVICES) {
try {
bindings = JSON.parse(VCAP_SERVICES)
bindingsSource = 'process.env.VCAP_SERVICES'
} catch(e) {
throw new Error ('[cds.env] - failed to parse VCAP_SERVICES', {cause: e})
}
}
if (!bindings && SERVICE_BINDING_ROOT) {
bindings = require('./serviceBindings')(SERVICE_BINDING_ROOT)
bindingsSource = SERVICE_BINDING_ROOT
}
if (bindings) {
try {
const any = this._add_vcap_services_to(bindings)
if (any) this._sources.push(bindingsSource)
} catch(e) {
throw new Error(`[cds.env] - failed to add service bindings from ${bindingsSource}`, {cause: e});
}
}
}
/**
* Build VCAP_SERVICES for compatibility (for example for CloudSDK) or for running
* locally with credentials (hybrid mode).
*/
_emulate_vcap_services() {
const vcap_services = {}, names = new Set()
for (const service in this.requires) {
let { vcap, credentials, binding } = this.requires[service]
// "binding.vcap" is chosen over "vcap" because it is meta data resolved from the real service (-> cds bind)
if (binding && binding.vcap) vcap = binding.vcap
if (vcap && vcap.label && credentials && Object.keys(credentials).length > 0) {
// Only one entry for a (instance) name. Generate name from label and plan if not given.
const { label, plan } = vcap
const name = vcap.name || `instance:${label}:${plan || ""}`
if (names.has(name)) continue
names.add(name)
if (!vcap_services[label]) vcap_services[label] = []
vcap_services[label].push(Object.assign({ name }, vcap, { credentials }))
}
}
process.env.VCAP_SERVICES = JSON.stringify(vcap_services)
}
//////////////////////////////////////////////////////////////////////////
//
// FORBIDDEN ZONE!
// The following are hacks for tests which should not exist!
// Tests should test public APIs, not internal ones.
// Tests should even less intrude hacks to core components
//
// FOR TESTS ONLY! --> PLEASE: tests should test public APIs (only)
_for_tests (...conf) {
const env = new Config('cds')
this._for_tests.vcaps = (vcaps) => { env._add_vcap_services_to (vcaps)}
// merge all configs, then resolve profiles (same as in 'for' function above)
for (let c of [...conf].reverse()) _merge(env, c, env._profiles)
return env
}
// FOR TESTS ONLY! --> PLEASE: tests should test public APIs (only)
_merge_with (src) {
_merge (this, src, this._profiles)
return this
}
// API for binding resolution in @sap/cds-dk
_find_credentials_for_required_service(service, conf, vcaps) {
return conf.vcap && _fetch (conf.vcap) || //> alternatives, e.g. { name:'foo', tag:'foo' }
_fetch ({ name: service }) ||
_fetch ({ tag: this._context+':'+service }) ||
_fetch ({ tag: conf.dialect || conf.kind }) || // important for hanatrial, labeled 'hanatrial', tagged 'hana'
_fetch ({ label: conf.dialect || conf.kind }) ||
_fetch ({ type: conf.dialect || conf.kind })
function _fetch (predicate) {
const filters = []
for (let k in predicate) {
const v = predicate[k]; if (!v) continue
const filter = k === 'tag' ? e => _array(e,'tags').includes(v) : e => e[k] === v
filters.push(filter)
}
if (filters.length === 0) return false
for (let stype in vcaps) {
const found = _array(vcaps,stype).find(e => filters.every(f => f(e)))
if (found) return found
}
}
function _array(o,p) {
const v = o[p]
if (!v) return []
if (Array.isArray(v)) return v
throw new Error(`Expected VCAP entry '${p}' to be an array, but was: ${require('util').inspect(vcaps)}`)
}
}
_add_vcap_services_to (vcaps={}) {
let any
for (let service in this.requires) {
const conf = this.requires [service]
if (!conf) continue
const { credentials } = this._find_credentials_for_required_service(service, conf, vcaps) || {}
if (credentials) {
// Merge `credentials`. Needed because some app-defined things like `credentials.destination` must survive.
if (conf === true) any = this.requires[service] = { credentials }
else any = conf.credentials = { ...conf.credentials, ...credentials }
}
}
return !!any
}
}
//////////////////////////////////////////////////////////////////////////
//
// Local Helpers...
//
/**
* Allows to set profiles in package.json or .cdsrc.json like so:
* ```json
* { "cds": { "profiles": ["mtx-sidecar","java"] } }
* { "cds": { "profile": "mtx-sidecar" } }
* ```
*/
function _add_static_profiles (_home, profiles) {
for (let src of ['package.json', '.cdsrc.json']) try {
const conf = require(path.join(_home,src))
const cds = src === 'package.json' ? conf.cds : conf.cds||conf
if (cds?.profiles) return profiles.push(...cds.profiles)
if (cds?.profile) return profiles.push(cds.profile)
} catch (e) { if (e.code !== 'MODULE_NOT_FOUND') throw e }
}
/**
* @returns {Config} dst
*/
function _merge (dst, src, _profiles) {
const profiled = [], descr = Object.getOwnPropertyDescriptors(src)
for (let p in descr) {
const pd = descr[p]
if ('get' in pd || !pd.enumerable) {
Object.defineProperty(dst,p,pd)
continue
}
if (_profiles && p[0] === '[') {
const important = p.endsWith('!]')
const profile = p.slice (1, important ? -2 : -1)
if (_profiles._defined) _profiles._defined.add (profile)
if (_profiles.has(profile)) {
let o = src[p]; if (typeof o !== 'object') continue
let merge = () => _merge (dst, o, _profiles, false)
if (important && _profiles._important) _profiles._important.push(merge)
else profiled.push ({ profile, merge })
}
continue
}
const v = pd.value
if (typeof v === 'object' && !Array.isArray(v) && v != null) {
if (!dst[p]) dst[p] = {}
if (typeof dst[p] !== 'object') dst[p] = v
else _merge (dst[p], v, _profiles)
continue
}
else if (typeof v === 'string' && typeof dst[p] === 'object' && dst[p]?.kind) {
dst[p].kind = v // requires.db = 'foo' -> requires.db.kind = 'foo'
}
else if (v !== undefined) dst[p] = v
}
if (profiled.length > 0 && !_profiles.has('production')) {
const profiles = Array.from(_profiles)
profiled.sort((a,b) => profiles.indexOf(b.profile) - profiles.indexOf(a.profile))
}
for (let each of profiled) each.merge()
return dst
}
function _value4 (val) {
if (val && val[0] === '{') try { return JSON.parse(val) } catch {/* ignored */}
if (val && val[0] === '[') try { return JSON.parse(val) } catch {/* ignored */}
if (val === 'true') return true
if (val === 'false') return false
if (!isNaN(val)) return parseFloat(val)
return val
}
function _readJson (file) {
if (isfile(file)) {
if (file.endsWith('.js')) return require (file)
try { return JSON.parse (fs.readFileSync(file,'utf-8')) } catch (e) { console.error(e) }
}
}
function _readYaml (file) {
if (isfile(file)) {
const YAML = _readYaml.parser ??= require('js-yaml')
return YAML.load (fs.readFileSync(file,'utf-8'))
}
}
function _readEnv (file) {
if (isfile(file)) {
const ENV = _readEnv.parser ??= require('../compile/etc/properties')
return ENV.parse (fs.readFileSync(file,'utf-8'))
}
}
function _readFromDir (p) {
if (isdir(p)) {
const result = {}
for (const dirent of fs.readdirSync(p)) result[dirent] = _readFromDir(path.join(p, dirent))
return result
}
return _value4(fs.readFileSync(p, "utf-8"))
}
// REVISIT: We need to get rid of such hard-coded stuff
function _ext_package_json (pkg) { // fill cds.extends from .extends
let cds = pkg.cds
if (pkg.extends) (cds??={}).extends = pkg.extends
return cds
}
/** @type Config & typeof DEFAULTS */
module.exports = Config.prototype
/* eslint no-console:0 */