UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

605 lines (534 loc) 21.4 kB
/* eslint-disable no-prototype-builtins */ 'use strict'; /** @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 };