@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
605 lines (534 loc) • 21.4 kB
JavaScript
/* eslint-disable no-prototype-builtins */
;
/** @type { import('@sap/cds') } */
const cds = require('../cds');
let convert = require('xml-js');
let axios = require('axios');
const messages = require("./message").getMessages();
const { fs, path, write, mkdirp, exists, read } = cds.utils;
const cdsCompiler = require('..').compile.to;
const md5 = data => require('crypto').createHash('md5').update(data).digest('hex');
const { info } = require("../util/term")
let optionsMapping = {
'keep-namespace': 'keepNamespace',
'include-namespaces': 'includeNamespaces',
'no-copy': 'no_copy',
'no-save': 'no_save'
};
let apiOptions = ['includeNamespaces', 'keepNamespace'];
let commonOptions = ['for', 'no_copy', 'no_save', 'out', 'dry', 'as', 'from', 'force', 'config', 'destination', 'group', 'name'];
let usingStatements = [];
async function generateEDMX2JSON(edmx) {
let cjson = convert.xml2json(edmx, {
compact: true,
spaces: 4
});
return JSON.parse(cjson);
}
async function _isValidXML(edmx) {
let isValid = false;
try {
generateEDMX2JSON(edmx);
isValid = true;
} catch (err) {
isValid = false;
}
return isValid;
}
async function _getODataVersion(edmx) {
let oDataVersion = "";
let checkForV2 = edmx.match(/m:DataServiceVersion="[^"]+"/);
if (checkForV2) {
oDataVersion = checkForV2[0].split('"')[1];
return oDataVersion;
}
let edmxMatching = edmx.match(/<edmx:Edmx[^>]+"/);
if (edmxMatching) {
let checkForV4 = edmxMatching[0].match(/Version="[^"]+"/);
oDataVersion = checkForV4[0].split('"')[1];
}
return oDataVersion;
}
// method to find the version, input is in XML format
async function getVersion(fileInputStream){
let oDataVersion;
const version = await _getODataVersion(fileInputStream);
if (version === '1.0' || version === '2.0') {
oDataVersion = 'V2';
} else if (version === '4.0' || version === "4.01") {
oDataVersion = 'V4';
}
else {
throw new Error(messages.INVALID_ODATA_VERSION_OR_UNSUPPORTED_ENCODING);
}
return oDataVersion;
}
function checkForEmptyKeys(element, oDataVersion) {
if (oDataVersion === "V2") {
return (element.includes("cds.Association") || element.includes("cds.Composition") && !element.includes(['"on":']));
}
else {
return (['cds.Association', 'cds.Composition'].includes(element.type)) && !element.on && !element.keys;
}
}
// to replace the alias value with original schema namespace value
function replaceAliasValue(schemaContent, schemaNamespaceValue, aliasValue) {
const re = RegExp(`([("/])${aliasValue}([/").])`, 'g');
const val = `$1${schemaNamespaceValue}$2`;
schemaContent = schemaContent.replace(re, val);
return JSON.parse(schemaContent);
}
async function getContentFromFile(path) {
let fileContent;
fileContent = await read(path, "utf-8");
return fileContent;
}
async function getContentFromURL(url) {
let edmx;
const response = await axios.get(url);
edmx = response["data"];
if (edmx && (await _isValidXML(edmx))) {
return edmx;
} else {
throw new Error(messages.INVALID_EDMX_METADATA);
}
}
function _getSuffix(outputFormat) {
const suffix = {
edmx: 'xml', "edmx-v2": 'xml', "edmx-v4": 'xml', "edmx-w4": 'xml',
"edmx-x4": 'xml', openapi: 'openapi3.json', sql: 'sql', edm: 'json', "cdl": "cds"
};
// find the output file extension based on the target conversion selected
return (outputFormat) ? (suffix[outputFormat] || outputFormat) : 'csn';
}
function _supportedAsFormats() {
return ["cds", "csn", "json"];
}
function _supportedFileExtensions() {
return ["edmx", "xml", "json"];
}
function _getInputFileExtension(inputFile) {
let startIndex = inputFile.lastIndexOf(".");
return inputFile.substring(startIndex + 1);
}
async function isValidInputFile(inputFileLoc) {
const fileResolved = await fs.isfile(inputFileLoc);
if (!fileResolved) throw new Error(messages.SPECIFY_INPUT_FILE);
let fileExtension = _getInputFileExtension(inputFileLoc);
if (_supportedFileExtensions().includes(fileExtension)) return fileResolved;
else throw new Error(messages.INVALID_INPUT_FILE);
}
async function _copyToSrvExternal(file, dst, cwd) {
const dstDir = path.dirname(dst), srcDir = path.resolve(path.dirname(file));
if (!file.startsWith(dstDir + path.sep)) {
await mkdirp(dstDir);
const copyOrMove = srcDir === cwd || srcDir === process.cwd() ? fs.renameSync : fs.copyFileSync;
try {
copyOrMove(file, dst);
} catch (err) {
// cross-device link error (might happen in docker containers), fall back to copy and delete
// see cap/issues#18004
if (err.code === 'EXDEV') {
fs.copyFileSync(file, dst);
fs.unlinkSync(file);
} else {
throw err;
}
}
return dst;
}
return file;
}
async function preProcess(file, options, cwd) {
let srcFilePath = await isValidInputFile(path.resolve(cwd, file));
if (options.as && !_supportedAsFormats().includes(options.as))
throw new Error(messages.INVALID_AS_OPTION);
return srcFilePath;
}
async function processOptions(cliOptions, options) {
// maps to renaming of variables
for (let [key, value] of Object.entries(optionsMapping)) {
if (cliOptions[key]) {
cliOptions[value] = cliOptions[key];
delete cliOptions[key];
}
}
// segregating cli/odata options
apiOptions.forEach(function (item) {
if (cliOptions[item]) {
options[item] = cliOptions[item];
delete cliOptions[item];
}
});
// forward cli options to api options
commonOptions.forEach(function (item) {
if (cliOptions[item]) {
options[item] = cliOptions[item];
}
});
// reads cli options from environment
readEnvVariables(cliOptions, true);
}
function _getServiceNames(csn) {
let serviceList = [];
for (let each in csn.definitions) {
if (csn.definitions[each].kind === 'service') serviceList.push(each);
}
return serviceList;
}
function _validateAndComputeOutputFilePath(outputFileLoc, as, destFilePath) {
let extension, expectedExtension, supportedExtension;
if (typeof (outputFileLoc) !== 'boolean') {
extension = path.extname(outputFileLoc).substring(1);
expectedExtension = (as) ? _getSuffix(as) : "csn";
supportedExtension = extension ? _supportedAsFormats().includes(extension) : false;
}
if (outputFileLoc === true || // if output file is not mentioned
outputFileLoc[0] === "-" || // if output file is missing and "--as" is considered as output file
outputFileLoc.trim() === "") {
throw new Error(messages.SPECIFY_OUTPUT_FILE);
} else if (extension && !supportedExtension) {
// extension not supported, eg., "test.abs", "./sv/external.test.abcs", "./a."
throw new Error(messages.INVALID_OUTPUT_FILE);
} else if (extension && !as && extension != "json" && extension != "csn") {
// --as option is not provided but output file is like: "test.cds", "./srv/external/test.cds"
throw new Error(messages.INCORRECT_EXTENSION);
} else if (extension && as && extension != expectedExtension) {
// extension not supported by --as option, e.g., "cds import <file> --out test.yml --as cds"
throw new Error(messages.OUTPUT_FILE_MISMATCH);
} else if (supportedExtension || (!outputFileLoc.endsWith("\\") && !outputFileLoc.endsWith("/"))) {
// eg., "./a.csn", "tst.cds", "./srv/external/test", "test", "c:\windows\output\test"
return outputFileLoc;
} else if (outputFileLoc.endsWith("/") || outputFileLoc.endsWith("\\")) {
// File name not specified ("./srv/external/", "c:\windows\output\test\")
return outputFileLoc + path.parse(destFilePath).name;
}
}
async function _generateChecksumValidate(dest, output, force) {
let fileExists = await exists(dest);
// force flag disabled and cds file already exists, then throw error
if (!force && fileExists) {
throw new Error(messages.FILE_MODIFIED);
}
const currentChecksum = md5(output);
let existingFileContent, existingFileContentWithoutChecksum;
let existingFileContentChecksum, existingChecksumValue;
if (fileExists) {
existingFileContent = await read(dest, "utf-8");
const checksumStartIndex = existingFileContent.indexOf(`/* checksum : `);
const checksumEndIndex = existingFileContent.indexOf(" */");
if (checksumStartIndex >= 0) {
existingFileContentWithoutChecksum = existingFileContent.substring(
checksumEndIndex + 4
);
existingChecksumValue = existingFileContent.substring(
checksumStartIndex + 14, checksumEndIndex
);
existingFileContentChecksum = md5(existingFileContentWithoutChecksum);
}
}
// if force flag enabled
if (force) {
// current and existing file content are same, and the existing
// checksum is not modified, then return the existing file content
if (currentChecksum == existingFileContentChecksum &&
currentChecksum == existingChecksumValue) {
return existingFileContent;
}
}
/**
* 1. current and existing checksum are different.
* 2. checksum is missing in the existing file:
* a. existing and current file content are same.
* b. existing and current file content are different.
* 3. cds file doesn't exist.
* 4. checksum of the existing file is modified.
*/
return "/* checksum : " + currentChecksum + " */\n" + output;
}
// Modify the output to add using statements with the dependency services
function _addUsingStatementsForDependent(output) {
if (usingStatements.length === 0)
return output;
let usings = usingStatements.join('\n') + '\n';
usingStatements = [];
return usings + output;
}
async function _getResult(output, as, dest, force, kind, isDependent) {
let extension = dest.substring(dest.lastIndexOf(".") + 1);
as = (as === "cds") ? "cdl" : as;
// output filepath should simply contain .csn as extension
if (kind === 'rest' && ['swagger', 'openapi3'].includes(extension)) {
dest = dest.replace('.' + extension, '');
}
if (!_supportedAsFormats().includes(extension)) {
// dest doesn't have any file extension, so get it
extension = _getSuffix(as);
dest += '.' + extension;
}
if (as && as !== "csn" && as !== "json") {
// cdsCompiler[as] will convert the csn output into various output formats
output = cdsCompiler[as](output);
if (isDependent && kind === 'V4') {
output = _addUsingStatementsForDependent(output);
}
}
switch (as) {
case "cdl":
output = await _generateChecksumValidate(dest, output, force);
break;
case undefined:
case "csn":
case "json":
output = JSON.stringify(output, null, ' ');
break;
default:
break;
}
return [output, dest];
}
function _printUsageHint(dest, service, cwd) {
const extension = dest.substring(dest.lastIndexOf('.'));
dest = dest.replace(extension, '');
let using
if (service.length > 1){
using = `using { ${service.join(', ')}} from './${path.relative(cds.env.folders.srv, dest).replace(/\\/g, '/')}'`;
} else {
using = `using { ${service} as external } from './${path.relative(cds.env.folders.srv, dest).replace(/\\/g, '/')}'`;
}
const message = `\n[cds] - imported API to ${path.relative(cwd, dest)}
> use it in your CDS models through the like of:
${require('../util/term').info(using)}
`;
console.log(message);
}
async function _updateNodeConfig(dest, services, kind, cwd, options) {
let config
if (typeof options.config === 'string') {
if (options.config.trim().startsWith('{')) {
config = JSON.parse(options.config.trim());
} else {
config = {};
options.config.trim().split(',').forEach(kv => {
const [key, val] = kv.split('=');
config[key] = val;
})
normalizeToDeepObject(config);
}
}
// try to find package.json in the srv folder
let package_json = _findPackageJson(path.resolve(cwd, dest), cwd);
let pkgConf = {};
if (package_json) {
pkgConf = JSON.parse(await read(package_json, "utf-8"));
} else {
package_json = path.resolve(cwd, 'package.json');
}
const requires = ['cds', 'requires'].reduce((p, n) => p[n] || (p[n] = {}), pkgConf);
if (kind !== 'rest' && kind !== 'rfc') {
kind = (kind === "V2") ? 'odata-v2' : 'odata';
}
// add destination configuration
if (!config && options.destination) {
const profile = options.for || 'production';
config = { [`[${profile}]`]: { credentials: { destination: options.destination } }};
}
let package_json_updated = false;
for (let service of services) {
// existing service configuration data can be updated, e.g. in case of delta imports
const model = path.relative(cwd, dest).replace(/\\/g, '/'); // cds env must not be platform specific
let serviceConfig = { kind, model, ...config }
if (options.for) serviceConfig = { [`[${options.for}]`]: serviceConfig };
if (!requires[service]) {
cds.env.requires[service] = requires[service] = serviceConfig;
package_json_updated = true;
} else {
if (JSON.stringify(serviceConfig) !== JSON.stringify(requires[service])) {
console.warn(`Service '${service}' already exists in ./package.json with a different configuration, skipping update.\n`);
}
}
}
if (kind.startsWith('odata')) {
pkgConf.dependencies ??= {};
const { dependencies } = require('./odata.package.json');
for (const dep in dependencies) {
if (!pkgConf.dependencies?.[dep]) {
pkgConf.dependencies[dep] = dependencies[dep];
package_json_updated = true;
}
}
}
if (package_json_updated) {
await write(package_json, JSON.stringify(pkgConf, null, ' '));
console.log(`[cds] - updated ./package.json`);
}
}
async function _updateJavaConfig(kind, serviceNames, csn, options={}) {
const mvn = require('../init/mvn')
for (let name of serviceNames) {
let type = kind
if (!options.from || options.from === 'edmx') type = `odata-${kind.toLowerCase()}`
const javaOptions = {
name, type,
'destination.name': options.destination ?? name,
}
// if the name is not in the model, set the first/only service there as 'model', assuming only one was imported
if (!csn.definitions[name]) {
const srv = cds.reflect(csn).find('service')
if (srv) javaOptions['service.model'] = srv.name
}
// normalize config object into our flat list of key-value pairs
if (typeof options.config === 'string') {
if (options.config.trim().startsWith('{')) {
const cfg = JSON.parse(options.config)
_flattenConfigInto(cfg, javaOptions)
} else {
options.config.trim().split(',').forEach(kv => {
const [key, val] = kv.split('=')
javaOptions[key] = val
})
}
}
await mvn.add('remote-service', javaOptions)
}
}
function _findPackageJson(dir, cwd) {
const packageJsonPath = path.join(dir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
return packageJsonPath;
}
if (dir === cwd) {
return null;
}
const parentDir = path.dirname(dir);
if (parentDir === dir) {
return null;
}
return _findPackageJson(parentDir, cwd);
}
async function postProcess(filePath, options, csn, cwd) {
const services = _getServiceNames(csn);
const out = options.out || path.join(cds.env.folders.srv, 'external');
let destFilePath = path.resolve(cwd, path.join(out, path.basename(filePath)));
if (options.out) {
options.out = _validateAndComputeOutputFilePath(options.out, options.as, destFilePath);
if (options.isDependency || options.isDependent) {
// output is a dirname in case of import with dependencies, then append the filename to it
let fileName = path.basename(filePath);
let index = fileName.lastIndexOf(".");
options.out = path.join(options.out, fileName.substring(0, index));
}
}
const kind = options.inputFileKind;
let result;
try {
result = await _getResult(csn, options.as, options.out || destFilePath.replace(/\.[^.]+$/, ''), options.force, kind, options.isDependent);
} catch (error) {
if (options.isDependency && error.message === messages.FILE_MODIFIED) {
return console.log(info(`[cds] - skipping import of ${path.basename(filePath)} as it is already imported`));
} else {
throw error;
}
}
const [serviceCsn, csnFile] = result;
// destFilePath might be wrongly computed and contain the csn file name in the path. Fix it here.
destFilePath = destFilePath.replace(csnFile, path.dirname(csnFile));
if (!options.dry && !options.no_save) {
if (cds.env['project-nature'] === 'java') {
await _updateJavaConfig(kind, services, csn, options)
} else {
await _updateNodeConfig(result[1].replace(/\.[^.]+$/, ''), services, kind, cwd, options);
}
}
if (!options.dry)
await write(csnFile, serviceCsn, "utf-8");
if (options.isDependency) {
// `cdsc.to.cdl() adds `using { foo.bar }` on-demand. We only need to include correct path.
// TODO: Should be part of `csn.requires`, and let ´to.cdl()` render them.
usingStatements.push(`using from './${path.basename(csnFile.replace(`'`, `''`))}';`);
}
_printUsageHint(csnFile, services, cwd);
if (!options.dry && !options.no_copy)
await _copyToSrvExternal(filePath, destFilePath, cwd);
if (options.dry)
return console.log(result[0]);
}
function _flattenConfigInto (config, target, prefix='') {
for (let each in config) {
if (typeof config[each] === 'object') _flattenConfigInto (config[each], target, prefix + each + '.')
else target[prefix + each] = config[each]
}
return target
}
/**
* Normalizes an dot-separated keys in the object to a deep structure.
* Example is the SpringBoot config that allows dots in keys to express nested objects.
*/
function normalizeToDeepObject(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
normalizeToDeepObject(obj[k])
// delete the original property from object if it was rewritten
if (prop.length) delete obj[k]
})
return obj
}
async function identifyFile(filepath) {
let extension = _getInputFileExtension(filepath);
if (["edmx", "xml"].includes(extension)) {
return "edmx";
} else if (["json", "yaml", "yml"].includes(extension)) {
// based on the file content determine if it is openapi or asyncapi file and return the correct type
filepath = await isValidInputFile(path.resolve(process.cwd(), filepath));
const src = await getContentFromFile(filepath);
// for openapi
let jsonSrc = JSON.parse(src);
if (jsonSrc.hasOwnProperty('openapi') || jsonSrc.hasOwnProperty('swagger')) {
return "openapi";
} else if (jsonSrc.hasOwnProperty('asyncapi')) {
return "asyncapi";
}
} else
return extension;
}
function readEnvVariables(options, cliCheck) {
if (cds.env.import) {
if (cliCheck) {
for (let key of commonOptions) {
if (key !== 'force' && !options[key]) {
options[key] = cds.env.import[key];
}
}
if (options.as && !options.force) options.force = cds.env.import.force;
}
else {
for (let [key, value] of Object.entries(options)) {
if (!value) options[key] = cds.env.import[key];
}
}
}
}
module.exports = {
generateEDMX2JSON,
getVersion,
checkForEmptyKeys,
replaceAliasValue,
getContentFromFile,
getContentFromURL,
isValidInputFile,
preProcess,
postProcess,
identifyFile,
readEnvVariables,
processOptions,
normalizeToDeepObject
};