@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
273 lines (259 loc) • 14.7 kB
JavaScript
const cds = require('../cds')
const { join, resolve, basename } = require('path')
const { fs, exists, isfile, isdir, path } = cds.utils
const { parseXml } = require('./xml')
const { REGEX_JAVA_VERSION, JAVA_LTS_VERSIONS } = require('./constants')
const TRACE = cds.debug('trace')
module.exports = new class ProjectReader {
constructor() {
this.readProject = this.readProject.bind(this)
}
#setPackageInfo() {
this.isJava = (exists('pom.xml') || cds.cli.options?.add?.has('java'))
&& !cds.env.for('cds').profiles.includes('node') // opt-out for hybrid projects like sflight
// 1. Use project name and static default values
this.appVersion = this.isJava ? '1.0.0-SNAPSHOT' : '1.0.0'
this.appName = this.appId = basename(cds.root)
this.appDescription = 'A simple CAP project.'
if (this.isJava) {
let artifactId, description, version, properties
try {
({ artifactId, description, version, properties } = parseXml(resolve(cds.root, 'pom.xml')) ?? {})
} catch (e) {
if (e.code !== 'ENOENT') throw e
}
const v = version?.[0] === '${revision}' ? properties?.[0]?.revision[0] : version?.[0] ?? '1.0.0-SNAPSHOT'
if (v && REGEX_JAVA_VERSION.test(v)) this.appVersion = v
this.jdkVersion = properties?.[0]?.['jdk.version']?.[0]?._text?.[0] ?? JAVA_LTS_VERSIONS[JAVA_LTS_VERSIONS.length - 1]
if (artifactId?.[0]) this.appName = this.appId = artifactId?.[0]?.split(/-parent/)[0]
if (description?.[0]) this.appDescription = description[0]
}
else if (exists('package.json')) {
const { name, version, description } = JSON.parse(fs.readFileSync(join(cds.root, 'package.json')))
this.appVersion = version ?? this.appVersion
const segments = (name ?? this.appName).trim().replace(/@/g, '').split('/').map(encodeURIComponent)
this.appName = segments[segments.length - 1]
this.appId = segments.join('.')
this.appDescription = description ?? this.appDescription
}
}
/**
* Returns cds.env using 'production' profile by default as mta deployment is executed with having production profile set.
*/
env4(profile = 'production') {
const cdsEnv = process.env.CDS_ENV
process.env.CDS_ENV = profile
try {
return cds.env.for('cds')
} finally {
cdsEnv ? process.env.CDS_ENV = cdsEnv : delete process.env.CDS_ENV
}
}
/**
* @typedef {Object} ProjectConfig
* @property {string} appVersion The version of the application.
* @property {string} appName The name of the application.
* @property {string} appId The ID of the application.
* @property {string} appDescription The description of the application.
* @property {'java' | 'nodejs'} language The programming language of the project.
* @property {string} srvPath The path to the server module.
* @property {string} archiveName The name of the archive.
* @property {Object} db Database configuration.
* @property {string} configFile The name of the configuration file. This is typically the `package.json` for Node.js and `.cdsrc.json` for Java.
* @property {string} appPath The path to the app folder.
* @property {string} approuterPath The path to the approuter folder.
* @property {boolean} hasUIEmbedded Indicates if the project uses an embedded UI.
* @property {boolean} hasUIModule Indicates if the project is using a modular UI.
* @property {boolean} hasUI Indicates if the project has a UI.
* @property {boolean} hasUI5 Indicates if the project is a UI5 project.
* @property {boolean} isUI5 Indicates if the project is a UI5 project.
* @property {boolean} isJava Indicates if the project is using Java.
* @property {boolean} isNodejs Indicates if the project is using Node.js.
* @property {boolean} isESM Indicates if the project is an ESM project (type 'module')
* @property {boolean} hasEsmStyle Indicates if code supports ESM-style imports and exports. This is true for ESM and Typescript projects.
* @property {boolean} isTypescript Indicates if the project is a Typescript project.
* @property {boolean} hasTyper Indicates if code supports CDS typer.
* @property {boolean} hasHelmUnifiedRuntime Indicates if the project uses Unified Runtime Helm deployment.
* @property {boolean} hasHelm Indicates if the project uses CAP Helm deployment.
* @property {boolean} hasMta Indicates if the project uses MTA deployment.
* @property {boolean} hasConnectivity Indicates if the project uses connectivity.
* @property {boolean} hasMultitenancy Indicates if the project is multitenant.
* @property {boolean} hasToggles Indicates if the project uses feature toggles.
* @property {boolean} hasExtensibility Indicates if the project is extensible.
* @property {boolean} hasXsuaa Indicates if the project uses XSUAA.
* @property {boolean} hasHana Indicates if the project uses HANA.
* @property {boolean} hasPostgres Indicates if the project uses PostgreSQL.
* @property {boolean} hasEnterpriseMessaging Indicates if the project uses SAP BTP Event Mesh.
* @property {boolean} hasAttachments Indicates if the project uses SAP BTP Object Store Service.
* @property {boolean} hasApprouter Indicates if the project uses the SAP Application Router.
* @property {boolean} hasHtml5Repo Indicates if the project uses the SAP BTP HTML5 Application Repository.
* @property {boolean} hasPortal Indicates if the project uses the SAP Cloud Portal service.
* @property {boolean} hasWorkzoneStandard Indicates if the project uses the SAP Build Work Zone, Standard Edition service.
* @property {boolean} hasDestination Indicates if the project uses the SAP BTP Destination service.
* @property {boolean} hasMTXRoute Indicates if the approuter should have a multitenant route.
* @property {boolean} hasMTXRouteJava Indicates if the approuter should have a multitenant subscription route (java).
* @property {boolean} hasMalwareScanner Indicates if the project uses the SAP Malware Scanning service.
* @property {boolean} hasDynatrace Indicates if the project uses Dynatrace.
* @property {boolean} hasCloudLogging Indicates if the project uses Cloud Logging.
* @property {String} extendsApp Indicates which project is extended if any.
*/
/**
* Reads the project configuration and constructs a project object.
* This function dynamically resolves project properties and returns an object with these properties.
*
* @returns {ProjectConfig} An object representing the project configuration.
*/
readProject() {
if (!this.appName) this.#setPackageInfo()
const env = this.env4('production')
const { appVersion, appName, appId, appDescription, jdkVersion } = this
TRACE?.({ env })
const pkgJson = () => exists('package.json') ? JSON.parse(fs.readFileSync(join(cds.root, 'package.json'))) : {}
const _isESM = () => pkgJson().type === 'module'
const _isTypescript = () => !!pkgJson().dependencies?.typescript || !!pkgJson().devDependencies?.typescript
const _hasTyper = () => !!pkgJson().dependencies?.['@cap-js/cds-typer'] || !!pkgJson().devDependencies?.['@cap-js/cds-typer']
const _inProd = plugin => require(`./template/${plugin}`).hasInProduction(env)
const _ui5 = () => pkgJson().sapux
const _uiEmbedded = () => exists(join(env.folders.app, 'xs-app.json'))
const _uiModule = () => !_uiEmbedded() && isdir(env.folders.app) && fs.readdirSync(resolve(cds.root, env.folders.app)).length > 0
const _isJava = () => this.isJava
const _extends = () => pkgJson().extends
const isApp = name => {
return name !== 'appconfig' && !name.match(/^[._]/) && name !== 'router' && name !== 'portal' && name !== 'workzone'
}
const reserved = {
dependencies: () => pkgJson().dependencies,
has: x => _inProd(x),
profile: () => cds.cli.options?.for,
language: () => _isJava() ? 'java' : 'nodejs',
isJava: () => _isJava(),
isNodejs: () => !_isJava(),
isTypescript: () => _isTypescript(),
isESM: () => _isESM,
hasEsmStyle: () => _isTypescript() || _isESM(),
hasTyper: () => _hasTyper(),
srvPath: () => join(env.build.target, env.folders.srv.replace(/\/$/, '')).replace(/\\/g, '/'),
archiveName: () => {
const pomXmlPath = resolve(cds.root, env.folders.srv, 'pom.xml')
const pom = exists(pomXmlPath) ? parseXml(pomXmlPath) : {}
const { artifactId = [basename(cds.root)], packaging = ['jar'] } = pom
return artifactId[0] + '-exec.' + packaging[0]
},
db: () => {
const folder = env.folders.db
const name = folder.replace(/\/$/, '')
const path = join(env.build.target, name).replace(/\\/g, '/')
return { folder, name, path }
},
configFile: () => _isJava() ? '.cdsrc.json' : 'package.json',
apps: () => {
const appPath = path.resolve(cds.root, cds.env.folders.app)
if (!exists(appPath)) return []
const apps = fs.readdirSync(appPath).filter(e =>
isdir(path.join(cds.root, cds.env.folders.app, e)) && isApp(e)
)
return apps.map((app, i) => {
const manifestPath = path.resolve(cds.root, cds.env.folders.app, app, 'webapp/manifest.json')
const manifest = exists(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) : {}
const inbounds = manifest?.['sap.app']?.crossNavigation?.inbounds
const [firstInbound] = Object.values(inbounds ?? { intent: { semanticObject: 'Books', action: 'display' }})
const vizId = firstInbound.semanticObject + '-' + firstInbound.action
return { app, strippedApp: app.replace(/-/g, ''), vizId, isNotLast: i < apps.length - 1, manifestAppId: manifest?.['sap.app']?.id ?? app}
})
},
appUIPaths: () => {
const appPath = path.resolve(cds.root, cds.env.folders.app)
if (!exists(appPath)) return []
return fs.readdirSync(appPath).filter(e =>
isdir(path.join(cds.root, cds.env.folders.app, e)) && isApp(e)
)
},
hasIndexHtml: () => {},
appPath: () => env.folders.app,
appVersion: () => appVersion,
jdkVersion: () => jdkVersion,
approuterPath: () => (_uiEmbedded() ? join(env.folders.app) : join(env.folders.app, 'router')).replace(/\\/g, '/'),
appName: () => appName,
cleanedAppName: () => appName.replaceAll('_', '-'),
strippedAppName: () => appName.replace(/-/g, ''),
appId: () => appId,
appDescription: () => appDescription,
hasUIEmbedded: _uiEmbedded,
hasUIModule: _uiModule,
hasUI: () => _uiEmbedded() || _uiModule(),
isUI5: _ui5,
hasUI5: _ui5,
hasMTXRoute: () => _inProd('extensibility') || (_inProd('multitenancy') && !_isJava()),
hasMTXRouteJava: () => (_inProd('helm-unified-runtime') || _inProd('helm')) && _isJava() && _inProd('multitenancy'),
imageRegistry: () => exists('chart/values.yaml') ? cds.load.yaml(join(cds.root, 'chart/values.yaml')).global?.image?.registry ?? '<your-container-registry>' : '<your-container-registry>',
extendsApp: _extends,
}
// Automatically creates availability checks for `cds add` commands.
// Maps to the `hasInProduction` implemented in the command.
// E.g. `cds add hana` can be checked using `hasHana` or `isHana`
const project = (() => {
const defined = {}
const _get = property => { const p = property
if (p in reserved) return reserved[p]()
for (const prefix of ['has', 'is']) if (p.startsWith(prefix) && p.length > prefix.length) {
return _inProd(p.slice(prefix.length).replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase())
}
}
return new Proxy({}, {
get(_, p) { return _get(p) ?? defined[p] },
set(_, p, value) { defined[p] = value; return true },
has(_, p) { return _get(p) ?? p in defined }
})
})()
return project
}
}
/**
* Finds and reads SpringBoot configuration in `cds.root/srv` dir.
* This is done in a best-effort way. In no way can we replicate all of SpringBoot's
* config variants.
* @param {string[]?} roots array of directories to search for application.yaml, like `srv`
* @returns {Promise<object[]>} array of objects with `cds` property
*/
module.exports._readSpringBootConfig = async function (roots=[cds.env.folders.srv]) {
const configNodes = []
const rootDirs = roots.map(d => join(cds.root, d))
for (let r of rootDirs) {
const file = isfile (join (r,'./src/main/resources/application.yaml'))
if (file) {
const yaml = cds.load.yaml(file)
for (let yamlDoc of Array.isArray(yaml) ? yaml : [yaml]) {
let cds = yamlDoc?.cds;
if (!cds) continue
cds = _normalizeSpringBootCfg(cds)
configNodes.push({cds})
}
}
}
return configNodes
}
/**
* Normalize SpringBoot's dots in keys to express nested objects
*
* @example: `cds.foo.bar = 1` --> `cds: { foo: { bar: 1 } }`
* @param {object} obj
* @returns {object}
*/
function _normalizeSpringBootCfg(obj) {
if (typeof obj !== 'object') return obj
Object.keys(obj).forEach(k => {
const prop = k.split('.')
const last = prop.pop()
// and define the object if not already defined
const res = prop.reduce((o, key) => {
// define the object if not defined and return
return o[key] = o[key] ?? {}
}, obj)
res[last] = obj[k]
// recursively normalize
_normalizeSpringBootCfg(obj[k])
// delete the original property from object if it was rewritten
if (prop.length) delete obj[k]
})
return obj
}