@sap/cds 7.7.3 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/lib/auth/ias-auth.js +5 -3
  3. package/lib/auth/jwt-auth.js +4 -2
  4. package/lib/compile/cdsc.js +0 -10
  5. package/lib/compile/for/java.js +9 -5
  6. package/lib/compile/for/lean_drafts.js +1 -1
  7. package/lib/compile/to/edm.js +2 -1
  8. package/lib/compile/to/sql.js +0 -21
  9. package/lib/compile/to/srvinfo.js +13 -4
  10. package/lib/dbs/cds-deploy.js +7 -7
  11. package/lib/env/cds-requires.js +6 -0
  12. package/lib/index.js +4 -3
  13. package/lib/linked/classes.js +151 -88
  14. package/lib/linked/entities.js +27 -23
  15. package/lib/linked/models.js +57 -36
  16. package/lib/linked/types.js +42 -104
  17. package/lib/ql/Whereable.js +3 -3
  18. package/lib/req/context.js +8 -4
  19. package/lib/srv/protocols/hcql.js +2 -1
  20. package/lib/srv/protocols/http.js +7 -7
  21. package/lib/srv/protocols/index.js +31 -13
  22. package/lib/srv/protocols/odata-v4.js +79 -58
  23. package/lib/srv/srv-api.js +7 -6
  24. package/lib/srv/srv-dispatch.js +1 -12
  25. package/lib/srv/srv-tx.js +9 -13
  26. package/lib/utils/cds-utils.js +6 -5
  27. package/libx/_runtime/cds-services/adapter/odata-v4/ODataRequest.js +11 -8
  28. package/libx/_runtime/cds-services/adapter/odata-v4/handlers/read.js +21 -12
  29. package/libx/_runtime/cds-services/adapter/odata-v4/to.js +5 -3
  30. package/libx/_runtime/cds-services/adapter/odata-v4/utils/handlerUtils.js +3 -7
  31. package/libx/_runtime/cds-services/adapter/odata-v4/utils/metaInfo.js +5 -0
  32. package/libx/_runtime/cds-services/adapter/odata-v4/utils/readAfterWrite.js +2 -1
  33. package/libx/_runtime/cds-services/adapter/odata-v4/utils/request.js +3 -7
  34. package/libx/_runtime/cds-services/services/utils/columns.js +6 -3
  35. package/libx/_runtime/cds.js +0 -13
  36. package/libx/_runtime/common/generic/input.js +3 -0
  37. package/libx/_runtime/common/generic/sorting.js +8 -6
  38. package/libx/_runtime/common/i18n/messages.properties +1 -0
  39. package/libx/_runtime/common/utils/cqn.js +5 -0
  40. package/libx/_runtime/common/utils/foreignKeyPropagations.js +7 -1
  41. package/libx/_runtime/common/utils/keys.js +2 -2
  42. package/libx/_runtime/common/utils/resolveView.js +2 -1
  43. package/libx/_runtime/common/utils/rewriteAsterisks.js +1 -1
  44. package/libx/_runtime/common/utils/stream.js +0 -10
  45. package/libx/_runtime/common/utils/template.js +20 -35
  46. package/libx/_runtime/db/Service.js +5 -1
  47. package/libx/_runtime/db/utils/columns.js +1 -1
  48. package/libx/_runtime/fiori/lean-draft.js +14 -2
  49. package/libx/_runtime/messaging/Outbox.js +7 -5
  50. package/libx/_runtime/messaging/kafka.js +266 -0
  51. package/libx/_runtime/messaging/service.js +7 -5
  52. package/libx/_runtime/sqlite/convertDraftAdminPathExpression.js +1 -0
  53. package/libx/common/assert/validation.js +1 -1
  54. package/libx/odata/index.js +8 -2
  55. package/libx/odata/middleware/batch.js +340 -0
  56. package/libx/odata/middleware/create.js +43 -46
  57. package/libx/odata/middleware/delete.js +27 -15
  58. package/libx/odata/middleware/error.js +6 -5
  59. package/libx/odata/middleware/metadata.js +16 -15
  60. package/libx/odata/middleware/operation.js +107 -59
  61. package/libx/odata/middleware/parse.js +15 -7
  62. package/libx/odata/middleware/read.js +150 -24
  63. package/libx/odata/middleware/service-document.js +17 -6
  64. package/libx/odata/middleware/stream.js +34 -17
  65. package/libx/odata/middleware/update.js +123 -87
  66. package/libx/odata/parse/afterburner.js +131 -28
  67. package/libx/odata/parse/cqn2odata.js +1 -1
  68. package/libx/odata/parse/grammar.peggy +4 -5
  69. package/libx/odata/parse/multipartToJson.js +163 -0
  70. package/libx/odata/parse/parser.js +1 -1
  71. package/libx/odata/utils/index.js +29 -47
  72. package/libx/odata/utils/path.js +72 -0
  73. package/libx/odata/utils/result.js +123 -20
  74. package/package.json +1 -1
  75. package/server.js +4 -0
@@ -1,78 +1,126 @@
1
1
  const cds = require('../../../')
2
2
 
3
- const { toODataResult } = require('../utils/result')
4
- const { cds2edm } = require('../utils')
3
+ const { toODataResult, postProcess } = require('../utils/result')
4
+ const { cds2edm, calculateLocationHeader, getKeysAndParamsFromPath, handleSapMessages } = require('../utils')
5
5
 
6
6
  const { deepCopy } = require('../../_runtime/common/utils/copy')
7
7
 
8
8
  // REVISIT: move to or rewrite in libx/odata
9
+ const { readAfterWrite } = require('../../_runtime/cds-services/adapter/odata-v4/utils/readAfterWrite')
9
10
  const metaInfo = require('../../_runtime/cds-services/adapter/odata-v4/utils/metaInfo')
10
11
 
11
- module.exports = srv => (req, res, next) => {
12
- let { operation, args } = req._query.SELECT.from.ref.slice(-1)[0]
13
- if (!operation) return next() //> create or read
14
-
15
- // unbound vs. bound
16
- let entity
17
- if (srv.model.definitions[operation]) {
18
- operation = srv.model.definitions[operation]
19
- } else {
20
- req._query.SELECT.from.ref.pop()
21
- // TODO: this does not work when navigating to the entity
22
- const lastRef = req._query.SELECT.from.ref.slice(-1)[0]
23
- entity = lastRef.id || lastRef
24
- entity = srv.model.definitions[entity]
25
- operation = entity.actions[operation]
26
- }
12
+ const DRAFT_EVENTS = { draftActivate: 1, EDIT: 1, draftPrepare: 1 }
27
13
 
28
- const data = args || deepCopy(req.body)
14
+ module.exports = srv =>
15
+ function operation(req, res, next) {
16
+ let { operation, args } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
17
+ if (!operation) return next() //> create or read
29
18
 
30
- // assert payload
31
- const assertOptions = { filter: true, http: { req }, mandatories: true }
32
- const errs = cds.assert(data, operation, assertOptions)
33
- if (errs) {
34
- if (errs.length === 1) throw errs[0]
35
- throw Object.assign(new Error('MULTIPLE_ERRORS'), { statusCode: 400, details: errs })
36
- }
19
+ // unbound vs. bound
20
+ let entity, /* keys, */ params
21
+ if (srv.model.definitions[operation]) {
22
+ operation = srv.model.definitions[operation]
23
+ } else {
24
+ req._query.SELECT.from.ref.pop()
25
+ let cur = { elements: srv.model.definitions }
26
+ for (const each of req._query.SELECT.from.ref) {
27
+ cur = cur.elements[each.id || each]
28
+ if (cur._target) cur = cur._target
29
+ }
30
+ operation = cur.actions[operation]
31
+ entity = cur
32
+ const keysAndParams = getKeysAndParamsFromPath(req._query.SELECT.from, srv)
33
+ params = keysAndParams.params
34
+ }
35
+
36
+ // payload & params
37
+ const data = args || deepCopy(req.body)
37
38
 
38
- // REVISIT: when is operation.name actually prefixed with the service name?
39
- const event = operation.name.replace(`${srv.name}.`, '')
39
+ // assert payload
40
+ const assertOptions = { filter: true, http: { req }, mandatories: true }
41
+ const errs = cds.assert(data, operation, assertOptions)
42
+ if (errs) {
43
+ if (errs.length === 1) throw errs[0]
44
+ throw Object.assign(new Error('MULTIPLE_ERRORS'), { statusCode: 400, details: errs })
45
+ }
40
46
 
41
- // TODO: params
42
- const cdsReq = new cds.Request({ query: entity ? req._query : undefined, event, data, params: [] })
47
+ // event
48
+ // REVISIT: when is operation.name actually prefixed with the service name?
49
+ let event = operation.name.replace(`${srv.name}.`, '')
50
+ // REVISIT: rewrite draft event -> do centrally in draft impl
51
+ if (event === 'draftEdit') event = 'EDIT'
43
52
 
44
- srv
45
- .dispatch(cdsReq)
46
- .then(result => {
47
- // REVISIT: result === undefined valid for modelled return type?
48
- if (!operation.returns || result === undefined) return res.sendStatus(204)
53
+ const query = entity ? req._query : undefined
49
54
 
50
- if (operation.returns._type?.match?.(/^cds\./)) {
51
- // TODO: check result type
52
- return res.json({
53
- '@odata.context': `${entity ? '../' : ''}$metadata#${cds2edm[operation.returns._type]}`,
54
- value: result
55
+ // we need a cds.Request for multiple reasons, incl. params, headers, sap-messages, read after write, ...
56
+ const cdsReq = new cds.Request({ query, event, data, params, target: query?.target, req, res })
57
+ Object.defineProperty(cdsReq, 'protocol', { value: 'odata-v4' })
58
+
59
+ // REVISIT: only via srv.run in combination with srv.dispatch inside
60
+ // we automatically either use a single auto-managed tx for the req (i.e., insert and read after write in same tx)
61
+ // or the auto-managed tx opened for the respective atomicity group, if exists
62
+ return srv
63
+ .run(() => {
64
+ return srv.dispatch(cdsReq).then(result => {
65
+ handleSapMessages(cdsReq, req, res)
66
+
67
+ // FIXME: should be handled in draft impl
68
+ if (event in /* { draftActivate: 1, EDIT: 1 } */ DRAFT_EVENTS /* && cdsReq._.readAfterWrite */) {
69
+ let columns
70
+ const queryOptions = req.url.split('?')[1]
71
+ if (queryOptions) columns = cds.odata.parse(`/X?${queryOptions}`).SELECT.columns
72
+ return readAfterWrite(cdsReq, srv, { operation: { result, returnType: operation.returns }, columns })
73
+ }
74
+
75
+ return result
55
76
  })
56
- }
77
+ })
78
+ .then(result => {
79
+ // we use an extra then block, after getting the result, so the transaction is commited, before sending the response
80
+ if (!operation.returns || result == null) return res.status(204).end()
57
81
 
58
- const assertOptions = { mandatories: true } //> TODO: more needed?
59
- // TODO: error targets are not correct if return type is "many X"
60
- const assertDefinition = operation.returns.items || operation.returns
61
- const errs = cds.assert(result, assertDefinition, assertOptions)
62
- if (errs) {
63
- // TODO: proper error handling
64
- }
82
+ if (operation.returns._type?.match?.(/^cds\./)) {
83
+ // TODO: check result type
84
+ return res.set('Content-Type', 'application/json;IEEE754Compatible=true').send({
85
+ '@odata.context': `${entity ? '../' : ''}$metadata#${cds2edm[operation.returns._type]}`,
86
+ value: result
87
+ })
88
+ }
89
+
90
+ const info = metaInfo(req._query, event, srv, result, req)
91
+
92
+ // FIXME: info.metadata.isCollection and contextUrl are incorrect for draft events
93
+ if (event in /* { draftActivate: 1, EDIT: 1 } */ DRAFT_EVENTS) {
94
+ info.metadata.isCollection = false
95
+ info.metadata.contextUrl += '/$entity'
96
+ }
97
+
98
+ // FIXME: info.metadata.isCollection is incorrect
99
+ if (!operation.returns.items) info.metadata.isCollection = false
65
100
 
66
- const info = metaInfo(req._query, event, srv, result, req)
67
- // FIXME: info.metadata.isCollection is incorrect for draftActivate
68
- if (event === 'draftActivate') info.metadata.isCollection = false
69
- result = toODataResult(result, info)
101
+ if (info.metadata.returnType) {
102
+ postProcess(info.metadata.returnType, srv, result)
103
+ if (result['$etag']) res.set('etag', result['$etag'])
104
+ }
70
105
 
71
- // TODO: toODataResult() doesn't seem to handle this case
72
- if (entity && !result['@odata.context'].match(/^\.\.\//))
73
- result['@odata.context'] = '../' + result['@odata.context']
106
+ result = toODataResult(result, info)
74
107
 
75
- res.json(result)
76
- })
77
- .catch(next)
78
- }
108
+ // FIXME: draftActivate needs location header -> move to draft impl
109
+ // FIXME: draftActivate needs HasActiveEntity and HasDraftEntity -> move to draft impl
110
+ if (event in /* { draftActivate: 1, EDIT: 1 } */ DRAFT_EVENTS) {
111
+ res.set('location', '../' + calculateLocationHeader(cdsReq.target, srv, result))
112
+ result.HasDraftEntity = false
113
+ if (event === 'draftActivate' || event === 'draftPrepare') result.HasActiveEntity = false
114
+ }
115
+
116
+ // // FIXME: draftEdit needs HasDraftEntity -> move to draft impl
117
+ // if (event === 'EDIT') result.HasDraftEntity = false
118
+
119
+ // FIXME: toODataResult() doesn't seem to handle this case
120
+ if (entity && !result['@odata.context'].match(/^\.\.\//))
121
+ result['@odata.context'] = '../' + result['@odata.context']
122
+
123
+ res.set('Content-Type', 'application/json;IEEE754Compatible=true').send(result)
124
+ })
125
+ .catch(next)
126
+ }
@@ -1,11 +1,19 @@
1
1
  const cds = require('../../../')
2
2
 
3
- module.exports = srv => (req, _, next) => {
4
- // if not a GET, use req.path instead of req.url to ignore query parameters
5
- req._query = cds.odata.parse(req.method === 'GET' ? req.url : req.path, { service: srv, baseUrl: req.baseUrl })
3
+ module.exports = srv =>
4
+ function parse(req, _, next) {
5
+ // REVISIT: can't we register the batch handler before the parse handler to avoid this?
6
+ if (req.path.startsWith('/$batch')) return next()
6
7
 
7
- const preferReturn = req.headers.prefer?.match(/\W?return=(\w+)/i)
8
- if (preferReturn) req._preferReturn = preferReturn[1]
8
+ // if not a GET, use req.path instead of req.url to ignore query parameters
9
+ req._query = cds.odata.parse(req.method === 'GET' ? req.url : req.path, {
10
+ service: srv,
11
+ baseUrl: req.baseUrl,
12
+ strict: true
13
+ })
9
14
 
10
- next()
11
- }
15
+ const preferReturn = req.headers.prefer?.match(/\W?return=(\w+)/i)
16
+ if (preferReturn) req._preferReturn = preferReturn[1]
17
+
18
+ next()
19
+ }
@@ -1,13 +1,12 @@
1
1
  const cds = require('../../../')
2
- const { toODataResult } = require('../utils/result')
2
+ const { toODataResult, postProcess } = require('../utils/result')
3
3
  const querystring = require('node:querystring')
4
- const { getPageSize } = require('../../_runtime/common/generic/paging')
4
+ const { getKeysAndParamsFromPath, handleSapMessages } = require('../utils')
5
5
  const { handleStreamProperties } = require('../../_runtime/common/utils/streamProp')
6
6
 
7
- const { getKeysFromPath } = require('../utils')
8
-
9
7
  // REVISIT: move to or rewrite in libx/odata
10
8
  const metaInfo = require('../../_runtime/cds-services/adapter/odata-v4/utils/metaInfo')
9
+ const { getPageSize } = require('../../_runtime/common/generic/paging')
11
10
 
12
11
  const _getCount = result =>
13
12
  Array.isArray(result)
@@ -17,7 +16,7 @@ const _getCount = result =>
17
16
  : result.$count || result._counted_ || 0
18
17
 
19
18
  const _calculateNextLink = (req, result) => {
20
- const $skiptoken = _calculateSkiptoken(req, result)
19
+ const $skiptoken = result.$nextLink ?? _calculateSkiptoken(req, result)
21
20
  if ($skiptoken) {
22
21
  const queryParamsWithSkipToken = { ...req.http.req.query, $skiptoken }
23
22
  // REVISIT: slice replaces leading '/'. Always starts with '/'?
@@ -65,26 +64,140 @@ const _reliablePagingPossible = req => {
65
64
  )
66
65
  }
67
66
 
67
+ const _checkExpandDeep = (column, entity, namespace) => {
68
+ const { expand } = column
69
+ if (expand.length > 1 || expand[0] !== '*') {
70
+ for (const expandColumn of expand) {
71
+ if (expandColumn === '*') continue
72
+ if (expandColumn.expand) {
73
+ _checkExpandDeep(expandColumn, entity.elements[expandColumn.ref[0]]._target, namespace)
74
+ }
75
+ }
76
+ }
77
+ if (!entity.name.startsWith(namespace) && !entity._service) {
78
+ // proxy, only add keys
79
+ const asteriskIndex = column.expand.findIndex(e => e === '*')
80
+ column.expand.splice(asteriskIndex)
81
+ for (const key in entity.keys) {
82
+ if (entity.elements[key].isAssociation) continue
83
+ column.expand.push({ ref: [key] })
84
+ }
85
+ }
86
+ }
87
+
88
+ const resolveProxyExpands = ({ SELECT: { columns }, target: entity }, service) => {
89
+ if (!columns) return
90
+
91
+ for (const column of columns) {
92
+ if (column.expand) {
93
+ _checkExpandDeep(column, entity.elements[column.ref[0]]._target, service.namespace)
94
+ }
95
+ }
96
+ }
97
+
98
+ const _isNullableSingleton = query => query._target._isSingleton && query._target['@odata.singleton.nullable']
99
+
100
+ const _isToOneAssoc = query =>
101
+ query.SELECT.from.ref.length > 1 && typeof query.SELECT.from.ref.slice(-1)[0] === 'string'
102
+
103
+ // basically stolen from old read handler without understanding it ^^
104
+ const _handleArrayOfQueries = (srv, req, res, next) => {
105
+ const info = metaInfo(req._query, 'READ', srv, {}, req, false)
106
+ const cdsReq = new cds.Request({ query: req._query, req, res })
107
+ srv
108
+ .dispatch(cdsReq)
109
+ .then(result => {
110
+ handleSapMessages(cdsReq, req, res)
111
+
112
+ if (req.url.match(/\/\$count/)) {
113
+ const count = Array.isArray(result)
114
+ ? result.reduce((acc, val) => {
115
+ return (
116
+ acc + ((val && (val.$count || val._counted_)) || (val[0] && (val[0].$count || val[0]._counted_))) || 0
117
+ )
118
+ }, 0)
119
+ : result.$count || result._counted_ || 0
120
+ return res.set('Content-Type', 'text/plain').send(count.toString())
121
+ }
122
+
123
+ const adjustedResult = []
124
+ if (cdsReq.query[0].SELECT.count) adjustedResult.$count = 0
125
+ adjustedResult.push(...result[0])
126
+ adjustedResult.$count += result[0].$count ? result[0].$count : 0
127
+ for (let i = 1; i < result.length; i++) {
128
+ adjustedResult.push(...result[i])
129
+ adjustedResult.$count += result[i].$count ? result[i].$count : 0
130
+ // Add OData context, if it deviates from main context
131
+ if (info.metadata.contextUrl !== info.metadata.additionalContextUrl[i - 1])
132
+ result[i].forEach(entry => (entry['@odata.context'] = info.metadata.additionalContextUrl[i - 1]))
133
+ }
134
+ result.splice(0, result.length, ...adjustedResult)
135
+ if (cdsReq.query[0].SELECT.count) result.$count = adjustedResult.$count || 0
136
+ result = toODataResult(result, info)
137
+ res.set('Content-Type', 'application/json;IEEE754Compatible=true').send(result)
138
+ })
139
+ .catch(next)
140
+ }
141
+
68
142
  module.exports = srv =>
69
143
  function read(req, res, next) {
144
+ // disable express etag checks
145
+ req.headers['cache-control'] = 'no-cache'
146
+
70
147
  if (req._preferReturn) {
71
- const message = `The 'return' preference is not allowed in ${req.method} requests`
72
- return res.status(400).json({ error: { code: '400', message } })
148
+ throw Object.assign(new Error(`The 'return' preference is not allowed in ${req.method} requests`), {
149
+ statusCode: 400
150
+ })
73
151
  }
74
152
 
153
+ // $apply with concat -> multiple queries with special handling
154
+ if (Array.isArray(req._query)) return _handleArrayOfQueries(srv, req, res, next)
155
+
156
+ // REVISIT: better solution for _propertyAccess
157
+ let {
158
+ SELECT: { from },
159
+ target,
160
+ _propertyAccess
161
+ } = req._query
75
162
  const { _query: query } = req
76
163
 
77
- // mainly for @odata.context
164
+ // payload & params
165
+ const { keys, params } = getKeysAndParamsFromPath(from, srv)
166
+ const data = keys //> for read and delete, we provide keys in req.data
167
+
168
+ // we need a cds.Request for multiple reasons, incl. params, headers, sap-messages, read after write, ...
169
+ const cdsReq = new cds.Request({ query, data, params, req, res })
170
+ Object.defineProperty(cdsReq, 'protocol', { value: 'odata-v4' })
171
+
172
+ // REVISIT: what is this for? some tests fail without it... we should find a better solution!
173
+ Object.defineProperty(query.SELECT, '_4odata', { value: true })
174
+
175
+ // do now to get meta info before the query is rewritten + to know return type
78
176
  const info = metaInfo(query, 'READ', srv, {}, req, false)
79
177
 
178
+ // FIXME: wrong contextUrl for SiblingEntity
179
+ if (info.metadata.contextUrl.match(/\/SiblingEntity\//)) {
180
+ const split = info.metadata.contextUrl.split('/')
181
+ const i = split.findIndex(s => s === 'SiblingEntity')
182
+ split.splice(i, 1)
183
+ if (split[i - 1].match(/IsActiveEntity=false/)) {
184
+ split[i - 1] = split[i - 1].replace('IsActiveEntity=false', 'IsActiveEntity=true')
185
+ info.metadata.contextUrl = split.join('/')
186
+ } else {
187
+ info.metadata.contextUrl = split.join('/').replace(/IsActiveEntity=true/g, 'IsActiveEntity=false')
188
+ }
189
+ }
190
+
80
191
  const lastPathElement = req.path.split('/').slice(-1)[0]
81
192
 
82
- handleStreamProperties(query.target, query.SELECT.columns, srv.model)
193
+ query.SELECT.columns ??= ['*']
194
+
195
+ if (cds.env.effective.odata.proxies && cds.env.effective.odata.xrefs) {
196
+ // REVISIT check above is still not perfect solution
197
+ resolveProxyExpands(query, srv)
198
+ }
83
199
 
84
- // 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
85
- const cdsReq = new cds.Request({ query })
86
- // for read and delete, we provide keys in req.data
87
- cdsReq.data = getKeysFromPath(query.SELECT.from, srv)
200
+ handleStreamProperties(target, query.SELECT.columns, srv.model)
88
201
 
89
202
  // REVISIT: what is this for? some tests fail without it... we should find a better solution!
90
203
  Object.defineProperty(query.SELECT, '_4odata', { value: true })
@@ -92,25 +205,38 @@ module.exports = srv =>
92
205
  return srv
93
206
  .dispatch(cdsReq)
94
207
  .then(result => {
95
- if (result == null && query._target._isSingleton && query._target['@odata.singleton.nullable'])
96
- return res.sendStatus(204)
208
+ handleSapMessages(cdsReq, req, res)
97
209
 
98
- if (lastPathElement === '$count') {
99
- result = _getCount(result)
100
- return res.send(result.toString())
101
- } else if (lastPathElement === '$value' && query._propertyAccess) {
102
- return res.send(result[query._propertyAccess].toString())
210
+ if (cdsReq.target._etag && result == null && cdsReq.headers['if-none-match']) {
211
+ return res.status(304).end()
103
212
  }
104
213
 
105
- if (query._propertyAccess && result[query._propertyAccess] === null) return res.sendStatus(204)
214
+ if (result == null) {
215
+ if (_isNullableSingleton(query) || _isToOneAssoc(query)) return res.sendStatus(204)
216
+ throw Object.assign(new Error(`Not Found`), {
217
+ statusCode: 404
218
+ })
219
+ }
220
+
221
+ if (_propertyAccess && result[_propertyAccess] === null) return res.sendStatus(204)
106
222
 
107
- if (result == null) return res.status(404).json({ error: { code: '404', message: 'Not Found' } })
223
+ if (lastPathElement === '$count') {
224
+ result = _getCount(result)
225
+ return res.set('Content-Type', 'text/plain').send(result.toString())
226
+ } else if (lastPathElement === '$value' && _propertyAccess) {
227
+ return res.set('Content-Type', 'text/plain').send(result[_propertyAccess].toString())
228
+ }
108
229
 
109
- if (info.metadata.isCollection && !result.$nextLink) _calculateNextLink(cdsReq, result)
230
+ if (info.metadata.isCollection) _calculateNextLink(cdsReq, result)
231
+ postProcess(cdsReq.target, srv, result)
232
+ if (result['$etag']) res.set('etag', result['$etag'])
110
233
  result = toODataResult(result, info)
111
234
 
112
235
  // Express interprets numbers as HTTP status codes
113
- res.send(typeof result === 'number' ? result.toString() : result)
236
+ const isNumber = typeof result === 'number'
237
+ res
238
+ .set('Content-Type', isNumber ? 'text/plain' : 'application/json;IEEE754Compatible=true')
239
+ .send(isNumber ? result.toString() : result)
114
240
  })
115
241
  .catch(next)
116
242
  }
@@ -1,14 +1,15 @@
1
1
  const cds = require('../../../')
2
2
 
3
3
  const crypto = require('crypto')
4
+ const metaInfo = require('../../_runtime/cds-services/adapter/odata-v4/utils/metaInfo')
4
5
 
5
6
  const normalize_header = value => {
6
7
  return value.split(',').map(str => str.trim())
7
8
  }
8
9
 
9
- const validate_etag = (ifNoneMatch, etag) => {
10
- const ifNoneMatchEtags = normalize_header(ifNoneMatch)
11
- return ifNoneMatchEtags.includes(etag) || ifNoneMatchEtags.includes('*')
10
+ const validate_etag = (header, etag) => {
11
+ const normalized = normalize_header(header)
12
+ return normalized.includes(etag) || normalized.includes('*') || normalized.includes('"*"')
12
13
  }
13
14
 
14
15
  const generateEtag = s => {
@@ -19,13 +20,20 @@ module.exports = srv =>
19
20
  function service_document(req, res) {
20
21
  if (req.method === 'HEAD') return res.end()
21
22
  if (req.method !== 'GET')
22
- return res.status(405).json({
23
- error: { code: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed for service document.` }
23
+ throw Object.assign(new Error(`Method ${req.method} not allowed for service document.`), {
24
+ statusCode: 405
24
25
  })
25
26
 
26
27
  const m = cds.context.model || cds.model
27
28
  const csnService = (cds.context.model || cds.model).definitions[srv.name]
28
29
 
30
+ if (req.headers['if-match']) {
31
+ if (csnService.srvDocEtag) {
32
+ const valid = validate_etag(req.headers['if-match'], csnService.srvDocEtag)
33
+ if (!valid) return res.status(412).end()
34
+ }
35
+ }
36
+
29
37
  if (req.headers['if-none-match']) {
30
38
  if (csnService.srvDocEtag) {
31
39
  const unchanged = validate_etag(req.headers['if-none-match'], csnService.srvDocEtag)
@@ -44,8 +52,11 @@ module.exports = srv =>
44
52
 
45
53
  csnService.srvDocEtag = generateEtag(JSON.stringify(exposedEntities))
46
54
  res.set('Etag', csnService.srvDocEtag)
55
+
56
+ const info = metaInfo({ SELECT: { from: { ref: [srv.name] } } }, 'READ', srv, {}, req, false)
57
+
47
58
  return res.json({
48
- '@odata.context': `$metadata`,
59
+ '@odata.context': info.metadata.contextUrl,
49
60
  '@odata.metadataEtag': csnService.srvDocEtag,
50
61
  value: exposedEntities.map(e => {
51
62
  const e_ = e.replace(/\./g, '_')
@@ -3,7 +3,7 @@ const { Readable } = require('node:stream')
3
3
  const getError = require('../../_runtime/common/error')
4
4
  const { getTransition } = require('../../_runtime/common/utils/resolveView')
5
5
  const LOG = cds.log('odata')
6
- const { getKeysFromPath } = require('../utils')
6
+ const { getKeysAndParamsFromPath, handleSapMessages } = require('../utils')
7
7
 
8
8
  const _resolveContentProperty = (target, annotName, resolvedProp) => {
9
9
  if (target.elements[resolvedProp]) {
@@ -25,7 +25,7 @@ const isStream = query => {
25
25
  }
26
26
 
27
27
  const isStreamByDollarValue = (query, previous, last) => {
28
- return query.SELECT.one && last === '$value' && !(previous in query.target.elements)
28
+ return query.SELECT?.one && last === '$value' && !(previous in query.target.elements)
29
29
  }
30
30
 
31
31
  const _addMetadataProperty = (query, property, annotName, odataName) => {
@@ -82,11 +82,7 @@ const validateStream = (req, res, result) => {
82
82
 
83
83
  // Reading one entity or a property of it should yield only a result length of one.
84
84
  if (result.length === 0 || result[0] === undefined) {
85
- if (req.headers['if-none-match']) {
86
- // TODO this should probably end the request?
87
- res.status(304)
88
- return
89
- }
85
+ if (req.headers['if-none-match']) return
90
86
  throw getError(404)
91
87
  }
92
88
 
@@ -122,6 +118,8 @@ const _ensureStream = stream => {
122
118
  }
123
119
 
124
120
  const normalizeStream = (result, propertyName, lastPathElement, target) => {
121
+ if (!result) return null
122
+
125
123
  let readable = result
126
124
  if (typeof result === 'object') {
127
125
  if (propertyName && result[propertyName] !== undefined) {
@@ -174,6 +172,9 @@ const stream = srv =>
174
172
  function streamHandler(req, res, next) {
175
173
  const { _query: query } = req
176
174
 
175
+ // $apply with concat -> multiple queries with special handling -> read only, no stream?
176
+ if (Array.isArray(query)) return next(null, req, res)
177
+
177
178
  const [previous, lastPathElement] = req.path.split('/').slice(-2)
178
179
  const _isStreamByDollarValue = isStreamByDollarValue(query, previous, lastPathElement)
179
180
 
@@ -198,37 +199,53 @@ const stream = srv =>
198
199
  if (!query.target['@cds.persistence.skip']) addStreamMetadata(query)
199
200
 
200
201
  // 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
201
- const cdsReq = new cds.Request({ query })
202
+ const cdsReq = new cds.Request({ query, req, res })
203
+ Object.defineProperty(cdsReq, 'protocol', { value: 'odata-v4' })
204
+
202
205
  // for read and delete, we provide keys in req.data
203
- cdsReq.data = getKeysFromPath(query.SELECT.from, srv)
206
+ // payload & params
207
+ const { keys } = getKeysAndParamsFromPath(query.SELECT.from, srv)
208
+ cdsReq.data = keys
204
209
 
205
210
  // REVISIT: what is this for? some tests fail without it... we should find a better solution!
206
211
  Object.defineProperty(query.SELECT, '_4odata', { value: true })
207
212
 
208
- return srv.tx(() => {
209
- return srv
210
- .dispatch(cdsReq)
211
- .then(async result => {
213
+ return srv
214
+ .tx(() => {
215
+ return srv.dispatch(cdsReq).then(async result => {
216
+ handleSapMessages(cdsReq, req, res)
212
217
  validateStream(req, res, result)
213
218
 
214
219
  const stream = normalizeStream(result, query._propertyAccess, lastPathElement, query.target)
215
220
  if (stream === null) {
216
221
  if (req.headers['if-none-match']) {
217
- return res.status(304).json({})
222
+ res.status(304)
223
+ return
218
224
  }
219
- return res.status(204).json({})
225
+ res.status(204)
226
+ return
220
227
  }
221
228
 
222
229
  setStreamingHeaders(result, res)
223
230
 
224
231
  return new Promise((resolve, reject) => {
232
+ if (res.destroyed) return reject(new Error('Response is closed while streaming'))
225
233
  stream.pipe(res)
226
234
  stream.on('end', () => resolve(result))
227
235
  stream.once('error', reject)
236
+ let finished = false
237
+ res.on('finish', () => {
238
+ finished = true
239
+ })
240
+ res.on('close', () => !finished && reject(new Error('Response is closed while streaming')))
228
241
  })
229
242
  })
230
- .catch(next)
231
- })
243
+ })
244
+ .then(() => {
245
+ // we use an extra then block, after getting the result, so the transaction is commited, before sending the response
246
+ res.end()
247
+ })
248
+ .catch(next) // catch outside of transaction, so tx is rolled back automatically in case of error
232
249
  }
233
250
 
234
251
  module.exports = {