From 38529ff5c1234ecb86dd81b59bdd4fc4be01196f Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Tue, 3 Mar 2026 22:40:42 +0100 Subject: [PATCH 1/5] feat(openapi): add opt-in shortActionPaths option for simplified bound action paths - Add `shortActionPaths` option to `csdl2openapi`: when enabled, bound action/function paths use the simple name (e.g. `/Products/{ID}/Discount`) instead of the namespace-qualified name (e.g. `/Products/{ID}/TestService.Discount`) - Apply options from `cds.env.openapi` in `package.json` as project-level defaults, overridable by config file, CLI flags, or programmatic options --- CHANGELOG.md | 13 +++- lib/compile/csdl2openapi.js | 11 ++- lib/compile/index.js | 50 ++++++------- package.json | 5 -- test/lib/compile/csdl2openapi.test.js | 101 ++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e2939..b029a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,20 @@ 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 CLI flag `--openapi:shortActionPaths` or project-wide via `{ "cds": { "openapi": { "shortActionPaths": true } } }` in `package.json`. +- Options from `cds.env.openapi` in `package.json` are now applied as project-level defaults, overridable by CLI flags or programmatic options. + +### 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..a6fed6a 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 ( @@ -100,9 +100,11 @@ module.exports.csdl2openapi = function ( host: host = 'localhost', basePath: basePath = '/service-root', diagram: diagram = false, - maxLevels: maxLevels = 5 + maxLevels: maxLevels = 5, + shortActionPaths: 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..a52f9dd 100644 --- a/lib/compile/index.js +++ b/lib/compile/index.js @@ -1,6 +1,5 @@ const csdl2openapi = require('./csdl2openapi') const cds = require('@sap/cds'); -const fs = require("fs"); const DEBUG = cds.debug('openapi'); const supportedProtocols = ["rest", "odata", "odata-v4"]; @@ -108,37 +107,18 @@ 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"] ?? envOptions["config-file"]); + const result = { ...envOptions, ...fileOptions, ...callerOptions }; const protocols = _getProtocols(csdl, csn, result.odataVersion); @@ -226,6 +206,24 @@ function _servicePath(csdl, csn, protocols) { return {} } +function _readConfigFile(configFilePath) { + if (!configFilePath) return {}; + + let fileContent; + try { + fileContent = require(configFilePath); + } 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; + result[normalizedKey] = typeof fileContent[key] === "boolean" ? fileContent[key] : JSON.stringify(fileContent[key]); + } + 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: { From cae3fe7e13f52ae6a23ef12fb5f03d59867b5ff1 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Tue, 3 Mar 2026 22:44:34 +0100 Subject: [PATCH 2/5] style: simplify destructuring in csdl2openapi function signature --- lib/compile/csdl2openapi.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index a6fed6a..75a34a2 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -95,13 +95,13 @@ 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, - shortActionPaths: shortActionPaths = false + odataVersion, + scheme = 'https', + host = 'localhost', + basePath = '/service-root', + diagram = false, + maxLevels = 5, + shortActionPaths = false } = {} ) { diagram = /** @type {unknown} */(diagram) !== "false" && !!diagram; From 30e05efc7607c0fc3015a00602b1c7e861927f92 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Thu, 5 Mar 2026 16:10:28 +0100 Subject: [PATCH 3/5] fix: improve openapi config file parsing and option merging --- lib/compile/index.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/compile/index.js b/lib/compile/index.js index a52f9dd..d7ff685 100644 --- a/lib/compile/index.js +++ b/lib/compile/index.js @@ -1,5 +1,6 @@ const csdl2openapi = require('./csdl2openapi') const cds = require('@sap/cds'); +const fs = require('fs'); const DEBUG = cds.debug('openapi'); const supportedProtocols = ["rest", "odata", "odata-v4"]; @@ -119,6 +120,7 @@ function toOpenApiOptions(csdl, csn, options = {}) { const envOptions = cds.env.openapi instanceof Object && !Array.isArray(cds.env.openapi) ? cds.env.openapi : {}; const fileOptions = _readConfigFile(callerOptions["config-file"] ?? envOptions["config-file"]); const result = { ...envOptions, ...fileOptions, ...callerOptions }; + delete result["config-file"]; const protocols = _getProtocols(csdl, csn, result.odataVersion); @@ -209,9 +211,13 @@ function _servicePath(csdl, csn, protocols) { function _readConfigFile(configFilePath) { if (!configFilePath) return {}; + if (!fs.existsSync(configFilePath)) { + throw new Error(`Unable to find openapi config file ${configFilePath}`); + } + let fileContent; try { - fileContent = require(configFilePath); + fileContent = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')); } catch (err) { throw new Error(`Unable to parse OpenAPI config ${configFilePath}`, { cause: err }); } @@ -219,7 +225,8 @@ function _readConfigFile(configFilePath) { const result = {}; for (const key of Object.keys(fileContent)) { const normalizedKey = key === "odata-version" ? "odataVersion" : key; - result[normalizedKey] = typeof fileContent[key] === "boolean" ? fileContent[key] : JSON.stringify(fileContent[key]); + const value = fileContent[key]; + result[normalizedKey] = typeof value === 'object' && value !== null ? JSON.stringify(value) : value; } return result; } From d3bf9ca530189f4e389eb4828385b75cc6e682ec Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Thu, 5 Mar 2026 16:48:29 +0100 Subject: [PATCH 4/5] docs: changelog --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b029a90..4774240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## [1.4.0] - tbd ### Added -- Opt-in option `shortActionPaths` to use simplified bound action paths (e.g., `.../Discount` instead of `.../ODataDemo.Discount`). Enable via CLI flag `--openapi:shortActionPaths` or project-wide via `{ "cds": { "openapi": { "shortActionPaths": true } } }` in `package.json`. -- Options from `cds.env.openapi` in `package.json` are now applied as project-level defaults, overridable by CLI flags or programmatic options. +- 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. From 2ab85d6df47145fd25daeff2ea60c4693c1e5888 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Fri, 6 Mar 2026 15:32:59 +0100 Subject: [PATCH 5/5] fix: do not support config-file via cds.env.openapi --- lib/compile/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compile/index.js b/lib/compile/index.js index d7ff685..6240f4b 100644 --- a/lib/compile/index.js +++ b/lib/compile/index.js @@ -118,7 +118,7 @@ function toOpenApiOptions(csdl, csn, options = {}) { } const envOptions = cds.env.openapi instanceof Object && !Array.isArray(cds.env.openapi) ? cds.env.openapi : {}; - const fileOptions = _readConfigFile(callerOptions["config-file"] ?? envOptions["config-file"]); + const fileOptions = _readConfigFile(callerOptions["config-file"]); const result = { ...envOptions, ...fileOptions, ...callerOptions }; delete result["config-file"];