@sap/cds 9.7.0 → 9.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
5
5
|
- This project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## Version 9.7.1 - 2026-02-06
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `DELETE` requests nulling a `@mandatory` property
|
|
12
|
+
- Correctly call remote collection bound action for `odata-v4` services
|
|
13
|
+
- Flow annotation validation at compile time strictly follows the documentation: only enum status values are allowed
|
|
14
|
+
+ Status value validation can be disabled via `cds.features.skip_flows_validation=true`
|
|
15
|
+
|
|
7
16
|
## Version 9.7.0 - 2026-02-02
|
|
8
17
|
|
|
9
18
|
### Added
|
package/lib/compile/for/flows.js
CHANGED
|
@@ -147,7 +147,7 @@ function enhanceCSNwithFlowAnnotations4FE(csn) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
module.exports = function cds_compile_for_flows(csn) {
|
|
150
|
-
const { history_for_flows } = cds.env.features
|
|
150
|
+
const { history_for_flows, skip_flows_validation } = cds.env.features
|
|
151
151
|
|
|
152
152
|
const _requires_history = !history_for_flows
|
|
153
153
|
? () => false
|
|
@@ -168,6 +168,61 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
const _validate_status_types = skip_flows_validation
|
|
172
|
+
? () => {}
|
|
173
|
+
: (name, def, status, csn, errors) => {
|
|
174
|
+
// enum
|
|
175
|
+
let enumVals
|
|
176
|
+
if (status.type === 'cds.Association') {
|
|
177
|
+
const target = csn.definitions[status.target]
|
|
178
|
+
if (!status.keys || status.keys.length !== 1) {
|
|
179
|
+
errors.push(`Status element in entity ${name} must have exactly one key when used as association`)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const code = target.elements[status.keys[0].ref[0]]
|
|
183
|
+
enumVals = code.enum || csn.definitions[code.type]?.enum
|
|
184
|
+
} else {
|
|
185
|
+
enumVals = status.enum ?? csn.definitions[status.type]?.enum
|
|
186
|
+
}
|
|
187
|
+
if (!enumVals) {
|
|
188
|
+
errors.push(`Status element in entity ${name} must be an enum or target an entity with an enum named "code"`)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
// actions
|
|
192
|
+
for (const each in def.actions) {
|
|
193
|
+
const action = def.actions[each]
|
|
194
|
+
const from = action[FROM]
|
|
195
|
+
if (from !== undefined) {
|
|
196
|
+
if (Array.isArray(from)) {
|
|
197
|
+
for (let i = 0; i < from.length; i++) {
|
|
198
|
+
if (from[i] !== null) {
|
|
199
|
+
let val = from[i]['#']
|
|
200
|
+
if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) {
|
|
201
|
+
errors.push(`Invalid ${FROM} value at position ${i} in action ${each}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} else if (from !== null) {
|
|
206
|
+
let val = from['#']
|
|
207
|
+
if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) {
|
|
208
|
+
errors.push(`Invalid ${FROM} value in action ${each}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const to = action[TO]
|
|
213
|
+
if (to !== undefined) {
|
|
214
|
+
if (Array.isArray(to)) {
|
|
215
|
+
errors.push(`${TO} must not be an array in action ${each}`)
|
|
216
|
+
} else if (to !== null && to['='] !== FLOW_PREVIOUS) {
|
|
217
|
+
let val = to['#']
|
|
218
|
+
if (!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val))) {
|
|
219
|
+
errors.push(`Invalid ${TO} value in action ${each}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
171
226
|
/*
|
|
172
227
|
* 1. propagate flows for well-known actions from extensions to definitions
|
|
173
228
|
*/
|
|
@@ -185,11 +240,14 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
185
240
|
}
|
|
186
241
|
}
|
|
187
242
|
|
|
243
|
+
const errors = []
|
|
188
244
|
const to_be_extended = new Set()
|
|
189
245
|
|
|
190
246
|
for (const name in csn.definitions) {
|
|
191
247
|
const def = csn.definitions[name]
|
|
192
248
|
|
|
249
|
+
if (!def.kind || def.kind !== 'entity' || !def.elements || !def.actions) continue
|
|
250
|
+
|
|
193
251
|
/*
|
|
194
252
|
* 2. propagate @flow.status to respective element and make it @readonly
|
|
195
253
|
*/
|
|
@@ -201,8 +259,6 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
201
259
|
}
|
|
202
260
|
}
|
|
203
261
|
|
|
204
|
-
if (!def.kind || def.kind !== 'entity' || !def.actions) continue
|
|
205
|
-
|
|
206
262
|
/*
|
|
207
263
|
* 3. normalize @from and @to annotations
|
|
208
264
|
*/
|
|
@@ -213,7 +269,19 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
213
269
|
}
|
|
214
270
|
|
|
215
271
|
/*
|
|
216
|
-
* 4.
|
|
272
|
+
* 4. validate annotations
|
|
273
|
+
*/
|
|
274
|
+
let status = Object.values(def.elements).filter(e => e['@flow.status'])
|
|
275
|
+
if (status.length === 0) continue
|
|
276
|
+
if (status.length > 1) {
|
|
277
|
+
errors.push(`Entity ${name} has multiple status elements. Only one @flow.status element is allowed per entity`)
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
status = status[0]
|
|
281
|
+
_validate_status_types(name, def, status, csn, errors)
|
|
282
|
+
|
|
283
|
+
/*
|
|
284
|
+
* 5. automatically apply aspect FlowHistory if needed and not present yet
|
|
217
285
|
*/
|
|
218
286
|
if (!_requires_history(def)) continue
|
|
219
287
|
|
|
@@ -226,6 +294,17 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
226
294
|
to_be_extended.add(base_name)
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
/*
|
|
298
|
+
* 6. throw validation errors, if any
|
|
299
|
+
*/
|
|
300
|
+
if (errors.length) {
|
|
301
|
+
if (errors.length === 1) cds.error(errors[0])
|
|
302
|
+
else cds.error('MULTIPLE_ERRORS', { details: errors.map(message => ({ message })) })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/*
|
|
306
|
+
* 7. apply the extensions
|
|
307
|
+
*/
|
|
229
308
|
if (to_be_extended.size) {
|
|
230
309
|
// REVISIT: ensure sap.common.FlowHistory is there
|
|
231
310
|
csn.definitions['sap.common.FlowHistory'] ??= JSON.parse(FlowHistory)
|
|
@@ -254,81 +333,9 @@ module.exports = function cds_compile_for_flows(csn) {
|
|
|
254
333
|
csn = dsn
|
|
255
334
|
}
|
|
256
335
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (status === null) return true
|
|
261
|
-
if (enumVals) {
|
|
262
|
-
let val = status['#']
|
|
263
|
-
if (
|
|
264
|
-
!(typeof val === 'string' && Object.entries(enumVals).some(([key]) => key === val)) &&
|
|
265
|
-
!(fromTo === TO && typeof status === 'object' && status['='] === FLOW_PREVIOUS)
|
|
266
|
-
) {
|
|
267
|
-
messages.push(`Invalid ${fromTo} value(s) in action ${action}`);
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
} else {
|
|
271
|
-
if (typeof status !== 'string' && !(fromTo === TO && typeof status === 'object' && status['='] === FLOW_PREVIOUS)) {
|
|
272
|
-
messages.push(`Invalid ${fromTo} value(s) in action ${action}`)
|
|
273
|
-
return false
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return true
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
for (const name in csn.definitions) {
|
|
281
|
-
const def = csn.definitions[name]
|
|
282
|
-
if (!def.kind || def.kind !== 'entity' || !def.actions || !def.elements) continue
|
|
283
|
-
|
|
284
|
-
const statusElements = Object.values(def.elements).filter(e => e['@flow.status'])
|
|
285
|
-
if (statusElements.length === 0) continue
|
|
286
|
-
if (statusElements.length > 1) {
|
|
287
|
-
messages.push(`Entity ${name} has multiple status elements. Only one @flow.status element is allowed per entity`)
|
|
288
|
-
continue
|
|
289
|
-
}
|
|
290
|
-
const status = statusElements[0]
|
|
291
|
-
|
|
292
|
-
let enumVals
|
|
293
|
-
if (status.type === 'cds.Association') {
|
|
294
|
-
const target = csn.definitions[status.target]
|
|
295
|
-
if (!status.keys || status.keys.length !== 1) {
|
|
296
|
-
messages.push(`Status element in entity ${name} must have exactly one key when used as association`)
|
|
297
|
-
continue
|
|
298
|
-
}
|
|
299
|
-
const code = target.elements[status.keys[0].ref[0]]
|
|
300
|
-
enumVals = code.enum || csn.definitions[code.type]?.enum
|
|
301
|
-
} else if (status.enum) {
|
|
302
|
-
enumVals = status.enum
|
|
303
|
-
} else if (csn.definitions[status.type]?.enum) {
|
|
304
|
-
enumVals = csn.definitions[status.type].enum
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
for (const each in def.actions) {
|
|
308
|
-
const action = def.actions[each]
|
|
309
|
-
let froms = action[FROM]
|
|
310
|
-
if (froms !== undefined) {
|
|
311
|
-
froms = Array.isArray(froms) ? froms : [froms]
|
|
312
|
-
for (let from of froms) if (!validate(from, enumVals, each, FROM)) break
|
|
313
|
-
}
|
|
314
|
-
let tos = action[TO]
|
|
315
|
-
if (tos !== undefined) {
|
|
316
|
-
if (Array.isArray(tos)) {
|
|
317
|
-
messages.push(`${TO} must not be an array in action ${each}`)
|
|
318
|
-
continue
|
|
319
|
-
}
|
|
320
|
-
validate(tos, enumVals, each, TO)
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (messages.length) {
|
|
325
|
-
if (messages.length === 1) cds.error(messages[0])
|
|
326
|
-
else {
|
|
327
|
-
const errors = messages.map(message => ({ message }))
|
|
328
|
-
cds.error ('MULTIPLE_ERRORS', { details: errors })
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
336
|
+
/*
|
|
337
|
+
* 8. exclude transitions_ from draft
|
|
338
|
+
*/
|
|
332
339
|
// REVISIT: annotate all X.transitions_ with @odata.draft.enabled: false
|
|
333
340
|
for (const name in csn.definitions)
|
|
334
341
|
if (name.endsWith('.transitions_')) csn.definitions[name]['@odata.draft.enabled'] = false
|
|
@@ -51,7 +51,7 @@ class HttpAdapter {
|
|
|
51
51
|
const user = cds.context.user
|
|
52
52
|
if (required.some(role => user.has(role))) return next()
|
|
53
53
|
else if (user._is_anonymous) return next(401) // request login
|
|
54
|
-
else throw Object.assign(new Error, { code: 403, reason: `User '${user.id}' is lacking required roles: [${required}]
|
|
54
|
+
else throw Object.assign(new Error, { code: 403, reason: `User '${user.id}' is lacking required roles: [${required}]` })
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -45,7 +45,7 @@ const _buildPartialUrlFunctions = (url, data, params, kind = 'odata-v4') => {
|
|
|
45
45
|
: `${url}(${funcParams.join(',')})?${queryOptions.join('&')}`
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const _extractParamsFromData = (data, params = {}) => {
|
|
48
|
+
const _extractParamsFromData = (data = {}, params = {}) => {
|
|
49
49
|
return Object.keys(data).reduce((res, el) => {
|
|
50
50
|
if (params[el]) Object.assign(res, { [el]: data[el] })
|
|
51
51
|
return res
|
|
@@ -142,7 +142,16 @@ const _addHandlerActionFunction = (srv, def, target) => {
|
|
|
142
142
|
srv.on(event, target, async function (req) {
|
|
143
143
|
const shortEntityName = req.target.name.replace(`${this.definition.name}.`, '')
|
|
144
144
|
if (this.kind === 'odata-v2') return _handleV2BoundActionFunction(srv, def, req, event, this.kind)
|
|
145
|
-
|
|
145
|
+
|
|
146
|
+
const action = req.target.actions[req.event]
|
|
147
|
+
const onCollection = !!(
|
|
148
|
+
action['@cds.odata.bindingparameter.collection'] ||
|
|
149
|
+
(action?.params && [...action.params].some(p => p?.items?.type === '$self'))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const url = onCollection
|
|
153
|
+
? `/${shortEntityName}/${this.definition.name}.${event}`
|
|
154
|
+
: `/${shortEntityName}(${_buildKeys(req, this.kind).join(',')})/${this.definition.name}.${event}`
|
|
146
155
|
return _handleBoundActionFunction(srv, def, req, url)
|
|
147
156
|
})
|
|
148
157
|
} else {
|
|
@@ -38,7 +38,8 @@ module.exports = adapter => {
|
|
|
38
38
|
const headers = { ...cds.context.http.req.headers, ...req.headers }
|
|
39
39
|
|
|
40
40
|
// we need a cds.Request for multiple reasons, incl. params, headers, sap-messages, read after write, ...
|
|
41
|
-
|
|
41
|
+
// for UPDATE: data is already provided via query
|
|
42
|
+
const cdsReq = adapter.request4({ query, data: query.DELETE ? data : undefined, headers, params, req, res })
|
|
42
43
|
|
|
43
44
|
// NOTES:
|
|
44
45
|
// - only via srv.run in combination with srv.dispatch inside,
|