diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e2939..4774240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Added ### Changed -- Removed special characters from placeholders for fields like short text, description, etc. ### Deprecated ### Removed +### Fixed +### Security + +## [1.4.0] - tbd + +### Added +- Opt-in option `shortActionPaths` to use simplified bound action paths (e.g., `.../Discount` instead of `.../ODataDemo.Discount`). Enable via `{ "cds": { "openapi": { "shortActionPaths": true } } }` in `package.json`. + +### Changed +- Removed special characters from placeholders for fields like short text, description, etc. + ### Fixed - Autoexposed `.texts` entities are now excluded from OpenAPI document - Generate navigation paths to CRUD-disabled entities diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index e8304bd..75a34a2 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -87,7 +87,7 @@ const ER_ANNOTATIONS = Object.freeze( /** * Construct an OpenAPI description from a CSDL document * @param {CSDL} csdl CSDL document - * @param {{ url?: string, servers?: object, odataVersion?: string, scheme?: string, host?: string, basePath?: string, diagram?: boolean, maxLevels?: number }} options Optional parameters + * @param {{ url?: string, servers?: object, odataVersion?: string, scheme?: string, host?: string, basePath?: string, diagram?: boolean, maxLevels?: number, shortActionPaths?: boolean }} options Optional parameters * @return {object} OpenAPI description */ module.exports.csdl2openapi = function ( @@ -95,14 +95,16 @@ module.exports.csdl2openapi = function ( { url: serviceRoot, servers: serversObject, - odataVersion: odataVersion, - scheme: scheme = 'https', - host: host = 'localhost', - basePath: basePath = '/service-root', - diagram: diagram = false, - maxLevels: maxLevels = 5 + odataVersion, + scheme = 'https', + host = 'localhost', + basePath = '/service-root', + diagram = false, + maxLevels = 5, + shortActionPaths = false } = {} ) { + diagram = /** @type {unknown} */(diagram) !== "false" && !!diagram; // as preProcess below mutates the csdl, copy it before, to avoid side-effects on the caller side csdl = JSON.parse(JSON.stringify(csdl)) csdl.$Version = odataVersion ? odataVersion : '4.01' @@ -1343,10 +1345,11 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } const overloads = meta.boundOverloads[element.$Type + (!byKey && element.$Collection ? '-c' : '')] || []; overloads.forEach(item => { + const pathName = shortActionPaths && item.name.includes('.') ? nameParts(item.name).name : item.name; if (item.overload.$Kind == 'Action') - pathItemAction(paths, `${prefix}/${item.name}`, prefixParameters, item.name, item.overload, sourceName); + pathItemAction(paths, `${prefix}/${pathName}`, prefixParameters, item.name, item.overload, sourceName); else - pathItemFunction(paths, `${prefix}/${item.name}`, prefixParameters, item.name, item.overload, sourceName); + pathItemFunction(paths, `${prefix}/${pathName}`, prefixParameters, item.name, item.overload, sourceName); }); } diff --git a/lib/compile/index.js b/lib/compile/index.js index 7c9ede3..6240f4b 100644 --- a/lib/compile/index.js +++ b/lib/compile/index.js @@ -1,6 +1,6 @@ const csdl2openapi = require('./csdl2openapi') const cds = require('@sap/cds'); -const fs = require("fs"); +const fs = require('fs'); const DEBUG = cds.debug('openapi'); const supportedProtocols = ["rest", "odata", "odata-v4"]; @@ -108,37 +108,19 @@ function _getOpenApi(csdl, options, serviceName = "") { } function toOpenApiOptions(csdl, csn, options = {}) { - const result = {}; + const callerOptions = {}; for (const key in options) { if (/^openapi:(.*)/.test(key) && RegExp.$1) { - result[RegExp.$1] = options[key]; + callerOptions[RegExp.$1] = options[key]; } else if (key === "odata-version") { - result.odataVersion = options[key]; + callerOptions.odataVersion = options[key]; } } - if (result["config-file"]) { - if (!fs.existsSync(result["config-file"])) { - throw new Error(`Unable to find openapi config file ${result["config-file"]}`); - } - - try{ - const fileData = fs.readFileSync(result["config-file"], "utf-8"); - const fileContent = JSON.parse(fileData); - - Object.keys(fileContent).forEach((key) => { - if (key === "odata-version" && !result.odataVersion) { - result.odataVersion = fileContent[key]; - } else if (key === "diagram") { - result.diagram = !result.diagram && fileContent[key] === "true"; - } else if (!(key in result)) { // inline options take precedence - result[key] = JSON.stringify(fileContent[key]); - } - }); - }catch(err){ - throw new Error(`Unable to parse OpenAPI config ${result["config-file"]}`, { cause: err }); - } - } + const envOptions = cds.env.openapi instanceof Object && !Array.isArray(cds.env.openapi) ? cds.env.openapi : {}; + const fileOptions = _readConfigFile(callerOptions["config-file"]); + const result = { ...envOptions, ...fileOptions, ...callerOptions }; + delete result["config-file"]; const protocols = _getProtocols(csdl, csn, result.odataVersion); @@ -226,6 +208,29 @@ function _servicePath(csdl, csn, protocols) { return {} } +function _readConfigFile(configFilePath) { + if (!configFilePath) return {}; + + if (!fs.existsSync(configFilePath)) { + throw new Error(`Unable to find openapi config file ${configFilePath}`); + } + + let fileContent; + try { + fileContent = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')); + } catch (err) { + throw new Error(`Unable to parse OpenAPI config ${configFilePath}`, { cause: err }); + } + + const result = {}; + for (const key of Object.keys(fileContent)) { + const normalizedKey = key === "odata-version" ? "odataVersion" : key; + const value = fileContent[key]; + result[normalizedKey] = typeof value === 'object' && value !== null ? JSON.stringify(value) : value; + } + return result; +} + // we're attaching the events to the main function so they become automatically exposed through the cds facade: // cds.compile.to.openapi(...) // cds.compile.to.openapi.events.before diff --git a/package.json b/package.json index 790f5bc..6e1868d 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,5 @@ "eslint": "^9.33.0", "typescript": "^5.9.2", "@mermaid-js/mermaid-cli": "^11.12.0" - }, - "cds": { - "openapi": { - "foo": "bar" - } } } diff --git a/test/lib/compile/csdl2openapi.test.js b/test/lib/compile/csdl2openapi.test.js index 5426877..a830567 100644 --- a/test/lib/compile/csdl2openapi.test.js +++ b/test/lib/compile/csdl2openapi.test.js @@ -2703,6 +2703,107 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot }); }); +describe("Bound action path naming", () => { + const baseCsdl = { + $Version: "4.01", + $Reference: { + dummy: { + $Include: [ + { $Namespace: "Org.OData.Capabilities.V1", $Alias: "Capabilities" }, + ], + }, + }, + $EntityContainer: "TestService.Container", + TestService: { + Product: { + $Kind: "EntityType", + $Key: ["ID"], + ID: { $Type: "Edm.Int32" }, + Name: { $Type: "Edm.String" }, + }, + Discount: [ + { + $Kind: "Action", + $IsBound: true, + $Parameter: [ + { $Name: "in", $Type: "TestService.Product" }, + { $Name: "percent", $Type: "Edm.Decimal" }, + ], + $ReturnType: { $Type: "Edm.Decimal" }, + }, + ], + Container: { + "@Capabilities.KeyAsSegmentSupported": true, + Products: { + $Type: "TestService.Product", + $Collection: true, + }, + }, + }, + }; + + test("Fully qualified action names by default", () => { + const csdl = JSON.parse(JSON.stringify(baseCsdl)); + const actual = lib.csdl2openapi(csdl); + + assert.ok( + actual.paths["/Products/{ID}/TestService.Discount"], + "Path should use fully qualified name with namespace prefix" + ); + assert.strictEqual( + actual.paths["/Products/{ID}/Discount"], + undefined, + "Path with simplified name should not exist" + ); + assert.strictEqual( + actual.paths["/Products/{ID}/TestService.Discount"].post.summary, + "Invokes action Discount", + "Action summary should be present" + ); + }); + + test("Simplified action names with shortActionPaths option", () => { + const csdl = JSON.parse(JSON.stringify(baseCsdl)); + const actual = lib.csdl2openapi(csdl, { shortActionPaths: true }); + + assert.ok( + actual.paths["/Products/{ID}/Discount"], + "Path should use simplified name without namespace prefix" + ); + assert.strictEqual( + actual.paths["/Products/{ID}/TestService.Discount"], + undefined, + "Path with namespace prefix should not exist" + ); + assert.strictEqual( + actual.paths["/Products/{ID}/Discount"].post.summary, + "Invokes action Discount", + "Action summary should be present" + ); + }); + + test("Simplified names with DefaultNamespace even without shortActionPaths", () => { + const csdl = JSON.parse(JSON.stringify(baseCsdl)); + csdl.$Reference.dummy.$Include.push({ + $Namespace: "Org.OData.Core.V1", + $Alias: "Core", + }); + csdl.TestService["@Core.DefaultNamespace"] = true; + + const actual = lib.csdl2openapi(csdl); + + assert.ok( + actual.paths["/Products/{ID}/Discount"], + "Path should use simplified name when DefaultNamespace is true" + ); + assert.strictEqual( + actual.paths["/Products/{ID}/TestService.Discount"], + undefined, + "Path with namespace prefix should not exist with DefaultNamespace" + ); + }); +}); + it("AllowedValues on various Edm types", () => { const csdl = { $Reference: {