@sap/cds 8.1.1 → 8.2.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/app/index.css +3 -0
  3. package/app/index.js +50 -4
  4. package/bin/serve.js +1 -1
  5. package/lib/compile/cdsc.js +2 -2
  6. package/lib/compile/etc/_localized.js +1 -1
  7. package/lib/compile/for/lean_drafts.js +1 -0
  8. package/lib/compile/to/sql.js +2 -2
  9. package/lib/env/cds-requires.js +6 -0
  10. package/lib/env/defaults.js +14 -3
  11. package/lib/env/plugins.js +6 -22
  12. package/lib/linked/classes.js +0 -14
  13. package/lib/linked/types.js +12 -0
  14. package/lib/linked/validate.js +13 -8
  15. package/lib/log/cds-log.js +3 -3
  16. package/lib/log/format/aspects/als.js +23 -29
  17. package/lib/log/format/aspects/cls.js +9 -0
  18. package/lib/log/format/json.js +42 -6
  19. package/lib/ql/Whereable.js +5 -1
  20. package/lib/srv/cds-connect.js +33 -32
  21. package/lib/srv/cds-serve.js +2 -1
  22. package/lib/srv/middlewares/cds-context.js +2 -1
  23. package/lib/utils/cds-utils.js +4 -2
  24. package/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-commons/validator/ValueValidator.js +1 -1
  25. package/libx/_runtime/cds-services/adapter/odata-v4/utils/metaInfo.js +1 -5
  26. package/libx/_runtime/cds-services/adapter/odata-v4/utils/result.js +2 -31
  27. package/libx/_runtime/common/generic/auth/utils.js +2 -0
  28. package/libx/_runtime/common/generic/input.js +2 -11
  29. package/libx/_runtime/common/generic/put.js +1 -10
  30. package/libx/_runtime/common/utils/binary.js +1 -7
  31. package/libx/_runtime/common/utils/resolveView.js +2 -2
  32. package/libx/_runtime/common/utils/search2cqn4sql.js +1 -1
  33. package/libx/_runtime/common/utils/streamProp.js +19 -6
  34. package/libx/_runtime/common/utils/template.js +26 -16
  35. package/libx/_runtime/common/utils/templateProcessor.js +8 -7
  36. package/libx/_runtime/common/utils/ucsn.js +2 -5
  37. package/libx/_runtime/db/expand/expandCQNToJoin.js +10 -0
  38. package/libx/_runtime/db/generic/input.js +1 -5
  39. package/libx/_runtime/fiori/lean-draft.js +272 -90
  40. package/libx/_runtime/messaging/event-broker.js +105 -40
  41. package/libx/_runtime/remote/utils/client.js +12 -4
  42. package/libx/_runtime/ucl/Service.js +16 -6
  43. package/libx/odata/middleware/batch.js +2 -2
  44. package/libx/odata/middleware/read.js +6 -10
  45. package/libx/odata/middleware/stream.js +4 -5
  46. package/libx/odata/parse/afterburner.js +3 -2
  47. package/libx/odata/parse/multipartToJson.js +3 -1
  48. package/libx/odata/utils/index.js +3 -3
  49. package/libx/odata/utils/postProcess.js +3 -25
  50. package/libx/rest/middleware/parse.js +1 -6
  51. package/package.json +2 -2
@@ -5,6 +5,8 @@ const express = require('express')
5
5
  const https = require('https')
6
6
  const crypto = require('crypto')
7
7
 
8
+ const usedWebhookEndpoints = new Set()
9
+
8
10
  async function request(options, data) {
9
11
  return new Promise((resolve, reject) => {
10
12
  const req = https.request(options, res => {
@@ -53,29 +55,32 @@ function _validateCertificate(req, res, next) {
53
55
  )
54
56
  const clientCert = clientCertObj.toLegacyObject()
55
57
 
56
- if (!this.isMultitenancy && !clientCertObj.checkPrivateKey(this.privateKey))
58
+ if (!this.isMultitenancy && !clientCertObj.checkPrivateKey(this.auth.privateKey))
57
59
  return res.status(401).josn({ message: 'Authentication Failed' })
58
60
 
59
61
  const cfSubject = Buffer.from(req.headers['x-ssl-client-subject-cn'], 'base64').toString()
60
- if (this.validationCert.subject.CN !== clientCert.subject.CN || this.validationCert.subject.CN !== cfSubject) {
62
+ if (
63
+ this.auth.validationCert.subject.CN !== clientCert.subject.CN ||
64
+ this.auth.validationCert.subject.CN !== cfSubject
65
+ ) {
61
66
  this.LOG.info('certificate subject does not match')
62
67
  return res.status(401).json({ message: 'Authentication Failed' })
63
68
  }
64
69
  this.LOG.debug('incoming Subject CN is valid.')
65
70
 
66
- if (this.validationCert.issuer.CN !== clientCert.issuer.CN) {
71
+ if (this.auth.validationCert.issuer.CN !== clientCert.issuer.CN) {
67
72
  this.LOG.info('Certificate issuer subject does not match')
68
73
  return res.status(401).json({ message: 'Authentication Failed' })
69
74
  }
70
75
  this.LOG.debug('incoming issuer subject CN is valid.')
71
76
 
72
- if (this.validationCert.issuer.O !== clientCert.issuer.O) {
77
+ if (this.auth.validationCert.issuer.O !== clientCert.issuer.O) {
73
78
  this.LOG.info('Certificate issuer org does not match')
74
79
  return res.status(401).json({ message: 'Authentication Failed' })
75
80
  }
76
81
  this.LOG.debug('incoming Issuer Org is valid.')
77
82
 
78
- if (this.validationCert.issuer.OU !== clientCert.issuer.OU) {
83
+ if (this.auth.validationCert.issuer.OU !== clientCert.issuer.OU) {
79
84
  this.LOG.info('certificate issuer OU does not match')
80
85
  return res.status(401).json({ message: 'Authentication Failed' })
81
86
  }
@@ -93,49 +98,79 @@ function _validateCertificate(req, res, next) {
93
98
  }
94
99
  }
95
100
 
96
- let instantiated = false
97
-
98
101
  class EventBroker extends cds.MessagingService {
99
102
  async init() {
100
- // TODO: Only needed if there are subscriptions
101
- if (instantiated)
102
- throw new Error('Event Broker service must be a singleton service, you cannot have more than one instance.')
103
- instantiated = true
104
103
  await super.init()
105
104
  cds.once('listening', () => {
106
105
  this.startListening()
107
106
  })
108
- this.agent = this.getAgent()
109
- this.isMultitenancy = cds.requires.multitenancy || cds.env.profiles.includes('mtx-sidecar')
110
- this.validationCert = new crypto.X509Certificate(
111
- this.isMultitenancy ? this.options.credentials.certificate : this.agent.options.cert
112
- ).toLegacyObject()
113
- this.privateKey = !this.isMultitenancy && crypto.createPrivateKey(this.agent.options.key)
114
- }
115
-
116
- getAgent() {
117
- try {
118
- if (this.options.x509.certPath && this.options.x509.pkeyPath) {
119
- return new https.Agent({
120
- cert: cds.utils.fs.readFileSync(cds.utils.path.resolve(cds.root, this.options.x509.certPath)),
121
- key: cds.utils.fs.readFileSync(cds.utils.path.resolve(cds.root, this.options.x509.pkeyPath))
122
- })
107
+ this.isMultitenancy = cds.env.requires.multitenancy || cds.env.profiles.includes('mtx-sidecar')
108
+
109
+ this.auth = {} // { kind: 'cert', validationCert?, privateKey? } or { kind: 'ias', ias }
110
+
111
+ // determine auth.kind
112
+ if (this.options.x509) {
113
+ if (!this.options.x509.cert && !this.options.x509.certPath)
114
+ throw new Error(`${this.name}: Event Broker with x509 option requires \`x509.cert\` or \`x509.certPath\`.`)
115
+ if (!this.options.x509.pkey && !this.options.x509.pkeyPath)
116
+ throw new Error(`${this.name}: Event Broker with x509 option requires \`x509.pkey\` or \`x509.pkeyPath\`.`)
117
+ this.auth.kind = 'cert' // byo cert, unofficial
118
+ } else {
119
+ let ias
120
+ for (const k in cds.env.requires) {
121
+ const r = cds.env.requires[k]
122
+ if (r.vcap?.label === 'identity' || r.kind === 'ias') ias = r
123
123
  }
124
- } catch (error) {
125
- if (this.LOG) this.LOG.error('GetCredentials', { error: error.message })
126
- throw error
124
+ // multitenant receiver-only services don't need x509, check for ias existence
125
+ if (!this.isMultitenancy || ias) {
126
+ this.auth.kind = 'ias'
127
+ this.auth.ias = ias
128
+ } else this.auth.kind = 'cert'
129
+ }
130
+
131
+ if (!this.auth.kind || (this.auth.kind === 'ias' && !this.auth.ias))
132
+ throw new Error(`${this.name}: Event Broker requires your app to be bound to an IAS instance.`) // do not mention byo cert
133
+
134
+ if (this.auth.kind === 'cert') {
135
+ if (this.isMultitenancy && !this.options.credentials?.certificate)
136
+ throw new Error(
137
+ `${this.name}: \`certificate\` not found in Event Broker binding information. You need to bind your app to an Event Broker instance.`
138
+ )
139
+ this.auth.validationCert = new crypto.X509Certificate(
140
+ this.isMultitenancy ? this.options.credentials.certificate : this.agent.options.cert
141
+ ).toLegacyObject()
142
+ this.auth.privateKey = !this.isMultitenancy && crypto.createPrivateKey(this.agent.options.key)
127
143
  }
144
+
145
+ this.LOG._debug && this.LOG.debug('using auth: ' + this.auth.kind)
146
+ }
147
+
148
+ get agent() {
149
+ return (this.__agentCache ??=
150
+ this.auth.kind === 'ias'
151
+ ? new https.Agent({ cert: this.auth.ias.credentials.certificate, key: this.auth.ias.credentials.key })
152
+ : new https.Agent({
153
+ cert:
154
+ this.options.x509.cert ??
155
+ cds.utils.fs.readFileSync(cds.utils.path.resolve(cds.root, this.options.x509.certPath)),
156
+ key:
157
+ this.options.x509.pkey ??
158
+ cds.utils.fs.readFileSync(cds.utils.path.resolve(cds.root, this.options.x509.pkeyPath))
159
+ }))
128
160
  }
129
161
 
130
162
  async handle(msg) {
131
163
  if (msg.inbound) return super.handle(msg)
164
+ if (!this.options.credentials) throw new Error(`${this.name}: No credentials found for Event Broker service.`)
165
+ if (!this.options.credentials.ceSource)
166
+ throw new Error(`${this.name}: Emitting events is not supported by Event Broker plan \`event-connectivity\`.`)
132
167
  const _msg = this.message4(msg)
133
168
  await this.emitToEventBroker(_msg)
134
169
  }
135
170
 
136
- async startListening() {
171
+ startListening() {
137
172
  if (!this._listenToAll.value && !this.subscribedTopics.size) return
138
- await this.registerWebhookEndpoints()
173
+ this.registerWebhookEndpoints()
139
174
  }
140
175
 
141
176
  async emitToEventBroker(msg) {
@@ -148,7 +183,6 @@ class EventBroker extends cds.MessagingService {
148
183
  // if (definition['@topic'] === topicOrEvent) return definition
149
184
  // }
150
185
 
151
- // TODO: What if we're in single tenant variant?
152
186
  try {
153
187
  const hostname = this.options.credentials.eventing.http.x509.url.replace(/^https?:\/\//, '')
154
188
 
@@ -185,31 +219,63 @@ class EventBroker extends cds.MessagingService {
185
219
  },
186
220
  agent: this.agent
187
221
  }
188
- this.LOG.debug('HTTP headers:', JSON.stringify(options.headers))
189
- this.LOG.debug('HTTP body:', JSON.stringify(msg.data))
222
+ if (this.LOG._debug) {
223
+ this.LOG.debug('HTTP headers:', JSON.stringify(options.headers))
224
+ this.LOG.debug('HTTP body:', JSON.stringify(msg.data))
225
+ }
190
226
  // what about headers?
191
227
  // TODO: Clarify if we should send `{ data, ...headers }` vs. `data` + HTTP headers (`ce-*`)
228
+ // Disadvantage with `data` + HTTP headers is that they're case insensitive -> information loss, but they're 'closer' to the cloudevents standard
192
229
  await request(options, { data: msg.data, ...headers }) // TODO: fetch does not work with mTLS as of today, requires another module. see https://github.com/nodejs/node/issues/48977
193
230
  if (this.LOG._info) this.LOG.info('Emit', { topic: msg.event })
194
231
  } catch (e) {
195
232
  this.LOG.error('Emit failed:', e.message)
233
+ throw e
196
234
  }
197
235
  }
198
236
 
199
237
  prepareHeaders(headers, event) {
200
238
  if (!('source' in headers)) {
201
239
  if (!this.options.credentials.ceSource)
202
- throw new Error(
203
- 'Cannot publish event because of missing source information, currently not part of binding information.'
204
- )
240
+ throw new Error(`${this.name}: Cannot emit event: Parameter \`ceSource\` not found in Event Broker binding.`)
205
241
  headers.source = `${this.options.credentials.ceSource[0]}/${cds.context.tenant}`
206
242
  }
207
243
  super.prepareHeaders(headers, event)
208
244
  }
209
245
 
210
- async registerWebhookEndpoints() {
246
+ registerWebhookEndpoints() {
211
247
  const webhookBasePath = this.options.webhookPath || '/-/cds/event-broker/webhook'
212
- cds.app.post(webhookBasePath, _validateCertificate.bind(this))
248
+ if (usedWebhookEndpoints.has(webhookBasePath))
249
+ throw new Error(
250
+ `${this.name}: Event Broker: Webhook endpoint already registered. Use a different one with \`options.webhookPath\`.`
251
+ )
252
+ usedWebhookEndpoints.add(webhookBasePath)
253
+ // auth
254
+ if (this.auth.kind === 'ias') {
255
+ const ias_auth = require('../../../lib/auth/ias-auth')
256
+ cds.app.use(webhookBasePath, cds.middlewares.context())
257
+ cds.app.use(webhookBasePath, ias_auth(this.auth.ias))
258
+ cds.app.use(webhookBasePath, (err, _req, res, next) => {
259
+ if (err.code === 401) return res.status(401).json({ message: 'Unauthorized' })
260
+ return next(err)
261
+ })
262
+ cds.app.use(webhookBasePath, (_req, res, next) => {
263
+ if (
264
+ cds.context.user.is('system-user') &&
265
+ cds.context.user.tokenInfo.azp === this.options.credentials.ias.clientId
266
+ ) {
267
+ // the token was fetched by event broker -> OK
268
+ return next()
269
+ }
270
+ if (cds.context.user.is('internal-user')) {
271
+ // the token was fetched by own credentials -> OK (for testing)
272
+ return next()
273
+ }
274
+ res.status(401).json({ message: 'Unauthorized' })
275
+ })
276
+ } else {
277
+ cds.app.post(webhookBasePath, _validateCertificate.bind(this))
278
+ }
213
279
  cds.app.post(webhookBasePath, express.json())
214
280
  cds.app.post(webhookBasePath, this.onEventReceived.bind(this))
215
281
  }
@@ -244,7 +310,6 @@ class EventBroker extends cds.MessagingService {
244
310
  } catch (e) {
245
311
  this.LOG.error('ERROR during inbound event processing:', e) // TODO: How does Event Broker do error handling?
246
312
  res.status(500).json({ message: 'Internal Server Error!' })
247
- throw e
248
313
  }
249
314
  }
250
315
  }
@@ -39,7 +39,11 @@ const _executeHttpRequest = async ({ requestConfig, destination, destinationOpti
39
39
  if (LOG._debug) {
40
40
  const req2log = { headers: _sanitizeHeaders({ ...requestConfig.headers }) }
41
41
  if (requestConfig.method !== 'GET' && requestConfig.method !== 'DELETE')
42
- req2log.data = requestConfig.data && SANITIZE_VALUES ? deepSanitize(requestConfig.data) : requestConfig.data
42
+ // In case of auto batch (only done for `GET` requests) no data is part of batch and for debugging URL is crucial
43
+ req2log.data =
44
+ requestConfig.data && SANITIZE_VALUES && !requestConfig._autoBatchedGet
45
+ ? deepSanitize(requestConfig.data)
46
+ : requestConfig.data
43
47
  LOG.debug(
44
48
  `${requestConfig.method} ${destination.url || `<${destination.destinationName}>`}${requestConfig.url}`,
45
49
  req2log
@@ -256,7 +260,7 @@ const run = async (requestConfig, options) => {
256
260
 
257
261
  // get result of $batch
258
262
  // does only support read requests as of now
259
- if (requestConfig._autoBatch) {
263
+ if (requestConfig._autoBatchedGet) {
260
264
  // response data splitted by empty lines
261
265
  // 1. entry contains batch id and batch headers
262
266
  // 2. entry contains request status code and request headers
@@ -409,7 +413,11 @@ const getReqOptions = (req, query, service) => {
409
413
  // batch envelope if needed
410
414
  const maxGetUrlLength = service.options.max_get_url_length ?? cds.env.remote?.max_get_url_length ?? 1028
411
415
  if (KINDS_SUPPORTING_BATCH[service.kind] && reqOptions.method === 'GET' && reqOptions.url.length > maxGetUrlLength) {
412
- reqOptions._autoBatch = true
416
+ LOG._debug &&
417
+ LOG.debug(
418
+ `URL of remote request exceeds the configured max length of ${maxGetUrlLength}. Converting it to a $batch request.`
419
+ )
420
+ reqOptions._autoBatchedGet = true
413
421
  reqOptions.data = [
414
422
  '--batch1',
415
423
  'Content-Type: application/http',
@@ -430,7 +438,7 @@ const getReqOptions = (req, query, service) => {
430
438
 
431
439
  // mount resilience and csrf middlewares for SAP Cloud SDK
432
440
  reqOptions.middleware = [service.middlewares.timeout]
433
- const fetchCsrfToken = !!(reqOptions._autoBatch ? service.csrfInBatch : service.csrf)
441
+ const fetchCsrfToken = !!(reqOptions._autoBatchedGet ? service.csrfInBatch : service.csrf)
434
442
  if (fetchCsrfToken) reqOptions.middleware.push(service.middlewares.csrf)
435
443
 
436
444
  if (service.path) reqOptions.url = `${encodeURI(service.path)}${reqOptions.url}`
@@ -22,17 +22,22 @@ class UCLService extends cds.Service {
22
22
  if (!this.options.credentials)
23
23
  throw new Error('No credentials found for the UCL service, please bind the service to your app.')
24
24
 
25
- if (!this.options.x509.certPath || !this.options.x509.pkeyPath)
26
- throw new Error('The UCL service requires the options `x509.certPath` and `x509.pkeyPath`.')
25
+ if (!this.options.x509.cert && !this.options.x509.certPath)
26
+ throw new Error('UCL requires `x509.cert` or `x509.certPath`.')
27
+ if (!this.options.x509.pkey && !this.options.x509.pkeyPath)
28
+ throw new Error('UCL requires `x509.pkey` or `x509.pkeyPath`.')
29
+
27
30
  const [cert, key] = await Promise.all([
28
- fs.readFile(cds.utils.path.resolve(cds.root, this.options.x509.certPath)),
29
- fs.readFile(cds.utils.path.resolve(cds.root, this.options.x509.pkeyPath))
31
+ this.options.x509.cert ?? fs.readFile(cds.utils.path.resolve(cds.root, this.options.x509.certPath)),
32
+ this.options.x509.pkey ?? fs.readFile(cds.utils.path.resolve(cds.root, this.options.x509.pkeyPath))
30
33
  ])
31
34
  this.agent = new https.Agent({ cert, key })
32
35
 
33
36
  const existingTemplate = await this.readTemplate()
34
37
  const template = existingTemplate ? await this.updateTemplate(existingTemplate) : await this.createTemplate() // TODO: Make sure return value is correct
35
38
 
39
+ if (!template) throw new Error('The UCL service could not create an application template.')
40
+
36
41
  cds.once('listening', async () => {
37
42
  const provisioning = await cds.connect.to('cds.xt.SaasProvisioningService')
38
43
  provisioning.prepend(() => {
@@ -68,7 +73,8 @@ class UCLService extends cds.Service {
68
73
  }
69
74
  `
70
75
  const variables = { key: 'xsappname', value: `"${xsappname}"` }
71
- return (await this._request(query, variables)).applicationTemplates.data[0]
76
+ const res = await this._request(query, variables)
77
+ if (res) return res.applicationTemplates.data[0]
72
78
  }
73
79
 
74
80
  async createTemplate() {
@@ -162,7 +168,10 @@ class UCLService extends cds.Service {
162
168
  headers: res.headers,
163
169
  body: Buffer.concat(chunks).toString()
164
170
  }
165
- resolve(JSON.parse(response.body).data)
171
+ const body = JSON.parse(response.body)
172
+ if (body.errors)
173
+ throw new Error('Request to UCL service failed with:\n' + JSON.stringify(body.errors, null, 2))
174
+ resolve(body.data)
166
175
  })
167
176
  })
168
177
 
@@ -201,6 +210,7 @@ class UCLService extends cds.Service {
201
210
  ) {
202
211
  id
203
212
  name
213
+ labels
204
214
  description
205
215
  applicationInput
206
216
  }
@@ -397,7 +397,7 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
397
397
  const { target } = cqn
398
398
  const keyString =
399
399
  '(' +
400
- target.keys
400
+ [...target.keys]
401
401
  .filter(k => !k.isAssociation)
402
402
  .map(k => {
403
403
  let v = dependentOnResult[k.name]
@@ -483,7 +483,7 @@ const _formatResponseMultipart = request => {
483
483
  // REVISIT: tests require specific sequence
484
484
  const headers = {
485
485
  ...response.getHeaders(),
486
- 'content-type': 'application/json;odata.metadata=minimal' //> REVISIT: expected by tests
486
+ ...(response.statusCode !== 204 && { 'content-type': 'application/json;odata.metadata=minimal' })
487
487
  }
488
488
  delete headers['content-length'] //> REVISIT: expected by tests
489
489
 
@@ -19,16 +19,12 @@ const _getCount = result =>
19
19
  }, 0)
20
20
  : result.$count || result._counted_ || 0
21
21
 
22
- const _calculateNextLink = (req, result) => {
22
+ const _setNextLink = (req, result) => {
23
23
  const $skiptoken = result.$nextLink ?? _calculateSkiptoken(req, result)
24
- if ($skiptoken) {
25
- const queryParamsWithSkipToken = { ...req.req.query, $skiptoken }
26
- // REVISIT: slice replaces leading '/'. Always starts with '/'?
27
- result.$nextLink =
28
- req.req.path.slice(1) +
29
- '?' +
30
- querystring.stringify(queryParamsWithSkipToken, '&', '=', { encodeURIComponent: e => e })
31
- }
24
+ if (!$skiptoken) return
25
+
26
+ const queryParamsWithSkipToken = { ...req.req.query, $skiptoken }
27
+ result.$nextLink = req.req.path.slice(1) + '?' + querystring.stringify(queryParamsWithSkipToken)
32
28
  }
33
29
 
34
30
  const _calculateSkiptoken = (req, result) => {
@@ -252,7 +248,7 @@ module.exports = adapter => {
252
248
  if (req.query.$count) result.$count = 0
253
249
  }
254
250
 
255
- if (!one) _calculateNextLink(cdsReq, result)
251
+ if (!one) _setNextLink(cdsReq, result)
256
252
  postProcess(cdsReq.target, service, result)
257
253
  if (result?.$etag) res.set('ETag', result.$etag) //> must be done after post processing
258
254
 
@@ -199,13 +199,12 @@ module.exports = adapter => {
199
199
  _addStreamMetadata(query)
200
200
  }
201
201
 
202
- // we need the cds request, so we can access the modified query, which is cloned due to lean-draft, so we need to use dispatch here and pass a cds req
203
- const cdsReq = adapter.request4({ query, req, res })
204
-
205
202
  // for read and delete, we provide keys in req.data
206
203
  // payload & params
207
- const { keys } = getKeysAndParamsFromPath(query.SELECT.from, service)
208
- cdsReq.data = keys
204
+ const { keys, params } = getKeysAndParamsFromPath(query.SELECT.from, service)
205
+
206
+ // we need the cds request, so we can access the modified query, which is cloned due to lean-draft, so we need to use dispatch here and pass a cds req
207
+ const cdsReq = adapter.request4({ query, data: keys, params, req, res })
209
208
 
210
209
  // REVISIT: what is this for? some tests fail without it... we should find a better solution!
211
210
  Object.defineProperty(query.SELECT, '_4odata', { value: true })
@@ -245,7 +245,8 @@ function _handleCollectionBoundActions(current, ref, i, namespace, one) {
245
245
  if (!action) return incompleteKeys
246
246
 
247
247
  const onCollection = !!(
248
- action['@cds.odata.bindingparameter.collection'] || action?.params?.some(p => p?.items?.type === '$self')
248
+ action['@cds.odata.bindingparameter.collection'] ||
249
+ (action?.params && [...action.params].some(p => p?.items?.type === '$self'))
249
250
  )
250
251
 
251
252
  if (onCollection && one) {
@@ -399,7 +400,7 @@ function _processSegments(from, model, namespace, cqn, protocol) {
399
400
  incompleteKeys = _handleCollectionBoundActions(current, ref, i, namespace, one)
400
401
 
401
402
  if (ref[i].where) {
402
- keyCount += addRefToWhereIfNecessary(ref[i].where, current)
403
+ keyCount += addRefToWhereIfNecessary(ref[i].where, current, true)
403
404
  _resolveAliasesInXpr(ref[i].where, current)
404
405
  _processWhere(ref[i].where, current)
405
406
  }
@@ -69,7 +69,9 @@ const parseStream = async function* (body, boundary) {
69
69
  const process = chunk => {
70
70
  let changed = chunk
71
71
  .toString()
72
- .replace(/--(.*)$/gm, (_, g) => `HEAD /${g} HTTP/1.1${g.slice(-2) === '--' ? CRLF : ''}`)
72
+ .replace(/^--(.*)$/gm, (_, g) => `HEAD /${g} HTTP/1.1${g.slice(-2) === '--' ? CRLF : ''}`)
73
+ // correct content-length for non-HEAD requests is inserted below
74
+ .replace(/content-length: \d+\r\n/gim, '')
73
75
  .replace(/ \$/g, ' /$')
74
76
 
75
77
  // HACKS!!!
@@ -277,7 +277,7 @@ const skipToken = (token, cqn) => {
277
277
 
278
278
  const calculateLocationHeader = (target, srv, result) => {
279
279
  const targetName = target.name.replace(`${srv.definition.name}.`, '')
280
- const filteredKeys = target.keys.filter(k => !k.isAssociation).map(k => k.name)
280
+ const filteredKeys = [...target.keys].filter(k => !k.isAssociation).map(k => k.name)
281
281
  const keyValuePairs = filteredKeys.reduce((acc, key) => {
282
282
  const value = result[key]
283
283
  if (value === undefined) return
@@ -358,11 +358,11 @@ function keysOf(entity, ignoreManagedBackLinks) {
358
358
  }
359
359
 
360
360
  // case: single key without name, e.g., Foo(1)
361
- function addRefToWhereIfNecessary(where, entity) {
361
+ function addRefToWhereIfNecessary(where, entity, ignoreManagedBackLinks = false) {
362
362
  if (!where || where.length !== 1) return 0
363
363
 
364
364
  const isView = !!entity.params
365
- const keys = isView ? Object.keys(entity.params) : keysOf(entity)
365
+ const keys = isView ? Object.keys(entity.params) : keysOf(entity, ignoreManagedBackLinks)
366
366
 
367
367
  if (keys.length !== 1) return 0
368
368
  where.unshift(...[{ ref: [keys[0]] }, '='])
@@ -1,27 +1,13 @@
1
1
  const cds = require('../../_runtime/cds')
2
2
 
3
3
  const getTemplate = require('../../_runtime/common/utils/template')
4
- const templateProcessor = require('../../_runtime/common/utils/templateProcessor')
5
-
6
- const _getParent = (model, name) => {
7
- const target = model.definitions[name]
8
-
9
- if (target && target.elements) {
10
- for (const elementName in target.elements) {
11
- const element = target.elements[elementName]
12
- if (element._anchor && element._anchor._isContained) return element._anchor
13
- }
14
- }
15
-
16
- return null
17
- }
18
4
 
19
5
  const _addEtags = (row, key) => {
20
6
  if (!row[key]) return
21
7
  row.$etag = row[key].startsWith('W/') ? row[key] : `W/"${row[key]}"`
22
8
  }
23
9
 
24
- const _processorFn = () => elementInfo => {
10
+ const _processorFn = elementInfo => {
25
11
  const { row, plain } = elementInfo
26
12
  if (typeof row !== 'object') return
27
13
  for (const category of plain.categories) {
@@ -70,20 +56,12 @@ module.exports = function postProcess(target, service, result, isMinimal) {
70
56
  }
71
57
 
72
58
  const cacheKey = isMinimal ? 'postProcessMinimal' : 'postProcess'
73
- const parent = _getParent(model, target.name)
74
59
  const options = { pick: _pick, ignore: isMinimal ? el => el.isAssociation : undefined }
75
- const template = getTemplate(cacheKey, service, target, options, parent)
60
+ const template = getTemplate(cacheKey, service, target, options)
76
61
 
77
62
  if (template.elements.size === 0) return
78
63
 
79
64
  // normalize result to rows
80
65
  result = result.value != null && Object.keys(result).filter(k => !k.match(/^\W/)).length === 1 ? result.value : result
81
-
82
- if (typeof result === 'object' && result != null) {
83
- const rows = Array.isArray(result) ? result : [result]
84
-
85
- // process each row
86
- const processFn = _processorFn()
87
- for (const row of rows) templateProcessor({ processFn, row, template })
88
- }
66
+ template.process(result, _processorFn)
89
67
  }
@@ -6,7 +6,6 @@ const { getKeysAndParamsFromPath } = require('../../common/utils')
6
6
  const { base64ToBuffer } = require('../../_runtime/common/utils/binary')
7
7
  const { convertStructured } = require('../../_runtime/common/utils/ucsn')
8
8
  const getTemplate = require('../../_runtime/common/utils/template')
9
- const templateProcessor = require('../../_runtime/common/utils/templateProcessor')
10
9
 
11
10
  const { checkStaticElementByKey } = require('../../_runtime/cds-services/util/assert')
12
11
 
@@ -151,11 +150,7 @@ module.exports = adapter => {
151
150
  cleanupStruct: cds.env.features.rest_struct_data
152
151
  })
153
152
  const template = getTemplate(_cache(req), service, definition, { pick: _picker })
154
- if (template && template.elements.size) {
155
- for (const row of Array.isArray(payload) ? payload : [payload]) {
156
- templateProcessor({ processFn: _processorFn(errs), row, template })
157
- }
158
- }
153
+ template.process(payload, _processorFn(errs))
159
154
  if (errs.length) {
160
155
  if (errs.length === 1) throw errs[0]
161
156
  throw Object.assign(new Error('MULTIPLE_ERRORS'), { statusCode: 400, details: errs })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "8.1.1",
3
+ "version": "8.2.1",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [
@@ -35,7 +35,7 @@
35
35
  "node": ">=18"
36
36
  },
37
37
  "dependencies": {
38
- "@sap/cds-compiler": ">=5",
38
+ "@sap/cds-compiler": ">=5.1",
39
39
  "@sap/cds-fiori": "^1",
40
40
  "@sap/cds-foss": "^5.0.0"
41
41
  },