@sap/cds 9.7.1 → 9.8.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 (39) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/_i18n/i18n_en_US_saptrc.properties +1 -56
  3. package/_i18n/messages_en_US_saptrc.properties +1 -92
  4. package/eslint.config.mjs +1 -1
  5. package/lib/compile/for/lean_drafts.js +12 -0
  6. package/lib/compile/to/json.js +4 -2
  7. package/lib/env/defaults.js +1 -0
  8. package/lib/env/serviceBindings.js +15 -5
  9. package/lib/index.js +1 -1
  10. package/lib/log/cds-error.js +33 -20
  11. package/lib/req/spawn.js +2 -2
  12. package/lib/srv/bindings.js +6 -13
  13. package/lib/srv/cds.Service.js +8 -36
  14. package/lib/srv/protocols/hcql.js +19 -2
  15. package/lib/utils/cds-utils.js +25 -16
  16. package/lib/utils/tar-win.js +106 -0
  17. package/lib/utils/tar.js +23 -158
  18. package/libx/_runtime/common/generic/crud.js +8 -7
  19. package/libx/_runtime/common/generic/sorting.js +7 -3
  20. package/libx/_runtime/common/utils/resolveView.js +47 -40
  21. package/libx/_runtime/common/utils/rewriteAsterisks.js +1 -0
  22. package/libx/_runtime/fiori/lean-draft.js +11 -2
  23. package/libx/_runtime/messaging/kafka.js +6 -5
  24. package/libx/_runtime/remote/Service.js +3 -0
  25. package/libx/_runtime/remote/utils/client.js +2 -4
  26. package/libx/_runtime/remote/utils/query.js +4 -4
  27. package/libx/odata/middleware/batch.js +316 -339
  28. package/libx/odata/middleware/create.js +0 -5
  29. package/libx/odata/middleware/delete.js +0 -5
  30. package/libx/odata/middleware/operation.js +10 -8
  31. package/libx/odata/middleware/read.js +0 -10
  32. package/libx/odata/middleware/stream.js +1 -0
  33. package/libx/odata/middleware/update.js +0 -6
  34. package/libx/odata/parse/afterburner.js +47 -22
  35. package/libx/odata/parse/cqn2odata.js +6 -1
  36. package/libx/odata/parse/grammar.peggy +14 -2
  37. package/libx/odata/parse/multipartToJson.js +2 -1
  38. package/libx/odata/parse/parser.js +1 -1
  39. package/package.json +2 -2
@@ -1,6 +1,5 @@
1
1
  const cds = require('../../../')
2
2
 
3
- const { AsyncResource } = require('async_hooks')
4
3
  const express = require('express')
5
4
  const { STATUS_CODES } = require('http')
6
5
  const qs = require('querystring')
@@ -16,7 +15,7 @@ const CT = { JSON: 'application/json', MULTIPART: 'multipart/mixed' }
16
15
  const CRLF = '\r\n'
17
16
 
18
17
  /*
19
- * common
18
+ * deconstruct and validate
20
19
  */
21
20
 
22
21
  const _deserializationError = message => cds.error({ status: 400, message: `Deserialization Error: ${message}` })
@@ -34,22 +33,30 @@ const _validateProperty = (name, value, type) => {
34
33
  }
35
34
  }
36
35
 
37
- const _validateBatch = body => {
36
+ const _deconstructAndValidateBatchBody = body => {
38
37
  const { requests } = body
39
38
 
40
39
  _validateProperty('requests', requests, 'Array')
41
-
40
+ if (!requests.length) cds.error({ status: 400, message: 'There must be at least one request' })
42
41
  if (requests.length > cds.env.odata.batch_limit) cds.error({ status: 429, message: 'BATCH_TOO_MANY_REQ' })
43
42
 
44
43
  const ids = {}
44
+ const atomicityGroups = []
45
45
 
46
46
  let previousAtomicityGroup
47
- requests.forEach((request, i) => {
47
+
48
+ for (let i = 0; i < requests.length; i++) {
49
+ const request = requests[i]
50
+
48
51
  if (typeof request !== 'object')
49
52
  throw _deserializationError(`Element of 'requests' array at index ${i} must be type of 'object'.`)
50
53
 
51
54
  const { id, method, url, body, atomicityGroup } = request
52
55
 
56
+ if (atomicityGroup && atomicityGroups.length && atomicityGroup === atomicityGroups.at(-1)[0]?.atomicityGroup)
57
+ atomicityGroups.at(-1).push(request)
58
+ else atomicityGroups.push([request])
59
+
53
60
  _validateProperty('id', id, 'string')
54
61
 
55
62
  if (ids[id]) throw _deserializationError(`Request ID '${id}' is not unique.`)
@@ -79,12 +86,10 @@ const _validateBatch = body => {
79
86
  if (ids[atomicityGroup]) throw _deserializationError(`Atomicity group ID '${atomicityGroup}' is not unique.`)
80
87
  else ids[atomicityGroup] = []
81
88
  }
82
- // add current index to ensure stable order in result
83
- request._agIndex = ids[atomicityGroup].length
84
89
  ids[atomicityGroup].push(request)
85
90
  }
86
91
 
87
- if (url.startsWith('$')) {
92
+ if (url.startsWith('$') && url !== '$metadata') {
88
93
  request.dependsOn ??= []
89
94
  const dependencyId = url.split('/')[0].replace(/^\$/, '')
90
95
  if (!request.dependsOn.includes(dependencyId)) {
@@ -115,155 +120,146 @@ const _validateBatch = body => {
115
120
  // TODO: validate if, and headers
116
121
 
117
122
  previousAtomicityGroup = atomicityGroup
118
- })
123
+ }
119
124
 
120
- return ids
125
+ return { ids, atomicityGroups }
121
126
  }
122
127
 
123
128
  /*
124
- * lookalike
129
+ * subrequest (a.k.a. lookalike)
125
130
  */
126
131
 
127
132
  let error_mws
128
- const _getNextForLookalike = lookalike => {
129
- error_mws ??= cds.middlewares.after.filter(mw => mw.length === 4) // error middleware has 4 params
130
- return err => {
131
- let _err = err
132
- let _next_called
133
- const _next = e => {
134
- _err = e
135
- _next_called = true
136
- }
137
- for (const mw of error_mws) {
138
- _next_called = false
139
- mw(_err, lookalike.req, lookalike.res, _next)
140
- if (!_next_called) break //> next chain was interrupted -> done
141
- }
142
- if (_next_called) {
143
- // here, final error middleware called next (which actually shouldn't happen!)
144
- if (_err.statusCode) lookalike.res.status(_err.statusCode)
145
- if (typeof _err === 'object') lookalike.res.json({ error: _err })
146
- else lookalike.res.send(_err)
147
- }
148
- }
149
- }
150
133
 
151
134
  // REVISIT: Why not simply use {__proto__:req, ...}?
152
- const _createExpressReqResLookalike = (request, _req, _res) => {
153
- const { id, method, url } = request
154
- const ret = { id }
135
+ const _createSubrequest = (request, _req, _res) => {
136
+ error_mws ??= cds.middlewares.after.filter(mw => mw.length === 4) // error middleware has 4 params
155
137
 
156
- const req = (ret.req = new express.request.constructor())
157
- req.__proto__ = express.request
138
+ const subrequest = { id: request.id }
158
139
 
159
- // express internals
140
+ // req
141
+ const req = (subrequest.req = new express.request.constructor())
142
+ req.__proto__ = express.request
160
143
  req.app = _req.app
161
-
162
- req.method = method.toUpperCase()
163
- req.url = url
164
- const u = new URL(url, 'http://cap')
144
+ req.method = request.method.toUpperCase()
145
+ req.url = request.url
146
+ const u = new URL(request.url, 'http://cap')
165
147
  req.query = qs.parse(u.search.slice(1))
166
148
  req.headers = request.headers || {}
149
+ req.headers['content-type'] ??= _req.headers['content-type']
167
150
  if (request.content_id) req.headers['content-id'] = request.content_id
168
151
  req.body = request.body
169
152
  if (_req._login) req._login = _req._login
170
-
171
- const res = (ret.res = new express.response.constructor(req))
172
- res.__proto__ = express.response
173
-
174
- // REVISIT: mark as subrequest
153
+ // REVISIT: mark as subrequest (only needed for logging)
175
154
  req._subrequest = true
176
155
 
177
- // express internals
156
+ // res
157
+ const res = (req.res = subrequest.res = new express.response.constructor(req))
158
+ res.__proto__ = express.response
178
159
  res.app = _res.app
179
160
 
180
- // back link
181
- req.res = res
161
+ // next
162
+ let _err
163
+ subrequest.next = err => {
164
+ _err = err
165
+ let _next_called
166
+ const _next = err => {
167
+ _err = err
168
+ _next_called = true
169
+ }
170
+ for (const mw of error_mws) {
171
+ _next_called = false
172
+ mw(_err, req, res, _next)
173
+ if (!_next_called) break //> next chain was interrupted -> done
174
+ }
175
+ if (_next_called) {
176
+ // here, final error middleware called next (which actually shouldn't happen!)
177
+ if (_err.statusCode) res.status(_err.statusCode)
178
+ if (typeof _err === 'object') res.json({ error: _err })
179
+ else res.send(_err)
180
+ }
181
+ }
182
182
 
183
- // resolve promise for subrequest via res.end()
184
- ret.promise = new Promise((resolve, reject) => {
183
+ // resolve/reject promise for subrequest via res.end()
184
+ subrequest.promise = new Promise((resolve, reject) => {
185
+ res.json = function (obj) {
186
+ subrequest._json = obj
187
+ return this.__proto__.json.call(this, obj)
188
+ }
185
189
  res.end = (chunk, encoding) => {
186
190
  res._chunk = chunk
187
191
  res._encoding = encoding
188
- if (res.statusCode >= 400) return reject(ret)
189
- resolve(ret)
192
+ if (res.statusCode >= 400) return reject(_err)
193
+ resolve(subrequest)
190
194
  }
191
195
  })
192
196
 
193
- return ret
197
+ return subrequest
194
198
  }
195
199
 
196
200
  /*
197
201
  * multipart/mixed response
198
202
  */
199
203
 
200
- const _formatResponseMultipart = request => {
201
- const { res: response } = request
202
- const content_id = request.req?.headers['content-id']
204
+ const _tryParse = body => {
205
+ try {
206
+ return JSON.parse(body)
207
+ } catch {
208
+ return body
209
+ }
210
+ }
211
+
212
+ const _formatResponseMultipart = response => {
213
+ const { content_id, statusCode, headers } = response
214
+ let { body } = response
203
215
 
204
216
  let txt = `content-type: application/http${CRLF}content-transfer-encoding: binary${CRLF}`
205
217
  if (content_id) txt += `content-id: ${content_id}${CRLF}`
206
218
  txt += CRLF
207
- txt += `HTTP/1.1 ${response.statusCode} ${STATUS_CODES[response.statusCode]}${CRLF}`
219
+ txt += `HTTP/1.1 ${statusCode} ${STATUS_CODES[statusCode]}${CRLF}`
208
220
 
209
- // REVISIT: tests require specific sequence
210
- const headers = {
211
- ...response.getHeaders(),
212
- ...(response.statusCode !== 204 && { 'content-type': 'application/json;odata.metadata=minimal' })
213
- }
214
- delete headers['content-length'] //> REVISIT: expected by tests
215
-
216
- for (const key in headers) {
217
- txt += key + ': ' + headers[key] + CRLF
218
- }
221
+ for (const key in headers) txt += key + ': ' + headers[key] + CRLF
219
222
  txt += CRLF
220
223
 
221
- const _tryParse = x => {
222
- try {
223
- return JSON.parse(x)
224
- } catch {
225
- return x
226
- }
227
- }
228
-
229
- if (response._chunk) {
230
- const chunk = _tryParse(response._chunk)
231
- if (chunk && typeof chunk === 'object') {
224
+ if (body) {
225
+ if (Buffer.isBuffer(body)) body = body.toString()
226
+ body = _tryParse(body)
227
+ if (body && typeof body === 'object') {
232
228
  let meta = [],
233
229
  data = []
234
- for (const [k, v] of Object.entries(chunk)) {
230
+ for (const [k, v] of Object.entries(body)) {
235
231
  if (k.startsWith('@')) meta.push(`"${k}":${typeof v === 'string' ? `"${v.replaceAll('"', '\\"')}"` : v}`)
236
232
  else data.push(JSON.stringify({ [k]: v }).slice(1, -1))
237
233
  }
238
234
  const _json_as_txt = '{' + meta.join(',') + (meta.length && data.length ? ',' : '') + data.join(',') + '}'
239
- txt += _json_as_txt
240
- } else {
241
- txt += chunk
242
- txt = txt.replace('content-type: application/json;odata.metadata=minimal', 'content-type: text/plain')
235
+ body = _json_as_txt
243
236
  }
237
+ txt += body
244
238
  }
245
239
 
246
240
  return [txt]
247
241
  }
248
242
 
249
- const _writeResponseMultipart = (responses, res, rejected, group, boundary) => {
243
+ const _writeResponseMultipart = (responses, res, boundary) => {
250
244
  res.write(`--${boundary}${CRLF}`)
251
245
 
246
+ const rejected = responses.find(r => r.status === 'fail')
252
247
  if (rejected) {
253
- const resp = responses.find(r => r.status === 'fail')
254
- resp.txt.forEach(txt => {
255
- res.write(`${txt}${CRLF}`)
256
- })
257
- } else {
258
- if (group) res.write(`content-type: multipart/mixed;boundary=${group}${CRLF}${CRLF}`)
259
- for (const resp of responses) {
260
- resp.txt.forEach(txt => {
261
- if (group) res.write(`--${group}${CRLF}`)
262
- res.write(`${txt}${CRLF}`)
263
- })
248
+ res.write(`${_formatResponseMultipart(rejected)[0]}${CRLF}`)
249
+ return
250
+ }
251
+
252
+ const groupId = responses[0].atomicityGroup
253
+ if (groupId) res.write(`content-type: multipart/mixed;boundary=${groupId}${CRLF}${CRLF}`)
254
+
255
+ for (const response of responses) {
256
+ for (const each of _formatResponseMultipart(response)) {
257
+ if (groupId) res.write(`--${groupId}${CRLF}`)
258
+ res.write(`${each}${CRLF}`)
264
259
  }
265
- if (group) res.write(`--${group}--${CRLF}`)
266
260
  }
261
+
262
+ if (groupId) res.write(`--${groupId}--${CRLF}`)
267
263
  }
268
264
 
269
265
  /*
@@ -276,127 +272,139 @@ const _formatStatics = {
276
272
  close: Buffer.from('}')
277
273
  }
278
274
 
279
- const _formatResponseJson = (request, atomicityGroup) => {
280
- const { id, res: response } = request
275
+ const _formatResponseJson = response => {
276
+ const { id, atomicityGroup, statusCode, headers, body } = response
281
277
 
282
- const chunk = {
283
- id,
284
- status: response.statusCode,
285
- headers: {
286
- ...response.getHeaders(),
287
- 'content-type': 'application/json' //> REVISIT: why?
288
- }
289
- }
278
+ const chunk = { id, status: statusCode, headers }
290
279
  if (atomicityGroup) chunk.atomicityGroup = atomicityGroup
280
+
291
281
  const raw = Buffer.from(JSON.stringify(chunk))
292
282
 
293
283
  // body?
294
- if (!response._chunk) return [raw]
284
+ if (!body) return [raw]
295
285
 
296
286
  // change last "}" into ","
297
287
  raw[raw.byteLength - 1] = _formatStatics.comma
298
- return [raw, _formatStatics.body, response._chunk, _formatStatics.close]
288
+
289
+ return [
290
+ raw,
291
+ _formatStatics.body,
292
+ // Stringify non-JSON bodies
293
+ !chunk.headers?.['content-type']?.includes('application/json')
294
+ ? JSON.stringify(Buffer.isBuffer(body) ? body.toString() : body)
295
+ : body,
296
+ _formatStatics.close
297
+ ]
299
298
  }
300
299
 
301
300
  const _writeResponseJson = (responses, res) => {
302
- for (const resp of responses) {
303
- if (resp.separator) res.write(resp.separator)
304
- resp.txt.forEach(txt => res.write(txt))
301
+ for (const response of responses) {
302
+ if (res._separator) res.write(res._separator)
303
+ else res._separator = Buffer.from(',')
304
+
305
+ for (const each of _formatResponseJson(response)) res.write(each)
305
306
  }
306
307
  }
307
308
 
308
309
  /*
309
- * process
310
+ * helpers
310
311
  */
311
312
 
312
- const _txs = {}
313
- cds.once('shutdown', () => Promise.all(Object.values(_txs).map(tx => tx.rollback().catch(() => {}))))
313
+ const _urlWithResolvedDependency = (dependsOnId, results, request, req, srv) => {
314
+ const dependentResult = results.find(r => r.value.id === dependsOnId)
315
+ const dependentOnUrl = dependentResult.value.req.originalUrl
316
+ const dependentOnResult = dependentResult.value._json ?? JSON.parse(dependentResult.value.res._chunk)
317
+ const recentUrl = request.url
318
+ const cqn = cds.odata.parse(dependentOnUrl, { service: srv, baseUrl: req.baseUrl, strict: true })
319
+ const target = cds.infer.target(cqn)
320
+ const keyString =
321
+ '(' +
322
+ [...target.keys]
323
+ .filter(k => !k.isAssociation)
324
+ .map(k => {
325
+ let v = dependentOnResult[k.name]
326
+ if (typeof v === 'string' && k._type !== 'cds.UUID') v = `'${v}'`
327
+ return k.name + '=' + v
328
+ })
329
+ .join(',') +
330
+ ')'
331
+ return recentUrl.replace(`$${dependsOnId}`, dependentOnUrl + keyString)
332
+ }
314
333
 
315
- // REVISIT: This looks frightening -> need to review
316
- const _transaction = async srv => {
317
- return new Promise(res => {
318
- const ret = {}
319
- const _tx = (ret._tx = srv.tx(
320
- async tx =>
321
- (ret.promise = new Promise((resolve, reject) => {
322
- // ensure rollback on shutdown so the db can disconnect
323
- const _id = cds.utils.uuid()
324
- _txs[_id] = tx
325
- tx.context.on('done', () => delete _txs[_id])
326
-
327
- const proms = []
328
- // It's important to run `makePromise` in the current execution context (cb of srv.tx),
329
- // otherwise, it will use a different transaction.
330
- // REVISIT: This looks frightening -> need to review
331
- ret.add = AsyncResource.bind(function (makePromise) {
332
- const p = makePromise()
333
- proms.push(p)
334
- return p
335
- })
336
- ret.done = async function () {
337
- const result = await Promise.allSettled(proms)
338
- if (result.some(r => r.status === 'rejected')) {
339
- reject()
340
- // REVISIT: workaround to wait for commit/rollback
341
- await _tx
342
- return 'rejected'
343
- }
344
- resolve(result)
345
- // REVISIT: workaround to wait for commit/rollback
346
- await _tx
347
- }
348
- res(ret)
349
- }))
350
- ))
334
+ const _resolveDependencies = async (request, deps, responses, ids, req, srv) => {
335
+ const { url } = request
336
+
337
+ const results = await Promise.allSettled(deps.map(d => d.promise))
338
+
339
+ const failed = results.some(({ status }) => status === 'rejected') || deps.some(d => d._rejected_on_commit)
340
+ if (failed) {
341
+ const err = { code: '424', message: 'Failed Dependency' }
342
+ responses[request._index] = _getResponse(request, { res: { statusCode: 424 } }, err)
343
+ throw err
344
+ }
345
+
346
+ const dependsOnId = url.split('/')[0].replace(/^\$/, '')
347
+ if (dependsOnId in ids) request.url = _urlWithResolvedDependency(dependsOnId, results, request, req, srv)
348
+ }
349
+
350
+ const _getResponse = (request, { res }, error) => {
351
+ const { id, content_id, atomicityGroup, _index } = request
352
+ return {
353
+ id,
354
+ content_id,
355
+ atomicityGroup,
356
+ _index,
357
+ status: error ? 'fail' : 'ok',
358
+ statusCode: res.statusCode,
359
+ body: error ? { error } : res._chunk,
360
+ headers: res.getHeaders?.() ?? {}
361
+ }
362
+ }
363
+
364
+ // construct odata-compliant error (without modifying original error)
365
+ const _getODataError = err => {
366
+ return odataError(Object.create(err), {
367
+ get locale() {
368
+ return cds.context.locale
369
+ },
370
+ get() {}
351
371
  })
352
372
  }
353
373
 
354
- const _tx_done = async (tx, responses, isJson) => {
355
- let rejected
356
- try {
357
- rejected = await tx.done()
358
- } catch (e) {
359
- // here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
360
- rejected = 'rejected'
361
- // construct commit error (without modifying original error)
362
- const error = odataError(Object.create(e), {
363
- get locale() {
364
- return cds.context.locale
365
- },
366
- get() {}
367
- })
368
- // replace all responses with commit error
369
- for (const res of responses) {
370
- res.status = 'fail'
371
- // REVISIT: should error go through any error middleware/ customization logic?
372
- if (isJson) {
373
- let txt = ''
374
- for (let i = 0; i < res.txt.length; i++) txt += Buffer.isBuffer(res.txt[i]) ? res.txt[i].toString() : res.txt[i]
375
- txt = JSON.parse(txt)
376
- txt.status = error.status
377
- txt.body = { error }
378
- // REVISIT: content-length needed? not there in multipart case...
379
- delete txt.headers['content-length']
380
- res.txt = [JSON.stringify(txt)]
381
- } else {
382
- const commitError = [
383
- 'content-type: application/http',
384
- 'content-transfer-encoding: binary',
385
- '',
386
- `HTTP/1.1 ${error.status} ${STATUS_CODES[error.status]}`,
387
- 'odata-version: 4.0',
388
- 'content-type: application/json;odata.metadata=minimal',
389
- '',
390
- JSON.stringify({ error })
391
- ].join(CRLF)
392
- res.txt = [commitError]
393
- break
394
- }
374
+ const _replaceResponsesWithCommitErrors = (err, responses, ids) => {
375
+ const error = _getODataError(err)
376
+
377
+ // adjust all responses to commit error and mark respective promises as failed
378
+ const body = JSON.stringify({ error })
379
+ const length = Buffer.byteLength(body, 'utf8')
380
+ for (const response of responses) {
381
+ response.status = 'fail'
382
+ response.statusCode = error.status
383
+ response.body = body
384
+ response.headers ??= {}
385
+ response.headers['content-length'] = length
386
+
387
+ ids[response.id]._rejected_on_commit = true
388
+ if (response.atomicityGroup) ids[response.atomicityGroup]._rejected_on_commit = true
389
+ }
390
+ }
391
+
392
+ const _serializeErrors = responses => {
393
+ for (const response of responses) {
394
+ if (response.status === 'fail' && typeof response.body === 'object') {
395
+ if (response.body.error && !response.body.error.toJSON) response.body.error = _getODataError(response.body.error)
396
+ response.body = JSON.stringify(response.body)
395
397
  }
396
398
  }
397
- return rejected
398
399
  }
399
400
 
401
+ /*
402
+ * process
403
+ */
404
+
405
+ const _txs = {}
406
+ cds.once('shutdown', () => Promise.all(Object.values(_txs).map(tx => tx.rollback().catch(() => {}))))
407
+
400
408
  const _processBatch = async (srv, router, req, res, next, body, ct, boundary) => {
401
409
  body ??= req.body
402
410
  ct ??= 'JSON'
@@ -406,7 +414,6 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
406
414
  if (req.headers.accept.indexOf('multipart/mixed') > -1) isJson = false
407
415
  else if (req.headers.accept.indexOf('application/json') > -1) isJson = true
408
416
  }
409
- const _formatResponse = isJson ? _formatResponseJson : _formatResponseMultipart
410
417
 
411
418
  // continue-on-error defaults to true in json batch
412
419
  let continue_on_error = req.headers.prefer?.match(/odata\.continue-on-error(=(\w+))?/)
@@ -417,147 +424,119 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
417
424
  }
418
425
 
419
426
  try {
420
- const ids = _validateBatch(body) // REVISIT: we will not be able to validate the whole once we stream
427
+ let { ids, atomicityGroups } = _deconstructAndValidateBatchBody(body)
428
+
429
+ const only_gets = body.requests.every(r => r.method === 'GET')
430
+ const max_parallel = only_gets ? cds.env.odata.max_batch_parallelization : 1
431
+
432
+ // experimental!
433
+ const _only_individual_requests = () =>
434
+ atomicityGroups.every(ag => ag.length === 1) && body.requests.every(r => !r.dependsOn?.length)
435
+ if (only_gets && cds.env.odata.group_parallel_gets && _only_individual_requests())
436
+ atomicityGroups = [atomicityGroups.reduce((acc, cur) => (acc.push(...cur), acc), [])]
437
+
438
+ res.setHeader('Content-Type', isJson ? CT.JSON : CT.MULTIPART + ';boundary=' + boundary)
439
+ res.status(200)
440
+ res.write(isJson ? '{"responses":[' : '')
441
+
442
+ const queue = []
443
+ let _continue = true
444
+ const _queued_exec = (atomicityGroup, responses = []) => {
445
+ const groupId = atomicityGroup[0].atomicityGroup
446
+
447
+ return new Promise(resolve => queue.push(resolve)).then(() => {
448
+ if (!_continue) return
449
+
450
+ const promise = srv
451
+ .tx(tx => {
452
+ // ensure rollback on shutdown so the db can disconnect
453
+ const _id = cds.utils.uuid()
454
+ _txs[_id] = tx
455
+ tx.context.on('done', () => delete _txs[_id])
456
+
457
+ const subrequests = []
458
+ for (let i = 0; i < atomicityGroup.length; i++) {
459
+ const request = atomicityGroup[i]
460
+
461
+ // for multipart, we need to keep the original order of requests in the atomicity group
462
+ request._index = i
463
+
464
+ const { id, dependsOn } = request
465
+
466
+ // wait for/ resolve dependencies?
467
+ const deps = dependsOn?.filter(id => id !== groupId).map(id => ids[id])
468
+ if (deps) ids[id].promise = _resolveDependencies(request, deps, responses, ids, req, srv)
469
+ else ids[id].promise = Promise.resolve()
470
+
471
+ ids[id].promise = ids[id].promise.then(() => {
472
+ const subrequest = _createSubrequest(request, req, res)
473
+ router.handle(subrequest.req, subrequest.res, subrequest.next)
474
+ return subrequest.promise
475
+ .then(res => {
476
+ responses[request._index] = _getResponse(request, subrequest)
477
+ return res
478
+ })
479
+ .catch(err => {
480
+ responses[request._index] = _getResponse(request, subrequest, err)
481
+ throw err
482
+ })
483
+ })
421
484
 
422
- // TODO: if (!requests || !requests.length) throw new Error('At least one request, buddy!')
485
+ subrequests.push(ids[id].promise)
486
+ }
423
487
 
424
- let previousAtomicityGroup
425
- let separator
426
- let tx
427
- let responses
488
+ // ensure all subrequests run in this tx
489
+ // (if first subrequest fails without really opening the tx, the rest are executed in a "dangling tx")
490
+ return Promise.allSettled(subrequests).then(ress => {
491
+ const failed = ress.filter(({ status }) => status === 'rejected')
492
+ if (!failed.length) return
493
+ // throw first error and call srv.on('error') for the others
494
+ const first = failed.shift()
495
+ if (srv.handlers._error?.length)
496
+ for (const other of failed)
497
+ for (const each of srv.handlers._error) each.handler.call(srv, other.reason, cds.context)
498
+ throw first.reason
499
+ })
500
+ })
501
+ .catch(err => {
502
+ responses._has_failure = true
428
503
 
429
- // IMPORTANT: Avoid sending headers and responses too eagerly, as we might still have to send a 401
430
- let sendPreludeOnce = () => {
431
- res.setHeader('Content-Type', isJson ? CT.JSON : CT.MULTIPART + ';boundary=' + boundary)
432
- res.status(200)
433
- res.write(isJson ? '{"responses":[' : '')
434
- sendPreludeOnce = () => {} //> only once
435
- }
504
+ // abort batch on first failure with odata.continue-on-error: false
505
+ if (!continue_on_error) _continue = false
436
506
 
437
- const { requests } = body
438
- for await (const request of requests) {
439
- // for json payloads, normalize headers to lowercase
440
- if (ct === 'JSON') {
441
- request.headers = request.headers
442
- ? Object.keys(request.headers).reduce((acc, cur) => {
443
- acc[cur.toLowerCase()] = request.headers[cur]
444
- return acc
445
- }, {})
446
- : {}
447
- }
507
+ if (!responses.some(r => r.status === 'fail')) {
508
+ // here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
509
+ _replaceResponsesWithCommitErrors(err, responses, ids)
510
+ }
511
+ })
512
+ .finally(() => {
513
+ // trigger next in queue
514
+ if (queue.length) queue.shift()()
448
515
 
449
- request.headers['content-type'] ??= req.headers['content-type']
450
-
451
- const { atomicityGroup } = request
452
-
453
- if (!atomicityGroup || atomicityGroup !== previousAtomicityGroup) {
454
- if (tx) {
455
- // Each change in `atomicityGroup` results in a new transaction. We execute them in sequence to avoid too many database connections.
456
- // In the future, we might make this configurable (e.g. allow X parallel connections per HTTP request).
457
- const rejected = await _tx_done(tx, responses, isJson)
458
- if (tx.failed?.res.statusCode === 401 && req._login) return req._login()
459
- else sendPreludeOnce()
460
- isJson
461
- ? _writeResponseJson(responses, res)
462
- : _writeResponseMultipart(responses, res, rejected, previousAtomicityGroup, boundary)
463
- if (rejected && !continue_on_error) {
464
- tx = null
465
- break
466
- }
467
- }
516
+ // late error serialization
517
+ if (responses._has_failure) _serializeErrors(responses)
468
518
 
469
- responses = []
470
- tx = await _transaction(srv)
471
- if (atomicityGroup) ids[atomicityGroup].promise = tx._tx
472
- }
519
+ if (isJson) _writeResponseJson(responses, res)
520
+ else _writeResponseMultipart(responses, res, boundary)
521
+ })
473
522
 
474
- tx.add(() => {
475
- return (request.promise = (async () => {
476
- const dependencies = request.dependsOn?.filter(id => id !== request.atomicityGroup).map(id => ids[id].promise)
477
- if (dependencies) {
478
- // first, wait for dependencies
479
- const results = await Promise.allSettled(dependencies)
480
- const dependendOnFailed = results.some(({ status }) => status === 'rejected')
481
- if (dependendOnFailed) {
482
- tx.id = request.id
483
- tx.res = {
484
- getHeaders: () => {},
485
- statusCode: 424,
486
- _chunk: JSON.stringify({
487
- code: '424',
488
- message: 'Failed Dependency'
489
- })
490
- }
491
- throw tx
492
- }
523
+ if (ids[groupId]) ids[groupId].promise = promise
493
524
 
494
- const dependsOnId = request.url.split('/')[0].replace(/^\$/, '')
495
- if (dependsOnId in ids) {
496
- const dependentResult = results.find(r => r.value.id === dependsOnId)
497
- const dependentOnUrl = dependentResult.value.req.originalUrl
498
- const dependentOnResult = JSON.parse(dependentResult.value.res._chunk)
499
- const recentUrl = request.url
500
- const cqn = cds.odata.parse(dependentOnUrl, { service: srv, baseUrl: req.baseUrl, strict: true })
501
- const target = cds.infer.target(cqn)
502
- const keyString =
503
- '(' +
504
- [...target.keys]
505
- .filter(k => !k.isAssociation)
506
- .map(k => {
507
- let v = dependentOnResult[k.name]
508
- if (typeof v === 'string' && k._type !== 'cds.UUID') v = `'${v}'`
509
- return k.name + '=' + v
510
- })
511
- .join(',') +
512
- ')'
513
- request.url = recentUrl.replace(`$${dependsOnId}`, dependentOnUrl + keyString)
514
- }
515
- }
516
-
517
- // REVIST: That sends each request through the whole middleware chain again and again, including authentication and authorization.
518
- // -> We should optimize this!
519
- const lookalike = _createExpressReqResLookalike(request, req, res)
520
- const lookalike_next = _getNextForLookalike(lookalike)
521
- router.handle(lookalike.req, lookalike.res, lookalike_next)
522
- return lookalike.promise
523
- })())
525
+ return promise
524
526
  })
525
- .then(req => {
526
- const resp = { status: 'ok' }
527
- if (separator) resp.separator = separator
528
- else separator = Buffer.from(',')
529
- resp.txt = _formatResponse(req, atomicityGroup)
530
- // ensure stable order of responses in multipart changeset
531
- if (!isJson && request.atomicityGroup) responses[request._agIndex] = resp
532
- else responses.push(resp)
533
- })
534
- .catch(failedReq => {
535
- const resp = { status: 'fail' }
536
- if (separator) resp.separator = separator
537
- else separator = Buffer.from(',')
538
- resp.txt = _formatResponse(failedReq, atomicityGroup)
539
- tx.failed = failedReq
540
- // ensure stable order of responses in multipart changeset
541
- if (!isJson && request.atomicityGroup) responses[request._agIndex] = resp
542
- else responses.push(resp)
543
- })
544
-
545
- previousAtomicityGroup = atomicityGroup
546
527
  }
547
528
 
548
- if (tx) {
549
- // The last open transaction must be finished
550
- const rejected = await _tx_done(tx, responses, isJson)
551
- if (tx.failed?.res.statusCode === 401 && req._login) return req._login()
552
- else sendPreludeOnce()
553
- isJson
554
- ? _writeResponseJson(responses, res)
555
- : _writeResponseMultipart(responses, res, rejected, previousAtomicityGroup, boundary)
556
- } else sendPreludeOnce()
529
+ // queue execution of atomicity groups
530
+ const promises = []
531
+ for (const atomicityGroup of atomicityGroups) promises.push(_queued_exec(atomicityGroup))
532
+
533
+ // trigger first max_parallel in queue
534
+ for (let i = 0; i < max_parallel; i++) if (queue.length) queue.shift()()
535
+
536
+ await Promise.all(promises)
537
+
557
538
  res.write(isJson ? ']}' : `--${boundary}--${CRLF}`)
558
539
  res.end()
559
-
560
- return
561
540
  } catch (e) {
562
541
  next(e)
563
542
  }
@@ -567,21 +546,9 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
567
546
  * exports
568
547
  */
569
548
 
570
- const _multipartBatch = async (srv, router, req, res, next) => {
571
- const boundary = getBoundary(req)
572
- if (!boundary) return next(new cds.error({ status: 400, message: 'No boundary found in Content-Type header' }))
573
-
574
- try {
575
- const { requests } = await multipartToJson(req.body, boundary)
576
- _processBatch(srv, router, req, res, next, { requests }, 'MULTIPART', boundary)
577
- } catch (e) {
578
- // REVISIT: (how) handle multipart accepts?
579
- next(e)
580
- }
581
- }
582
-
583
549
  module.exports = adapter => {
584
550
  const { router, service } = adapter
551
+
585
552
  const textBodyParser = express.text({
586
553
  ...adapter.body_parser_options,
587
554
  type: '*/*' // REVISIT: why do we need to override type here?
@@ -597,9 +564,19 @@ module.exports = adapter => {
597
564
  }
598
565
 
599
566
  if (req.headers['content-type'].includes('multipart/mixed')) {
600
- return textBodyParser(req, res, function odata_batch_next(err) {
567
+ return textBodyParser(req, res, async function odata_batch_next(err) {
601
568
  if (err) return next(err)
602
- return _multipartBatch(service, router, req, res, next)
569
+
570
+ const boundary = getBoundary(req)
571
+ if (!boundary) return next(new cds.error({ status: 400, message: 'No boundary found in Content-Type header' }))
572
+
573
+ try {
574
+ const { requests } = await multipartToJson(req.body, boundary)
575
+ _processBatch(service, router, req, res, next, { requests }, 'MULTIPART', boundary)
576
+ } catch (e) {
577
+ // REVISIT: (how) handle multipart accepts?
578
+ next(e)
579
+ }
603
580
  })
604
581
  }
605
582