@sap/cds 8.1.0 → 8.2.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.
- package/CHANGELOG.md +60 -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/index.js +3 -2
- package/lib/linked/classes.js +0 -14
- package/lib/linked/types.js +12 -0
- package/lib/linked/validate.js +3 -2
- 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/req/context.js +1 -0
- package/lib/req/locale.js +1 -1
- package/lib/srv/cds-connect.js +33 -32
- package/lib/srv/cds-serve.js +2 -1
- package/lib/srv/srv-tx.js +1 -0
- package/lib/utils/cds-utils.js +4 -2
- 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/cqn2cqn4sql.js +10 -1
- 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 +12 -1
- 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 +43 -2
- package/libx/_runtime/db/generic/input.js +1 -5
- package/libx/_runtime/fiori/lean-draft.js +287 -96
- package/libx/_runtime/messaging/event-broker.js +105 -40
- package/libx/_runtime/remote/Service.js +3 -1
- 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/create.js +5 -0
- package/libx/odata/middleware/delete.js +5 -0
- package/libx/odata/middleware/error.js +1 -0
- package/libx/odata/middleware/operation.js +6 -0
- package/libx/odata/middleware/read.js +16 -11
- package/libx/odata/middleware/stream.js +4 -5
- package/libx/odata/middleware/update.js +9 -4
- package/libx/odata/parse/afterburner.js +3 -2
- package/libx/odata/parse/multipartToJson.js +1 -1
- package/libx/odata/utils/index.js +3 -3
- package/libx/odata/utils/postProcess.js +3 -25
- package/libx/rest/middleware/error.js +1 -0
- 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
|
}
|
|
@@ -261,7 +261,9 @@ class RemoteService extends cds.Service {
|
|
|
261
261
|
const returnType = req._returnType
|
|
262
262
|
const additionalOptions = { destination, kind, resolvedTarget, returnType, destinationOptions }
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
// REVISIT: i don't believe req.context.headers is an official API
|
|
265
|
+
let jwt = req?.context?.headers?.authorization?.split(/^bearer /i)[1]
|
|
266
|
+
if (!jwt) jwt = req?.context?.http?.req?.headers?.authorization?.split(/^bearer /i)[1]
|
|
265
267
|
if (jwt) additionalOptions.jwt = jwt
|
|
266
268
|
|
|
267
269
|
// hidden compat flag in order to suppress logging response body of failed request
|
|
@@ -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
|
|
|
@@ -85,6 +85,11 @@ module.exports = (adapter, isUpsert) => {
|
|
|
85
85
|
.catch(err => {
|
|
86
86
|
handleSapMessages(cdsReq, req, res)
|
|
87
87
|
|
|
88
|
+
// REVISIT: invoke service.on('error') for failed batch subrequests
|
|
89
|
+
if (cdsReq.http.req.path.startsWith('/$batch') && service._handlers._error.length) {
|
|
90
|
+
for (const each of service._handlers._error) each.handler.call(service, err, cdsReq)
|
|
91
|
+
}
|
|
92
|
+
|
|
88
93
|
next(err)
|
|
89
94
|
})
|
|
90
95
|
}
|
|
@@ -62,6 +62,11 @@ module.exports = adapter => {
|
|
|
62
62
|
.catch(err => {
|
|
63
63
|
handleSapMessages(cdsReq, req, res)
|
|
64
64
|
|
|
65
|
+
// REVISIT: invoke service.on('error') for failed batch subrequests
|
|
66
|
+
if (cdsReq.http.req.path.startsWith('/$batch') && service._handlers._error.length) {
|
|
67
|
+
for (const each of service._handlers._error) each.handler.call(service, err, cdsReq)
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
next(err)
|
|
66
71
|
})
|
|
67
72
|
}
|
|
@@ -7,6 +7,7 @@ const { normalizeError, unwrapMultipleErrors } = require('../../_runtime/common/
|
|
|
7
7
|
module.exports = () => {
|
|
8
8
|
return function odata_error(err, req, res, next) {
|
|
9
9
|
if (err == 401 || err.code == 401) return next(err) // speed up logins, at least temporary until we reviewed and eliminated overhead that may be involved below
|
|
10
|
+
|
|
10
11
|
// REVISIT: keep?
|
|
11
12
|
// log the error (4xx -> warn)
|
|
12
13
|
_log(err)
|
|
@@ -134,6 +134,12 @@ module.exports = adapter => {
|
|
|
134
134
|
})
|
|
135
135
|
.catch(err => {
|
|
136
136
|
handleSapMessages(cdsReq, req, res)
|
|
137
|
+
|
|
138
|
+
// REVISIT: invoke service.on('error') for failed batch subrequests
|
|
139
|
+
if (cdsReq.http.req.path.startsWith('/$batch') && service._handlers._error.length) {
|
|
140
|
+
for (const each of service._handlers._error) each.handler.call(service, err, cdsReq)
|
|
141
|
+
}
|
|
142
|
+
|
|
137
143
|
next(err)
|
|
138
144
|
})
|
|
139
145
|
}
|
|
@@ -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) => {
|
|
@@ -164,6 +160,11 @@ const _handleArrayOfQueriesFactory = adapter => {
|
|
|
164
160
|
.catch(err => {
|
|
165
161
|
handleSapMessages(cdsReq, req, res)
|
|
166
162
|
|
|
163
|
+
// REVISIT: invoke service.on('error') for failed batch subrequests
|
|
164
|
+
if (cdsReq.http.req.path.startsWith('/$batch') && service._handlers._error.length) {
|
|
165
|
+
for (const each of service._handlers._error) each.handler.call(service, err, cdsReq)
|
|
166
|
+
}
|
|
167
|
+
|
|
167
168
|
next(err)
|
|
168
169
|
})
|
|
169
170
|
}
|
|
@@ -247,7 +248,7 @@ module.exports = adapter => {
|
|
|
247
248
|
if (req.query.$count) result.$count = 0
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
if (!one)
|
|
251
|
+
if (!one) _setNextLink(cdsReq, result)
|
|
251
252
|
postProcess(cdsReq.target, service, result)
|
|
252
253
|
if (result?.$etag) res.set('ETag', result.$etag) //> must be done after post processing
|
|
253
254
|
|
|
@@ -264,9 +265,13 @@ module.exports = adapter => {
|
|
|
264
265
|
res.send(result)
|
|
265
266
|
})
|
|
266
267
|
.catch(err => {
|
|
267
|
-
// REVISIT: move error middleware -> applies to all these anti patterns
|
|
268
268
|
handleSapMessages(cdsReq, req, res)
|
|
269
269
|
|
|
270
|
+
// REVISIT: invoke service.on('error') for failed batch subrequests
|
|
271
|
+
if (cdsReq.http.req.path.startsWith('/$batch') && service._handlers._error.length) {
|
|
272
|
+
for (const each of service._handlers._error) each.handler.call(service, err, cdsReq)
|
|
273
|
+
}
|
|
274
|
+
|
|
270
275
|
next(err)
|
|
271
276
|
})
|
|
272
277
|
}
|
|
@@ -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 })
|
|
@@ -127,13 +127,13 @@ module.exports = adapter => {
|
|
|
127
127
|
result = getODataResult(result, metadata, { property: _propertyAccess })
|
|
128
128
|
res.send(result)
|
|
129
129
|
})
|
|
130
|
-
.catch(
|
|
130
|
+
.catch(err => {
|
|
131
131
|
handleSapMessages(cdsReq, req, res)
|
|
132
132
|
|
|
133
133
|
// if UPSERT is allowed, redirect to POST
|
|
134
|
-
const is404 =
|
|
134
|
+
const is404 = err.code === 404 || err.status === 404 || err.statusCode === 404
|
|
135
135
|
const isForcedInsert =
|
|
136
|
-
(
|
|
136
|
+
(err.code === 412 || err.status === 412 || err.statusCode === 412) &&
|
|
137
137
|
extractIfNoneMatch(req.headers?.['if-none-match']) === '*'
|
|
138
138
|
if (!_propertyAccess && (is404 || isForcedInsert) && _isUpsertAllowed({ target, data, event: req.method })) {
|
|
139
139
|
// PUT / PATCH with if-match header means "only if already exists" -> no insert if it does not
|
|
@@ -147,8 +147,13 @@ module.exports = adapter => {
|
|
|
147
147
|
return next()
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// REVISIT: invoke service.on('error') for failed batch subrequests
|
|
151
|
+
if (cdsReq.http.req.path.startsWith('/$batch') && service._handlers._error.length) {
|
|
152
|
+
for (const each of service._handlers._error) each.handler.call(service, err, cdsReq)
|
|
153
|
+
}
|
|
154
|
+
|
|
150
155
|
// continue with caught error
|
|
151
|
-
next(
|
|
156
|
+
next(err)
|
|
152
157
|
})
|
|
153
158
|
}
|
|
154
159
|
}
|
|
@@ -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
|
}
|
|
@@ -105,7 +105,7 @@ const parseStream = async function* (body, boundary) {
|
|
|
105
105
|
chunk = `${leftover}${chunk}`
|
|
106
106
|
const lastBoundary = chunk.lastIndexOf('--')
|
|
107
107
|
const lastCRLF = chunk.lastIndexOf(CRLF)
|
|
108
|
-
if (lastBoundary > lastCRLF) {
|
|
108
|
+
if (lastBoundary > lastCRLF && lastBoundary + 2 < chunk.length) {
|
|
109
109
|
leftover = chunk.slice(lastBoundary)
|
|
110
110
|
chunk = chunk.slice(0, lastBoundary)
|
|
111
111
|
} else {
|
|
@@ -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
|
}
|
|
@@ -54,6 +54,7 @@ module.exports = () => {
|
|
|
54
54
|
|
|
55
55
|
return function rest_error(err, req, res, next) {
|
|
56
56
|
if (err == 401 || err.code == 401) return next(err) // speed up logins, at least temporary until we reviewed and eliminated overhead that may be involved below
|
|
57
|
+
|
|
57
58
|
// REVISIT: keep?
|
|
58
59
|
// log the error (4xx -> warn)
|
|
59
60
|
_log(err)
|