Skip to content

Commit 4b20475

Browse files
authored
fix: support INA protocol and prevent null entryPoints (#351)
* fix: support INA protocol and prevent null entryPoints Refactor protocol handling to properly support: - ORD-only protocols like INA (sap-ina-api-v1) that CDS doesn't recognize - Plugin-unsupported protocols like GraphQL (with proper warnings) - Data product services with data subscription protocol Key changes: - Add protocol constants (ORD_API_PROTOCOL, CAP_TO_ORD_PROTOCOL_MAP, etc.) - Extract protocol resolution to new protocol-resolver.js with clear API: - resolveApiResourceProtocol(serviceName, srvDefinition, options) - Options: capEndpoints (from caller), isPrimaryDataProduct (strategy fn) - Three rules for protocol handling: - Rule A: Explicit protocol + empty endpoints → don't fallback to OData - Rule B: Only fallback to OData when no explicit protocol - Rule C: Never produce [null] in entryPoints - Add INA service example to xmpl - Fix custom.ord.json schema validation issues * refactor: move getCapEndpoints to protocol-resolver and add dedicated tests - Move getCapEndpoints from templates.js to protocol-resolver.js - Call getCapEndpoints internally in resolveApiResourceProtocol - Remove capEndpoints parameter from resolveApiResourceProtocol - Create protocol-resolver.test.js with dedicated unit tests - Remove protocol tests from templates.test.js * feat: support multi-protocol scenarios and refactor protocol resolution - Refactored resolveApiResourceProtocol to collect results from all protocol sources instead of early return - Added support for @protocol: ['ina', 'odata'] multi-protocol scenarios - Added tests for multi-protocol combinations * chore: remove unused cds import from templates.js * refactor: simplify protocol-resolver to single protocol support - Remove multi-protocol merging logic - Rename _getExplicitProtocols to _getExplicitProtocol (returns single value) - Inline helper functions for cleaner code - Update tests accordingly
1 parent 50e071e commit 4b20475

File tree

6 files changed

+408
-67
lines changed

6 files changed

+408
-67
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
const cds = require("@sap/cds");
2+
const { ORD_API_PROTOCOL } = require("../../lib/constants");
3+
const { resolveApiResourceProtocol, _getExplicitProtocol } = require("../../lib/protocol-resolver");
4+
const { isPrimaryDataProductService } = require("../../lib/templates");
5+
const Logger = require("../../lib/logger");
6+
7+
describe("protocol-resolver", () => {
8+
describe("_getExplicitProtocol", () => {
9+
it("should return null when no @protocol annotation", () => {
10+
const srvDefinition = { name: "MyService" };
11+
expect(_getExplicitProtocol(srvDefinition)).toBeNull();
12+
});
13+
14+
it("should return string protocol as-is", () => {
15+
const srvDefinition = { "name": "MyService", "@protocol": "rest" };
16+
expect(_getExplicitProtocol(srvDefinition)).toBe("rest");
17+
});
18+
19+
it("should return first protocol from array", () => {
20+
const srvDefinition = { "name": "MyService", "@protocol": ["odata", "rest"] };
21+
expect(_getExplicitProtocol(srvDefinition)).toBe("odata");
22+
});
23+
24+
it("should handle single-item array", () => {
25+
const srvDefinition = { "name": "MyService", "@protocol": ["graphql"] };
26+
expect(_getExplicitProtocol(srvDefinition)).toBe("graphql");
27+
});
28+
});
29+
30+
describe("resolveApiResourceProtocol", () => {
31+
let loggerWarnSpy;
32+
33+
beforeEach(() => {
34+
loggerWarnSpy = jest.spyOn(Logger, "warn").mockImplementation(() => {});
35+
});
36+
37+
afterEach(() => {
38+
loggerWarnSpy.mockRestore();
39+
});
40+
41+
it("should return odata-v4 for default OData service without explicit protocol", () => {
42+
const model = cds.linked(`
43+
service MyService {
44+
entity Books { key ID: UUID; }
45+
}
46+
`);
47+
const srvDefinition = model.definitions["MyService"];
48+
const result = resolveApiResourceProtocol("MyService", srvDefinition, {
49+
isPrimaryDataProduct: isPrimaryDataProductService,
50+
});
51+
52+
expect(result).toHaveLength(1);
53+
expect(result[0].apiProtocol).toBe(ORD_API_PROTOCOL.ODATA_V4);
54+
expect(result[0].hasResourceDefinitions).toBe(true);
55+
expect(result[0].entryPoints).not.toContain(null);
56+
});
57+
58+
it("should return empty array for unknown explicit protocol", () => {
59+
const srvDefinition = {
60+
"name": "MyService",
61+
"@protocol": "unknown-protocol",
62+
};
63+
const result = resolveApiResourceProtocol("MyService", srvDefinition, {
64+
isPrimaryDataProduct: isPrimaryDataProductService,
65+
});
66+
67+
expect(result).toEqual([]);
68+
expect(loggerWarnSpy).toHaveBeenCalledWith(
69+
expect.stringContaining("Unknown protocol 'unknown-protocol' is not supported"),
70+
);
71+
});
72+
73+
it("should handle INA protocol as ORD-only protocol", () => {
74+
const srvDefinition = {
75+
"name": "INAService",
76+
"@protocol": "ina",
77+
};
78+
const result = resolveApiResourceProtocol("INAService", srvDefinition, {
79+
isPrimaryDataProduct: isPrimaryDataProductService,
80+
});
81+
82+
expect(result).toHaveLength(1);
83+
expect(result[0].apiProtocol).toBe(ORD_API_PROTOCOL.SAP_INA);
84+
expect(result[0].entryPoints).toEqual([]);
85+
expect(result[0].hasResourceDefinitions).toBe(false);
86+
});
87+
88+
it("should warn and skip GraphQL protocol", () => {
89+
const srvDefinition = {
90+
"name": "GraphQLService",
91+
"@protocol": "graphql",
92+
};
93+
const result = resolveApiResourceProtocol("GraphQLService", srvDefinition, {
94+
isPrimaryDataProduct: isPrimaryDataProductService,
95+
});
96+
97+
expect(result).toEqual([]);
98+
expect(loggerWarnSpy).toHaveBeenCalledWith(
99+
expect.stringContaining("plugin cannot generate its resource definitions yet"),
100+
);
101+
});
102+
103+
it("should return data subscription protocol for primary data product service", () => {
104+
const srvDefinition = {
105+
"name": "DataProductService",
106+
"@DataIntegration.dataProduct.type": "primary",
107+
};
108+
const result = resolveApiResourceProtocol("DataProductService", srvDefinition, {
109+
isPrimaryDataProduct: isPrimaryDataProductService,
110+
});
111+
112+
expect(result).toHaveLength(1);
113+
expect(result[0].apiProtocol).toBe(ORD_API_PROTOCOL.SAP_DATA_SUBSCRIPTION);
114+
expect(result[0].entryPoints).toEqual([]);
115+
expect(result[0].hasResourceDefinitions).toBe(true);
116+
});
117+
118+
it("should never produce [null] in entryPoints (Rule C)", () => {
119+
const testCases = [
120+
{ "name": "Svc1", "@protocol": "ina" },
121+
{ "name": "Svc2", "@DataIntegration.dataProduct.type": "primary" },
122+
{ name: "Svc3" },
123+
];
124+
125+
testCases.forEach((srvDefinition) => {
126+
const result = resolveApiResourceProtocol(srvDefinition.name, srvDefinition, {
127+
isPrimaryDataProduct: isPrimaryDataProductService,
128+
});
129+
result.forEach((r) => {
130+
expect(r.entryPoints).not.toContain(null);
131+
expect(r.entryPoints).not.toContain(undefined);
132+
});
133+
});
134+
});
135+
136+
it("should not fallback to OData when explicit protocol is set (Rule A)", () => {
137+
const srvDefinition = {
138+
"name": "CustomService",
139+
"@protocol": "custom-protocol",
140+
};
141+
const result = resolveApiResourceProtocol("CustomService", srvDefinition, {
142+
isPrimaryDataProduct: isPrimaryDataProductService,
143+
});
144+
145+
expect(result).toEqual([]);
146+
const hasOData = result.some((r) => r.apiProtocol === ORD_API_PROTOCOL.ODATA_V4);
147+
expect(hasOData).toBe(false);
148+
});
149+
150+
it("should only fallback to OData when no explicit protocol (Rule B)", () => {
151+
const model = cds.linked(`
152+
service DefaultService {
153+
entity Items { key ID: UUID; }
154+
}
155+
`);
156+
const srvDefinition = model.definitions["DefaultService"];
157+
const result = resolveApiResourceProtocol("DefaultService", srvDefinition, {
158+
isPrimaryDataProduct: isPrimaryDataProductService,
159+
});
160+
161+
expect(result).toHaveLength(1);
162+
expect(result[0].apiProtocol).toBe(ORD_API_PROTOCOL.ODATA_V4);
163+
});
164+
});
165+
});

lib/constants.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ const SEM_VERSION_REGEX =
117117

118118
const MCP_CUSTOM_TYPE = "sap:mcp-server-card:v0";
119119

120+
// ORD apiProtocol values
121+
const ORD_API_PROTOCOL = Object.freeze({
122+
ODATA_V4: "odata-v4",
123+
ODATA_V2: "odata-v2",
124+
REST: "rest",
125+
GRAPHQL: "graphql",
126+
SAP_INA: "sap-ina-api-v1",
127+
SAP_DATA_SUBSCRIPTION: "sap.dp:data-subscription-api:v1",
128+
});
129+
130+
// Mapping from CAP protocol kind to ORD apiProtocol
131+
// CAP may return 'odata', 'odata-v4', 'rest', etc.
132+
const CAP_TO_ORD_PROTOCOL_MAP = Object.freeze({
133+
"odata": ORD_API_PROTOCOL.ODATA_V4,
134+
"odata-v4": ORD_API_PROTOCOL.ODATA_V4,
135+
"odata-v2": ORD_API_PROTOCOL.ODATA_V2,
136+
"rest": ORD_API_PROTOCOL.REST,
137+
});
138+
139+
// Protocols that ORD supports but CAP doesn't recognize (endpoints4 returns [])
140+
// These need special handling in the ORD plugin
141+
const ORD_ONLY_PROTOCOLS = Object.freeze({
142+
"ina": {
143+
apiProtocol: ORD_API_PROTOCOL.SAP_INA,
144+
hasEntryPoints: false,
145+
hasResourceDefinitions: false,
146+
},
147+
});
148+
149+
// Protocols that the ORD plugin cannot currently generate definitions for
150+
// GraphQL is supported by ORD spec, but plugin can't emit graphql-sdl yet
151+
const PLUGIN_UNSUPPORTED_PROTOCOLS = Object.freeze(["graphql"]);
152+
120153
// CF mTLS Error Reasons
121154
const CF_MTLS_ERROR_REASON = Object.freeze({
122155
NO_HEADERS: "NO_HEADERS",
@@ -146,6 +179,7 @@ module.exports = {
146179
BASIC_AUTH_HEADER_KEY,
147180
BUILD_DEFAULT_PATH,
148181
BLOCKED_SERVICE_NAME,
182+
CAP_TO_ORD_PROTOCOL_MAP,
149183
CDS_ELEMENT_KIND,
150184
CF_MTLS_HEADERS,
151185
COMPILER_TYPES,
@@ -160,12 +194,15 @@ module.exports = {
160194
OPEN_RESOURCE_DISCOVERY_VERSION,
161195
OPENAPI_SERVERS_ANNOTATION,
162196
ORD_ACCESS_STRATEGY,
197+
ORD_API_PROTOCOL,
163198
ORD_DOCUMENT_FILE_NAME,
164199
ORD_EXTENSIONS_PREFIX,
165200
ORD_ODM_ENTITY_NAME_ANNOTATION,
166201
ORD_EXISTING_PRODUCT_PROPERTY,
202+
ORD_ONLY_PROTOCOLS,
167203
ORD_RESOURCE_TYPE,
168204
ORD_SERVICE_NAME,
205+
PLUGIN_UNSUPPORTED_PROTOCOLS,
169206
RESOURCE_VISIBILITY,
170207
ALLOWED_VISIBILITY,
171208
IMPLEMENTATIONSTANDARD_VERSIONS,

lib/protocol-resolver.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
const cds = require("@sap/cds");
2+
const {
3+
CAP_TO_ORD_PROTOCOL_MAP,
4+
ORD_ONLY_PROTOCOLS,
5+
ORD_API_PROTOCOL,
6+
PLUGIN_UNSUPPORTED_PROTOCOLS,
7+
} = require("./constants");
8+
const Logger = require("./logger");
9+
10+
/**
11+
* Gets CAP endpoints for a service using CDS endpoints4().
12+
*
13+
* @param {string} serviceName The service name.
14+
* @param {Object} srvDefinition The service definition object.
15+
* @returns {Array} Raw endpoints from CDS.
16+
*/
17+
function _getCapEndpoints(serviceName, srvDefinition) {
18+
const srvObj = { name: serviceName, definition: srvDefinition };
19+
return cds.service.protocols.endpoints4(srvObj);
20+
}
21+
22+
/**
23+
* Reads the explicit @protocol annotation from service definition.
24+
*
25+
* @param {Object} srvDefinition The service definition object.
26+
* @returns {string|null} Protocol name, or null if not explicitly set.
27+
*/
28+
function _getExplicitProtocol(srvDefinition) {
29+
const protocol = srvDefinition["@protocol"];
30+
if (!protocol) {
31+
return null;
32+
}
33+
return Array.isArray(protocol) ? protocol[0] : protocol;
34+
}
35+
36+
/**
37+
* Resolves protocol for ORD API Resource generation.
38+
*
39+
* Design Principles:
40+
* - explicit protocol is the "master switch" for all decisions
41+
* - Rule A: Explicit protocol + empty endpoints → don't fallback to OData
42+
* - Rule B: Only fallback to OData when no explicit protocol
43+
* - Rule C: Never produce [null] in entryPoints
44+
*
45+
* @param {string} serviceName The service name.
46+
* @param {Object} srvDefinition The service definition object.
47+
* @param {Object} options Configuration options.
48+
* @param {Function} options.isPrimaryDataProduct Strategy function to check if service is primary data product.
49+
* @returns {Array} Array with single {apiProtocol, entryPoints, hasResourceDefinitions} object, or empty array.
50+
*/
51+
function resolveApiResourceProtocol(serviceName, srvDefinition, options = {}) {
52+
const { isPrimaryDataProduct = () => false } = options;
53+
54+
// 1. Primary Data Product - early return
55+
if (isPrimaryDataProduct(srvDefinition)) {
56+
return [
57+
{
58+
apiProtocol: ORD_API_PROTOCOL.SAP_DATA_SUBSCRIPTION,
59+
entryPoints: [],
60+
hasResourceDefinitions: true,
61+
},
62+
];
63+
}
64+
65+
const explicit = _getExplicitProtocol(srvDefinition);
66+
67+
// 2. Handle explicit protocol
68+
if (explicit) {
69+
// 2a. Check if it's an ORD-only protocol (e.g., INA)
70+
if (ORD_ONLY_PROTOCOLS[explicit]) {
71+
const config = ORD_ONLY_PROTOCOLS[explicit];
72+
const path = config.hasEntryPoints ? cds.service.protocols.path4(srvDefinition) : null;
73+
return [
74+
{
75+
apiProtocol: config.apiProtocol,
76+
entryPoints: path ? [path] : [],
77+
hasResourceDefinitions: config.hasResourceDefinitions,
78+
},
79+
];
80+
}
81+
82+
// 2b. Check if it's a plugin-unsupported protocol
83+
if (PLUGIN_UNSUPPORTED_PROTOCOLS.includes(explicit)) {
84+
Logger.warn(
85+
`Protocol '${explicit}' is supported by ORD but this plugin cannot generate its resource definitions yet.`,
86+
);
87+
return [];
88+
}
89+
}
90+
91+
// 3. Try to resolve from CAP endpoints
92+
const capEndpoints = _getCapEndpoints(serviceName, srvDefinition);
93+
for (const endpoint of capEndpoints) {
94+
if (PLUGIN_UNSUPPORTED_PROTOCOLS.includes(endpoint.kind)) {
95+
Logger.warn(
96+
`Protocol '${endpoint.kind}' is supported by ORD but this plugin cannot generate its resource definitions yet.`,
97+
);
98+
continue;
99+
}
100+
101+
const apiProtocol = CAP_TO_ORD_PROTOCOL_MAP[endpoint.kind] ?? endpoint.kind;
102+
if (apiProtocol) {
103+
return [
104+
{
105+
apiProtocol,
106+
entryPoints: endpoint.path ? [endpoint.path] : [],
107+
hasResourceDefinitions: true,
108+
},
109+
];
110+
}
111+
}
112+
113+
// 4. Handle explicit protocol with no CAP endpoint (Rule A)
114+
if (explicit) {
115+
Logger.warn(`Unknown protocol '${explicit}' is not supported, skipping service '${serviceName}'.`);
116+
return [];
117+
}
118+
119+
// 5. No explicit protocol and no CAP endpoint - fallback to OData (Rule B)
120+
const path = cds.service.protocols.path4(srvDefinition);
121+
return [
122+
{
123+
apiProtocol: ORD_API_PROTOCOL.ODATA_V4,
124+
entryPoints: path ? [path] : [],
125+
hasResourceDefinitions: true,
126+
},
127+
];
128+
}
129+
130+
module.exports = {
131+
resolveApiResourceProtocol,
132+
// Exported for testing
133+
_getExplicitProtocol,
134+
};

0 commit comments

Comments
 (0)