@sap/cds 9.2.1 → 9.3.0

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/_i18n/i18n_es.properties +3 -3
  3. package/_i18n/i18n_es_MX.properties +3 -3
  4. package/_i18n/i18n_fr.properties +2 -2
  5. package/_i18n/messages.properties +6 -0
  6. package/app/index.js +0 -1
  7. package/bin/deploy.js +1 -1
  8. package/bin/serve.js +7 -20
  9. package/lib/compile/cdsc.js +3 -0
  10. package/lib/compile/for/flows.js +102 -0
  11. package/lib/compile/for/nodejs.js +28 -0
  12. package/lib/compile/to/edm.js +11 -4
  13. package/lib/core/classes.js +1 -1
  14. package/lib/core/linked-csn.js +8 -0
  15. package/lib/dbs/cds-deploy.js +12 -12
  16. package/lib/env/cds-env.js +1 -1
  17. package/lib/env/cds-requires.js +21 -20
  18. package/lib/env/defaults.js +2 -1
  19. package/lib/index.js +5 -6
  20. package/lib/log/cds-log.js +6 -5
  21. package/lib/log/format/aspects/cf.js +2 -2
  22. package/lib/plugins.js +1 -1
  23. package/lib/ql/cds-ql.js +0 -3
  24. package/lib/req/request.js +3 -3
  25. package/lib/req/response.js +12 -7
  26. package/lib/srv/bindings.js +17 -17
  27. package/lib/srv/cds-connect.js +6 -9
  28. package/lib/srv/cds-serve.js +74 -137
  29. package/lib/srv/cds.Service.js +49 -0
  30. package/lib/srv/factory.js +4 -4
  31. package/lib/srv/middlewares/auth/ias-auth.js +29 -9
  32. package/lib/srv/middlewares/auth/index.js +3 -2
  33. package/lib/srv/middlewares/auth/jwt-auth.js +19 -6
  34. package/lib/srv/protocols/hcql.js +16 -1
  35. package/lib/srv/srv-dispatch.js +1 -1
  36. package/lib/utils/cds-utils.js +4 -8
  37. package/lib/utils/csv-reader.js +27 -7
  38. package/libx/_runtime/cds.js +0 -6
  39. package/libx/_runtime/common/Service.js +5 -0
  40. package/libx/_runtime/common/generic/crud.js +1 -1
  41. package/libx/_runtime/common/generic/flows.js +106 -0
  42. package/libx/_runtime/common/generic/paging.js +3 -3
  43. package/libx/_runtime/common/utils/differ.js +5 -15
  44. package/libx/_runtime/common/utils/resolveView.js +2 -2
  45. package/libx/_runtime/fiori/lean-draft.js +76 -40
  46. package/libx/_runtime/messaging/enterprise-messaging.js +1 -1
  47. package/libx/_runtime/remote/Service.js +68 -62
  48. package/libx/_runtime/remote/utils/client.js +29 -216
  49. package/libx/_runtime/remote/utils/query.js +197 -0
  50. package/libx/_runtime/ucl/Service.js +180 -112
  51. package/libx/_runtime/ucl/queries.js +61 -0
  52. package/libx/odata/ODataAdapter.js +1 -4
  53. package/libx/odata/index.js +2 -10
  54. package/libx/odata/middleware/error.js +8 -1
  55. package/libx/odata/middleware/stream.js +1 -1
  56. package/libx/odata/middleware/update.js +12 -2
  57. package/libx/odata/parse/afterburner.js +113 -20
  58. package/libx/odata/parse/cqn2odata.js +1 -3
  59. package/libx/rest/middleware/parse.js +9 -2
  60. package/package.json +2 -2
  61. package/server.js +2 -0
  62. package/srv/app-service.js +1 -0
  63. package/srv/db-service.js +1 -0
  64. package/srv/msg-service.js +1 -0
  65. package/srv/remote-service.js +1 -0
  66. package/srv/ucl-service.cds +32 -0
  67. package/srv/ucl-service.js +1 -0
  68. package/lib/ql/resolve.js +0 -45
  69. package/libx/common/assert/type-strict.js +0 -109
  70. package/libx/common/assert/utils.js +0 -60
@@ -383,10 +383,15 @@ const _replaceStreams = result => {
383
383
  }
384
384
 
385
385
  const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestData, draftsRef) => {
386
- const prefixRef = []
386
+ const txModel = cds.context.tx.model
387
+ let targetEntity = txModel.definitions[draftsRef[0].id || draftsRef[0]]
388
+
389
+ // The 'target' of validation errors needs to specify the full path from the draft root
390
+ // > Since success of establishment of containment can't be guaranteed, we clean up messages
391
+ if (!targetEntity.actions.draftActivate) return []
387
392
 
388
393
  // Determine the path prefix required for a fully qualified validation message 'target'
389
- let targetEntity = cds.context.tx.model.definitions[draftsRef[0].id || draftsRef[0]]
394
+ const prefixRef = []
390
395
  for (let refIdx = 0; refIdx < draftsRef.length; refIdx++) {
391
396
  const dRef = draftsRef[refIdx],
392
397
  dRefId = dRef.id ?? dRef
@@ -398,8 +403,7 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
398
403
  const pRef = { where: [] }
399
404
 
400
405
  if (dRefId === targetEntity.name) {
401
- if (!targetEntity.isDraft) pRef.id = targetEntity.name.replace(`${targetEntity._service.name}.`, '')
402
- else pRef.id = targetEntity.actives.name.replace(`${targetEntity.actives._service.name}.`, '')
406
+ pRef.id = targetEntity.isDraft ? targetEntity.actives.name : targetEntity.name
403
407
  } else pRef.id = dRefId
404
408
 
405
409
  if (targetEntity.isDraft) targetEntity = targetEntity.actives
@@ -413,6 +417,7 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
413
417
  if (v === undefined)
414
418
  if (key.name === 'IsActiveEntity') v = false
415
419
  else return null
420
+ if (v instanceof Buffer) v = v.toString('base64') //> convert binary keys to base64
416
421
  if (pRef.where.length > 0) pRef.where.push('and')
417
422
  pRef.where.push(key.name, '=', v)
418
423
  }
@@ -427,16 +432,16 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
427
432
  const nextMessages = []
428
433
 
429
434
  // Collect messages that were created during the most recent validation run
430
- const newMessagesByCodeAndTarget = newMessages.reduce((acc, message) => {
431
- message.numericSeverity ??= 4
435
+ const newMessagesByMessageKeyAndTarget = newMessages.reduce((acc, message) => {
436
+ message.numericSeverity ??= message['@Common.numericSeverity'] ?? 4
437
+ if (message['@Common.additionalTargets']) message.additionalTargets ??= message['@Common.additionalTargets']
438
+ message.additionalTargets = message.additionalTargets?.map(t => (t.startsWith('in/') ? t.slice(3) : t))
439
+ delete message['@Common.additionalTargets']
432
440
 
433
441
  // Handle validation messages produced during draftActivate, that went through error normalization already
434
442
  // > We must not store pre-localized data in DraftAdministrativeData.DraftMessages
435
443
  const messageTarget = message.target.startsWith('in/') ? message.target.slice(3) : message.target
436
- if (message.code) {
437
- message.message = message.code
438
- delete message.code
439
- }
444
+ if (message.code) delete message.code // > Expect _only_ message to be set and contain the code
440
445
 
441
446
  // Process the message target produced by validation
442
447
  // > The message target contains the relative path of the erroneous entity
@@ -451,23 +456,36 @@ const _compileUpdatedDraftMessages = (newMessages, persistedMessages, requestDat
451
456
  if (tRef.where.length > 0) tRef.where.push('and', 'IsActiveEntity', '=', false)
452
457
  }
453
458
 
454
- message.prefix = cds.odata.urlify({ SELECT: { from: { ref: messagePrefixRef } } }).path
459
+ message.prefix = cds.odata.urlify(
460
+ { SELECT: { from: { ref: messagePrefixRef } } },
461
+ { kind: 'odata-v4', model: txModel }
462
+ ).path
455
463
 
456
464
  nextMessages.push(message)
457
465
 
458
466
  return acc.set(`${message.message}:${message.prefix}:${message.target}`, message)
459
467
  }, new Map())
460
468
 
461
- const draftMessageTargetPrefix = cds.odata.urlify({ SELECT: { from: { ref: prefixRef } } }).path
469
+ const draftMessageTargetPrefix = cds.odata.urlify(
470
+ { SELECT: { from: { ref: prefixRef } } },
471
+ { kind: 'odata-v4', model: txModel }
472
+ ).path
462
473
 
463
474
  // Merge new messages with persisted ones & eliminate outdated ones
464
475
  const simplePathElements = prefixRef.map(pRef => pRef.id)
465
476
  for (const message of persistedMessages) {
466
477
  // Drop persisted draft messages that are replaced by new ones
467
- if (newMessagesByCodeAndTarget.has(`${message.message}:${message.prefix}:${message.target}`)) continue
478
+ if (newMessagesByMessageKeyAndTarget.has(`${message.message}:${message.prefix}:${message.target}`)) continue
479
+ const hasNewMessageForAdditionalTarget = message.additionalTargets?.some(target =>
480
+ newMessagesByMessageKeyAndTarget.has(`${message.message}:${message.prefix}:${target}`)
481
+ )
482
+ if (hasNewMessageForAdditionalTarget) continue
468
483
 
469
484
  // Drop persisted draft messages where the value of the target field changed without a new error
470
- if (message.prefix === draftMessageTargetPrefix && requestData[message.target] !== undefined) continue
485
+ if (message.prefix === draftMessageTargetPrefix) {
486
+ if (requestData[message.target] !== undefined) continue
487
+ if (message.additionalTargets?.some(target => requestData[target] !== undefined)) continue
488
+ }
471
489
 
472
490
  // Drop persisted draft messages, whose target's use navigations, where the value of the target field changed without a new error
473
491
  const messageSimplePathElements = message.prefix.replaceAll(/\([^(]*\)/g, '').split('/')
@@ -562,9 +580,7 @@ const handle = async function (req) {
562
580
  if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
563
581
  if (_req.target.isDraft && (_req.event === 'UPDATE' || _req.event === 'NEW')) {
564
582
  // Degrade all errors to messages & prevent !!req.errors into req.reject() in dispatch
565
- _req.error = (...args) => {
566
- for (const err of args) _req._messages.add(4, err)
567
- }
583
+ _req.error = (...args) => _req._messages.add(4, ...args)
568
584
  }
569
585
  }
570
586
 
@@ -681,7 +697,10 @@ const handle = async function (req) {
681
697
  pRef.where.push('and', 'IsActiveEntity', '=', false)
682
698
  return pRef
683
699
  })
684
- const messageTargetPrefix = cds.odata.urlify({ SELECT: { from: { ref: prefixRef } } }).path
700
+ const messageTargetPrefix = cds.odata.urlify(
701
+ { SELECT: { from: { ref: prefixRef } } },
702
+ { kind: 'odata-v4', model: req.context.tx.model }
703
+ ).path
685
704
 
686
705
  const draftData = await SELECT.one
687
706
  .from({ ref: _redirectRefToDrafts(query.DELETE.from.ref, this.model) }) // REVISIT: Avoid redundant redirect
@@ -694,7 +713,8 @@ const handle = async function (req) {
694
713
  const persistedDraftMessages = draftData?.DraftAdministrativeData?.DraftMessages || []
695
714
 
696
715
  if (draftAdminDataUUID && persistedDraftMessages?.length) {
697
- const nextDraftMessages = persistedDraftMessages.filter(msg => !msg.prefix.startsWith(messageTargetPrefix))
716
+ let nextDraftMessages = persistedDraftMessages.filter(msg => !msg.prefix.startsWith(messageTargetPrefix))
717
+ if (!req.target.actions?.draftActivate) nextDraftMessages = []
698
718
 
699
719
  await UPDATE('DRAFT.DraftAdministrativeData')
700
720
  .set({ DraftMessages: nextDraftMessages })
@@ -890,7 +910,10 @@ const handle = async function (req) {
890
910
  if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && _req.errors)
891
911
  _req.on('failed', async () => {
892
912
  const nextDraftMessages = _compileUpdatedDraftMessages(
893
- _req.errors.map(e => ({ ...e })),
913
+ // Errors procesed during 'failed' will have undergone error._normalize at this point
914
+ // > We need to revert the code - message swap _normalize includes
915
+ // > This is required to ensure, no localized messages are persisted and redundant localization is avoided
916
+ _req.errors.map(e => ({ message: e.code ?? e.message, target: e.target, args: e.args, i18n: e.i18n })),
894
917
  persistedDraftMessages,
895
918
  {},
896
919
  draftRef
@@ -999,7 +1022,16 @@ const handle = async function (req) {
999
1022
  })
1000
1023
  }
1001
1024
 
1002
- await run(UPDATE({ ref: draftsRef }).data(req.data))
1025
+ const innerQuery = UPDATE({ ref: draftsRef }).data(req.data)
1026
+ const innerReq = _newReq(req, innerQuery, draftParams, {})
1027
+ await h.call(this, innerReq).catch(error => {
1028
+ // > This prevents thrown req.errors from being added to 'sap-messages'
1029
+ // > Downgraded req.error will add to req.messages, regardless of whether the error is thrown
1030
+ // > Unless we prevent it here, the response will contain the error in both body and header
1031
+ // > Downgrading is only required for PATCH, to prevent a rollback in case validation fails
1032
+ if (req.messages?.length) req.messages = req.messages.filter(m => m !== error)
1033
+ throw req.error(error)
1034
+ })
1003
1035
 
1004
1036
  const nextDraftAdminData = {
1005
1037
  InProcessByUser: req.user.id,
@@ -1156,11 +1188,7 @@ const Read = {
1156
1188
  let drafts
1157
1189
  if (ignoreDrafts) drafts = []
1158
1190
  else {
1159
- try {
1160
- drafts = await Read.complementaryDrafts(query, actives)
1161
- } catch {
1162
- drafts = []
1163
- }
1191
+ drafts = await Read.complementaryDrafts(query, actives)
1164
1192
  }
1165
1193
  Read.merge(query._target, actives, drafts, (row, other) => {
1166
1194
  if (other) {
@@ -1218,7 +1246,12 @@ const Read = {
1218
1246
  return result
1219
1247
  }
1220
1248
 
1221
- if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages) {
1249
+ const selectedColumnNames = query._drafts.SELECT.columns?.map(c => c.ref?.[0] || c) || ['*']
1250
+ const isAllKeysSelected =
1251
+ selectedColumnNames.includes('*') ||
1252
+ Object.keys(query._drafts._target.keys).every(k => selectedColumnNames.includes(k))
1253
+
1254
+ if (cds.model.definitions['DRAFT.DraftAdministrativeData'].elements.DraftMessages && isAllKeysSelected) {
1222
1255
  // Replace selection of 'DraftMessages' with the proper path expression
1223
1256
 
1224
1257
  query._drafts.SELECT.columns = [
@@ -1256,6 +1289,8 @@ const Read = {
1256
1289
  const messageSimplePath = msg.prefix.replaceAll(/\([^(]*\)/g, '')
1257
1290
  if (!messageSimplePath.startsWith(simplePath)) return acc
1258
1291
  msg.target = `/${msg.prefix}/${msg.target}`
1292
+ if (msg.additionalTargets?.length)
1293
+ msg.additionalTargets = msg.additionalTargets.map(additionalTarget => `/${msg.prefix}/${additionalTarget}`)
1259
1294
  delete msg.prefix
1260
1295
  delete msg.simplePathElements
1261
1296
  acc.push(msg)
@@ -1502,21 +1537,22 @@ const Read = {
1502
1537
  complementaryDrafts: (query, _actives) => {
1503
1538
  const actives = Array.isArray(_actives) ? _actives : [_actives]
1504
1539
  if (!actives.length) return []
1505
- const drafts = cds.ql.clone(query._drafts)
1540
+ const drafts = SELECT.from(query._target.drafts)
1541
+ drafts[$draftParams] = query[$draftParams]
1542
+ if (query._srv) drafts._srv = query._srv
1506
1543
  drafts.SELECT.where = Read.whereIn(query._target, actives)
1507
1544
  const newColumns = _entityKeys(query._target).map(k => ({ ref: [k] }))
1508
- if (
1509
- !drafts.SELECT.columns ||
1510
- drafts.SELECT.columns.some(c => c === '*' || c.ref?.[0] === 'DraftAdministrativeData_DraftUUID')
1511
- )
1512
- newColumns.push({ ref: ['DraftAdministrativeData_DraftUUID'] })
1513
- const draftAdmin = drafts.SELECT.columns?.find(c => c.ref?.[0] === 'DraftAdministrativeData')
1514
- if (draftAdmin) newColumns.push(draftAdmin)
1545
+ const queryDrafts = query._drafts
1546
+ if (queryDrafts) {
1547
+ if (
1548
+ !queryDrafts.SELECT.columns ||
1549
+ queryDrafts.SELECT.columns.some(c => c === '*' || c.ref?.[0] === 'DraftAdministrativeData_DraftUUID')
1550
+ )
1551
+ newColumns.push({ ref: ['DraftAdministrativeData_DraftUUID'] })
1552
+ const draftAdmin = queryDrafts.SELECT.columns?.find(c => c.ref?.[0] === 'DraftAdministrativeData')
1553
+ if (draftAdmin) newColumns.push(draftAdmin)
1554
+ }
1515
1555
  drafts.SELECT.columns = newColumns
1516
- drafts.SELECT.count = undefined
1517
- drafts.SELECT.search = undefined
1518
- drafts.SELECT.one = undefined
1519
- drafts.SELECT.recurse = undefined
1520
1556
  return drafts
1521
1557
  },
1522
1558
 
@@ -2073,7 +2109,7 @@ async function onEdit(req) {
2073
2109
  res[key] = keyData[key]
2074
2110
  return res
2075
2111
  }, {})
2076
- const transition = cds.ql.resolve.transitions(req.query, cds.db)
2112
+ const transition = cds.db.resolve.transitions(req.query)
2077
2113
 
2078
2114
  // gets the underlying target entity, as record locking can't be
2079
2115
  // applied to localized views
@@ -30,7 +30,7 @@ const _checkAppURL = appURL => {
30
30
  // REVISIT: It's bad to have to rely on the subdomain.
31
31
  // For all interactions where we perform the token exchange ourselves,
32
32
  // we will be able to use the zoneId instead of the subdomain.
33
- const _subdomainFromContext = context => context?.http.req?.authInfo?.getSubdomain?.()
33
+ const _subdomainFromContext = context => context?.user?.authInfo?.getSubdomain?.()
34
34
 
35
35
  class EnterpriseMessaging extends AMQPWebhookMessaging {
36
36
  init() {
@@ -1,20 +1,18 @@
1
1
  const cds = require('../cds')
2
-
3
- const { run, getReqOptions } = require('./utils/client')
4
2
  const { getCloudSdk, getCloudSdkConnectivity, getCloudSdkResilience } = require('./utils/cloudSdkProvider')
3
+ const { run } = require('./utils/client')
4
+ const { extractRequestConfig } = require('./utils/query')
5
5
  const { hasAliasedColumns } = require('./utils/data')
6
6
  const postProcess = require('../common/utils/postProcess')
7
7
  const { formatVal } = require('../../odata/utils')
8
8
 
9
9
  const _getHeaders = (defaultHeaders, req) => {
10
- return Object.assign(
11
- {},
12
- defaultHeaders,
13
- Object.keys(req.headers).reduce((acc, cur) => {
14
- acc[cur.toLowerCase()] = req.headers[cur]
15
- return acc
16
- }, {})
17
- )
10
+ const normalizedHeaders = Object.keys(req.headers).reduce((acc, cur) => {
11
+ acc[cur.toLowerCase()] = req.headers[cur]
12
+ return acc
13
+ }, {})
14
+
15
+ return { ...defaultHeaders, ...normalizedHeaders }
18
16
  }
19
17
 
20
18
  const _setCorrectValue = (el, data, params, kind) => {
@@ -159,25 +157,23 @@ const _isSelectWithAliasedColumns = q => q?.SELECT && !q._transitions && q.SELEC
159
157
 
160
158
  const resolvedTargetOfQuery = q => q?._transitions?.at(-1)?.target
161
159
 
162
- const _resolveSelectionStrategy = options => {
163
- if (typeof options?.selectionStrategy !== 'string') return
164
-
165
- options.selectionStrategy = getCloudSdkConnectivity().DestinationSelectionStrategies[options.selectionStrategy]
166
- if (typeof options?.selectionStrategy !== 'function') {
167
- throw new Error(`Unsupported destination selection strategy "${options.selectionStrategy}".`)
168
- }
160
+ const _resolveSelectionStrategy = selectionStrategy => {
161
+ const { DestinationSelectionStrategies } = getCloudSdkConnectivity()
162
+ const strategy = DestinationSelectionStrategies[selectionStrategy]
163
+ if (typeof strategy !== 'function')
164
+ throw new Error(`Unsupported destination selection strategy "${selectionStrategy}".`)
165
+ return strategy
169
166
  }
170
167
 
171
168
  const _getKind = options => {
172
- const kind = (options.credentials && options.credentials.kind) || options.kind
169
+ let kind = options.credentials?.kind ?? options.kind
173
170
  if (typeof kind === 'object') {
174
- const k = Object.keys(kind).find(
175
- key => key === 'odata' || key === 'odata-v4' || key === 'odata-v2' || key === 'rest'
171
+ kind = Object.keys(kind).find(
172
+ key => key === 'odata' || key === 'odata-v4' || key === 'odata-v2' || key === 'rest' || key === 'hcql'
176
173
  )
177
- // odata-v4 is equivalent of odata
178
- return k === 'odata-v4' ? 'odata' : k
179
174
  }
180
- return kind
175
+ // odata-v4 is equivalent of odata
176
+ return kind === 'odata-v4' ? 'odata' : kind
181
177
  }
182
178
 
183
179
  const _getDestination = (name, credentials) => {
@@ -188,49 +184,48 @@ const _getDestination = (name, credentials) => {
188
184
 
189
185
  class RemoteService extends cds.Service {
190
186
  init() {
187
+ if (([...this.entities].length || [...this.operations].length) && !this.options.credentials)
188
+ throw new Error(`No credentials configured for "${this.name}".`)
189
+
191
190
  this.kind = _getKind(this.options) // TODO: Simplify
192
191
 
193
- /*
194
- * set up connectivity stuff if credentials are provided
195
- * throw error if no credentials are provided and the service has at least one entity or one action/function
196
- */
197
192
  if (this.options.credentials) {
198
193
  this.datasource = this.options.datasource
199
- this.destinationOptions = this.options.destinationOptions
200
- _resolveSelectionStrategy(this.destinationOptions)
201
- this.destination =
202
- this.options.credentials.destination ??
203
- _getDestination(this.definition?.name ?? this.datasource, this.options.credentials)
194
+
195
+ this.destinationOptions = this.options.destinationOptions || {}
196
+ if (typeof this.destinationOptions.selectionStrategy === 'string') {
197
+ this.destinationOptions.selectionStrategy = _resolveSelectionStrategy(this.destinationOptions.selectionStrategy)
198
+ }
199
+
200
+ if (this.options.credentials.destination) this.destination = this.options.credentials.destination
201
+ else this.destination = _getDestination(this.definition?.name ?? this.datasource, this.options.credentials)
202
+
204
203
  this.path = this.options.credentials.path
205
204
 
206
- // `requestTimeout` API is kept as it was public
205
+ // API `requestTimeout` is kept because it once was public
207
206
  this.requestTimeout = this.options.credentials.requestTimeout ?? 60000
208
207
 
209
208
  this.csrf = this.options.csrf
210
209
  this.csrfInBatch = this.options.csrfInBatch
211
210
 
212
- // we're using this as an object to allow remote services without the need for Cloud SDK
213
- // required for BAS creating remote services only for events
214
- // at first request the middlewares are created
211
+ // We're using this as an object to allow remote services without the need for Cloud SDK
212
+ // This is required for BAS creating remote services only for events
213
+ // Middlewares are created at time of the first request, on the fly
215
214
  this.middlewares = {
216
215
  timeout: getCloudSdkResilience().timeout(this.requestTimeout),
217
216
  csrf: this.csrf && getCloudSdk().csrf(this.csrf)
218
217
  }
219
- } else if ([...this.entities].length || [...this.operations].length) {
220
- throw new Error(`No credentials configured for "${this.name}".`)
221
218
  }
222
219
 
220
+ // Add 'initial' handler to clear keys from data
223
221
  const clearKeysFromData = function (req) {
224
222
  if (req.target && req.target.keys) for (const k of Object.keys(req.target.keys)) delete req.data[k]
225
223
  }
226
224
  this.before('UPDATE', '*', Object.assign(clearKeysFromData, { _initial: true }))
227
225
 
228
- for (const each of this.entities) {
229
- for (const a in each.actions) {
230
- _addHandlerActionFunction(this, each.actions[a], each)
231
- }
232
- }
233
-
226
+ // Add handlers for bound & unbound operations
227
+ for (const each of this.entities)
228
+ for (const a in each.actions) _addHandlerActionFunction(this, each.actions[a], each)
234
229
  for (const each of this.operations) _addHandlerActionFunction(this, each)
235
230
 
236
231
  // IMPORTANT: regular function is used on purpose, don't switch to arrow function.
@@ -238,39 +233,50 @@ class RemoteService extends cds.Service {
238
233
  const { query } = req
239
234
  if (!query && !(typeof req.path === 'string')) return next()
240
235
 
241
- // early validation on first request for use case without remote API
242
- // ideally, that's done on bootstrap of the remote service
236
+ // Early validation on first request for use case without remote API
237
+ // Ideally, that's done on bootstrap of the remote service
243
238
  if (typeof this.destination === 'object' && !this.destination.url)
244
239
  throw new Error(`"url" or "destination" property must be configured in "credentials" of "${this.name}".`)
245
240
 
246
- const reqOptions = getReqOptions(req, query, this)
247
- reqOptions.headers = _getHeaders(reqOptions.headers, req)
241
+ const requestConfig = extractRequestConfig(req, query, this)
242
+ requestConfig.headers = _getHeaders(requestConfig.headers, req)
248
243
 
249
244
  // REVISIT: we should not have to set the content-type at all for that
250
- if (reqOptions.headers.accept?.match(/stream|image|tar/)) reqOptions.responseType = 'stream'
251
-
252
- // ensure request correlation (even with systems that use x-correlationid)
253
- const correlationId = reqOptions.headers['x-correlation-id'] || cds.context?.id //> prefer custom header over context id
254
- reqOptions.headers['x-correlation-id'] = correlationId
255
- reqOptions.headers['x-correlationid'] = correlationId
256
-
257
- const { kind, destination, destinationOptions } = this
258
- const resolvedTarget =
259
- resolvedTargetOfQuery(query) || cds.ql.resolve.transitions(query, this)?.target || req.target
260
- const returnType = req._returnType
261
- const additionalOptions = { destination, kind, resolvedTarget, returnType, destinationOptions }
245
+ if (requestConfig.headers.accept?.match(/stream|image|tar/)) requestConfig.responseType = 'stream'
246
+
247
+ // Ensure request correlation (even with systems that use x-correlationid)
248
+ const correlationId = requestConfig.headers['x-correlation-id'] || cds.context?.id
249
+ requestConfig.headers['x-correlation-id'] = correlationId
250
+ requestConfig.headers['x-correlationid'] = correlationId
251
+
252
+ // Compile Cloud SDK options
253
+ const additionalOptions = {
254
+ kind: this.kind,
255
+ destination: this.destination,
256
+ destinationOptions: this.destinationOptions,
257
+ resolvedTarget:
258
+ resolvedTargetOfQuery(query) ||
259
+ (typeof query === 'object' ? cds.infer.target(query) || query?._target : undefined) ||
260
+ req.target,
261
+ returnType: req._returnType
262
+ }
262
263
 
263
264
  // REVISIT: i don't believe req.context.headers is an official API
264
265
  let jwt = req?.context?.headers?.authorization?.split(/^bearer /i)[1]
265
266
  if (!jwt) jwt = req?.context?.http?.req?.headers?.authorization?.split(/^bearer /i)[1]
266
267
  if (jwt) additionalOptions.jwt = jwt
267
268
 
268
- // hidden compat flag in order to suppress logging response body of failed request
269
+ // Mount resilience and csrf middlewares for SAP Cloud SDK
270
+ requestConfig.middleware = [this.middlewares.timeout]
271
+ const fetchCsrfToken = !!(requestConfig._autoBatchedGet ? this.csrfInBatch : this.csrf)
272
+ if (fetchCsrfToken) requestConfig.middleware.push(this.middlewares.csrf)
273
+
274
+ // Hidden compat flag to suppress logging the response body of failed request
269
275
  if (req._suppressRemoteResponseBody) {
270
276
  additionalOptions.suppressRemoteResponseBody = req._suppressRemoteResponseBody
271
277
  }
272
278
 
273
- let result = await run(reqOptions, additionalOptions)
279
+ let result = await run(requestConfig, additionalOptions)
274
280
  result = typeof query === 'object' && query.SELECT?.one && Array.isArray(result) ? result[0] : result
275
281
  return result
276
282
  })