@sap/cds 9.7.1 → 9.8.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 +48 -0
- package/_i18n/i18n_en_US_saptrc.properties +1 -56
- package/_i18n/messages_en_US_saptrc.properties +1 -92
- package/eslint.config.mjs +4 -1
- package/lib/compile/cds-compile.js +1 -0
- package/lib/compile/for/direct_crud.js +23 -0
- package/lib/compile/for/lean_drafts.js +12 -0
- package/lib/compile/for/odata.js +1 -18
- package/lib/compile/to/edm.js +1 -0
- package/lib/compile/to/json.js +4 -2
- package/lib/env/defaults.js +1 -0
- package/lib/env/serviceBindings.js +15 -5
- package/lib/index.js +1 -1
- package/lib/log/cds-error.js +33 -20
- package/lib/req/spawn.js +2 -2
- package/lib/srv/bindings.js +6 -13
- package/lib/srv/cds.Service.js +8 -36
- package/lib/srv/protocols/hcql.js +19 -2
- package/lib/utils/cds-utils.js +25 -16
- package/lib/utils/tar-win.js +106 -0
- package/lib/utils/tar.js +23 -158
- package/libx/_runtime/common/generic/crud.js +8 -7
- package/libx/_runtime/common/generic/sorting.js +7 -3
- package/libx/_runtime/common/utils/resolveView.js +47 -40
- package/libx/_runtime/common/utils/rewriteAsterisks.js +1 -0
- package/libx/_runtime/fiori/lean-draft.js +11 -2
- package/libx/_runtime/messaging/kafka.js +6 -5
- package/libx/_runtime/messaging/service.js +3 -1
- package/libx/_runtime/remote/Service.js +3 -0
- package/libx/_runtime/remote/utils/client.js +2 -4
- package/libx/_runtime/remote/utils/query.js +4 -4
- package/libx/odata/middleware/batch.js +323 -339
- package/libx/odata/middleware/create.js +0 -5
- package/libx/odata/middleware/delete.js +0 -5
- package/libx/odata/middleware/operation.js +10 -8
- package/libx/odata/middleware/read.js +0 -10
- package/libx/odata/middleware/stream.js +1 -0
- package/libx/odata/middleware/update.js +0 -6
- package/libx/odata/parse/afterburner.js +47 -22
- package/libx/odata/parse/cqn2odata.js +6 -1
- package/libx/odata/parse/grammar.peggy +14 -2
- package/libx/odata/parse/multipartToJson.js +2 -1
- package/libx/odata/parse/parser.js +1 -1
- 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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
153
|
-
|
|
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
|
|
157
|
-
req.__proto__ = express.request
|
|
138
|
+
const subrequest = { id: request.id }
|
|
158
139
|
|
|
159
|
-
//
|
|
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.
|
|
163
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
181
|
-
|
|
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
|
-
|
|
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(
|
|
189
|
-
resolve(
|
|
192
|
+
if (res.statusCode >= 400) return reject(_err)
|
|
193
|
+
resolve(subrequest)
|
|
190
194
|
}
|
|
191
195
|
})
|
|
192
196
|
|
|
193
|
-
return
|
|
197
|
+
return subrequest
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
/*
|
|
197
201
|
* multipart/mixed response
|
|
198
202
|
*/
|
|
199
203
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
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 ${
|
|
219
|
+
txt += `HTTP/1.1 ${statusCode} ${STATUS_CODES[statusCode]}${CRLF}`
|
|
208
220
|
|
|
209
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 =
|
|
280
|
-
const { id,
|
|
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 (!
|
|
284
|
+
if (!body) return [raw]
|
|
295
285
|
|
|
296
286
|
// change last "}" into ","
|
|
297
287
|
raw[raw.byteLength - 1] = _formatStatics.comma
|
|
298
|
-
|
|
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
|
|
303
|
-
if (
|
|
304
|
-
|
|
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
|
-
*
|
|
310
|
+
* helpers
|
|
310
311
|
*/
|
|
311
312
|
|
|
312
|
-
const
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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,126 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
|
|
|
417
424
|
}
|
|
418
425
|
|
|
419
426
|
try {
|
|
420
|
-
|
|
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, agIndex, 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
|
-
|
|
485
|
+
subrequests.push(ids[id].promise)
|
|
486
|
+
}
|
|
423
487
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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)
|
|
491
|
+
.then(ress => {
|
|
492
|
+
// wait for all previous atomicity groups (ignoring errors via allSettled) for odata v2
|
|
493
|
+
const prevs = []
|
|
494
|
+
for (let i = 0; i < agIndex; i++) prevs.push(promises[i])
|
|
495
|
+
return Promise.allSettled(prevs).then(() => ress)
|
|
496
|
+
})
|
|
497
|
+
.then(ress => {
|
|
498
|
+
const failed = ress.filter(({ status }) => status === 'rejected')
|
|
499
|
+
if (!failed.length) return
|
|
500
|
+
// throw first error and call srv.on('error') for the others
|
|
501
|
+
const first = failed.shift()
|
|
502
|
+
if (srv.handlers._error?.length)
|
|
503
|
+
for (const other of failed)
|
|
504
|
+
for (const each of srv.handlers._error) each.handler.call(srv, other.reason, cds.context)
|
|
505
|
+
throw first.reason
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
.catch(err => {
|
|
509
|
+
responses._has_failure = true
|
|
428
510
|
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
}
|
|
511
|
+
// abort batch on first failure with odata.continue-on-error: false
|
|
512
|
+
if (!continue_on_error) _continue = false
|
|
436
513
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}, {})
|
|
446
|
-
: {}
|
|
447
|
-
}
|
|
514
|
+
if (!responses.some(r => r.status === 'fail')) {
|
|
515
|
+
// here, the commit was rejected even though all requests were successful (e.g., by custom handler or db consistency check)
|
|
516
|
+
_replaceResponsesWithCommitErrors(err, responses, ids)
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
.finally(() => {
|
|
520
|
+
// trigger next in queue
|
|
521
|
+
if (queue.length) queue.shift()()
|
|
448
522
|
|
|
449
|
-
|
|
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
|
-
}
|
|
523
|
+
// late error serialization
|
|
524
|
+
if (responses._has_failure) _serializeErrors(responses)
|
|
468
525
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}
|
|
526
|
+
if (isJson) _writeResponseJson(responses, res)
|
|
527
|
+
else _writeResponseMultipart(responses, res, boundary)
|
|
528
|
+
})
|
|
473
529
|
|
|
474
|
-
|
|
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
|
-
}
|
|
530
|
+
if (ids[groupId]) ids[groupId].promise = promise
|
|
493
531
|
|
|
494
|
-
|
|
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
|
-
})())
|
|
532
|
+
return promise
|
|
524
533
|
})
|
|
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
534
|
}
|
|
547
535
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
536
|
+
// queue execution of atomicity groups
|
|
537
|
+
const promises = []
|
|
538
|
+
for (let i = 0; i < atomicityGroups.length; i++) promises.push(_queued_exec(atomicityGroups[i], i))
|
|
539
|
+
|
|
540
|
+
// trigger first max_parallel in queue
|
|
541
|
+
for (let i = 0; i < max_parallel; i++) if (queue.length) queue.shift()()
|
|
542
|
+
|
|
543
|
+
await Promise.all(promises)
|
|
544
|
+
|
|
557
545
|
res.write(isJson ? ']}' : `--${boundary}--${CRLF}`)
|
|
558
546
|
res.end()
|
|
559
|
-
|
|
560
|
-
return
|
|
561
547
|
} catch (e) {
|
|
562
548
|
next(e)
|
|
563
549
|
}
|
|
@@ -567,21 +553,9 @@ const _processBatch = async (srv, router, req, res, next, body, ct, boundary) =>
|
|
|
567
553
|
* exports
|
|
568
554
|
*/
|
|
569
555
|
|
|
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
556
|
module.exports = adapter => {
|
|
584
557
|
const { router, service } = adapter
|
|
558
|
+
|
|
585
559
|
const textBodyParser = express.text({
|
|
586
560
|
...adapter.body_parser_options,
|
|
587
561
|
type: '*/*' // REVISIT: why do we need to override type here?
|
|
@@ -597,9 +571,19 @@ module.exports = adapter => {
|
|
|
597
571
|
}
|
|
598
572
|
|
|
599
573
|
if (req.headers['content-type'].includes('multipart/mixed')) {
|
|
600
|
-
return textBodyParser(req, res, function odata_batch_next(err) {
|
|
574
|
+
return textBodyParser(req, res, async function odata_batch_next(err) {
|
|
601
575
|
if (err) return next(err)
|
|
602
|
-
|
|
576
|
+
|
|
577
|
+
const boundary = getBoundary(req)
|
|
578
|
+
if (!boundary) return next(new cds.error({ status: 400, message: 'No boundary found in Content-Type header' }))
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
const { requests } = await multipartToJson(req.body, boundary)
|
|
582
|
+
_processBatch(service, router, req, res, next, { requests }, 'MULTIPART', boundary)
|
|
583
|
+
} catch (e) {
|
|
584
|
+
// REVISIT: (how) handle multipart accepts?
|
|
585
|
+
next(e)
|
|
586
|
+
}
|
|
603
587
|
})
|
|
604
588
|
}
|
|
605
589
|
|