@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.
- package/CHANGELOG.md +56 -0
- package/app/index.css +3 -0
- package/app/index.js +50 -4
- package/bin/serve.js +1 -1
- package/lib/compile/cdsc.js +2 -2
- package/lib/compile/etc/_localized.js +1 -1
- package/lib/compile/for/lean_drafts.js +1 -0
- package/lib/compile/to/sql.js +2 -2
- package/lib/env/cds-requires.js +6 -0
- package/lib/env/defaults.js +14 -3
- package/lib/env/plugins.js +6 -22
- package/lib/linked/classes.js +0 -14
- package/lib/linked/types.js +12 -0
- package/lib/linked/validate.js +13 -8
- package/lib/log/cds-log.js +3 -3
- package/lib/log/format/aspects/als.js +23 -29
- package/lib/log/format/aspects/cls.js +9 -0
- package/lib/log/format/json.js +42 -6
- package/lib/ql/Whereable.js +5 -1
- package/lib/srv/cds-connect.js +33 -32
- package/lib/srv/cds-serve.js +2 -1
- package/lib/srv/middlewares/cds-context.js +2 -1
- package/lib/utils/cds-utils.js +4 -2
- package/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-commons/validator/ValueValidator.js +1 -1
- package/libx/_runtime/cds-services/adapter/odata-v4/utils/metaInfo.js +1 -5
- package/libx/_runtime/cds-services/adapter/odata-v4/utils/result.js +2 -31
- package/libx/_runtime/common/generic/auth/utils.js +2 -0
- package/libx/_runtime/common/generic/input.js +2 -11
- package/libx/_runtime/common/generic/put.js +1 -10
- package/libx/_runtime/common/utils/binary.js +1 -7
- package/libx/_runtime/common/utils/resolveView.js +2 -2
- package/libx/_runtime/common/utils/search2cqn4sql.js +1 -1
- package/libx/_runtime/common/utils/streamProp.js +19 -6
- package/libx/_runtime/common/utils/template.js +26 -16
- package/libx/_runtime/common/utils/templateProcessor.js +8 -7
- package/libx/_runtime/common/utils/ucsn.js +2 -5
- package/libx/_runtime/db/expand/expandCQNToJoin.js +10 -0
- package/libx/_runtime/db/generic/input.js +1 -5
- package/libx/_runtime/fiori/lean-draft.js +272 -90
- package/libx/_runtime/messaging/event-broker.js +105 -40
- package/libx/_runtime/remote/utils/client.js +12 -4
- package/libx/_runtime/ucl/Service.js +16 -6
- package/libx/odata/middleware/batch.js +2 -2
- package/libx/odata/middleware/read.js +6 -10
- package/libx/odata/middleware/stream.js +4 -5
- package/libx/odata/parse/afterburner.js +3 -2
- package/libx/odata/parse/multipartToJson.js +3 -1
- package/libx/odata/utils/index.js +3 -3
- package/libx/odata/utils/postProcess.js +3 -25
- package/libx/rest/middleware/parse.js +1 -6
- 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 (
|
|
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.
|
|
109
|
-
|
|
110
|
-
this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
if (this.
|
|
126
|
-
|
|
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
|
-
|
|
171
|
+
startListening() {
|
|
137
172
|
if (!this._listenToAll.value && !this.subscribedTopics.size) return
|
|
138
|
-
|
|
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.
|
|
189
|
-
|
|
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
|
-
|
|
246
|
+
registerWebhookEndpoints() {
|
|
211
247
|
const webhookBasePath = this.options.webhookPath || '/-/cds/event-broker/webhook'
|
|
212
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
throw new Error('
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
|
22
|
+
const _setNextLink = (req, result) => {
|
|
23
23
|
const $skiptoken = result.$nextLink ?? _calculateSkiptoken(req, result)
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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)
|
|
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
|
-
|
|
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'] ||
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
},
|