@sap/cds 9.0.3 → 9.1.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.
@@ -306,6 +306,7 @@ const _cqnToReqOptions = (query, service, req) => {
306
306
  const reqOptions = {
307
307
  method: queryObject.method,
308
308
  url: queryObject.path
309
+ // REVISIT: remove when we can assume that number of remote services running Okra is negligible
309
310
  // ugly workaround for Okra not allowing spaces in ( x eq 1 )
310
311
  .replace(/\( /g, '(')
311
312
  .replace(/ \)/g, ')')
@@ -30,62 +30,66 @@ module.exports = class ODataAdapter extends HttpAdapter {
30
30
  }
31
31
 
32
32
  get router() {
33
+ function set_odata_version(_, res, next) {
34
+ res.set('OData-Version', '4.0')
35
+ next()
36
+ }
37
+
38
+ function validate_representation_headers(req, res, next) {
39
+ if (req.method === 'PUT' && isStream(req._query)) {
40
+ req.body = { value: req }
41
+ return next()
42
+ }
43
+ if (req.method === 'POST' && req.headers['content-type']?.match(/multipart\/mixed/)) {
44
+ return next()
45
+ }
46
+
47
+ const contentLength = req.headers['content-length']
48
+ const [type = '', suffix] = (req.headers['content-type'] || '').split(';')
49
+ // header ending with semicolon is not allowed
50
+ const isJson = type.match(/^application\/json$/) && suffix !== ''
51
+
52
+ // POST with empty body is allowed if no content-type header is set
53
+ if (req.method === 'POST' && (!contentLength || isJson)) return jsonBodyParser(req, res, next)
54
+
55
+ if (req.method in { POST: 1, PUT: 1, PATCH: 1 }) {
56
+ if (!isJson) {
57
+ res.status(415).json({ error: { message: 'Unsupported Media Type', statusCode: 415, code: '415' } })
58
+ return
59
+ }
60
+ if (contentLength === '0') {
61
+ res.status(400).json({ error: { message: 'Expected non-empty body', statusCode: 400, code: '400' } })
62
+ return
63
+ }
64
+ }
65
+
66
+ return jsonBodyParser(req, res, next)
67
+ }
68
+
69
+ function validate_operation_http_method(req, _, next) {
70
+ // operations must have been handled above (POST or GET)
71
+ const { operation } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
72
+ next(operation ? { code: 405 } : undefined)
73
+ }
74
+
33
75
  const jsonBodyParser = bodyParser4(this)
34
76
  return (
35
77
  super.router
36
- .use(function odata_version(req, res, next) {
37
- res.set('OData-Version', '4.0')
38
- next()
39
- })
40
- // REVISIT: add middleware for negative cases?
78
+ .use(set_odata_version)
41
79
  // service root
42
- .use(/^\/$/, require('./middleware/service-document')(this))
43
- .use('/\\$metadata', require('./middleware/metadata')(this))
80
+ .all('/', require('./middleware/service-document')(this))
81
+ .all('/\\$metadata', require('./middleware/metadata')(this))
44
82
  // parse
45
83
  .use(require('./middleware/parse')(this))
46
- .use(function odata_streams(req, res, next) {
47
- if (req.method === 'PUT' && isStream(req._query)) {
48
- req.body = { value: req }
49
- return next()
50
- }
51
- if (req.method === 'POST' && req.headers['content-type']?.match(/multipart\/mixed/)) {
52
- return next()
53
- }
54
- // POST with empty body is allowed by actions
55
- if (req.method in { PUT: 1, PATCH: 1 }) {
56
- if (req.headers['content-length'] === '0') {
57
- res.status(400).json({ error: { message: 'Expected non-empty body', statusCode: 400, code: '400' } })
58
- return
59
- }
60
- }
61
- if (req.method in { POST: 1, PUT: 1, PATCH: 1 }) {
62
- const contentType = req.headers['content-type'] ?? ''
63
- let contentLength = req.headers['content-length']
64
- contentLength = contentLength ? parseInt(contentLength) : 0
65
-
66
- const parts = contentType.split(';')
67
- // header ending with semicolon is not allowed
68
- if ((contentLength && !parts[0].match(/^application\/json$/)) || parts[1] === '') {
69
- res.status(415).json({ error: { message: 'Unsupported Media Type', statusCode: 415, code: '415' } })
70
- return
71
- }
72
- }
73
-
74
- return jsonBodyParser(req, res, next)
75
- })
84
+ .use(validate_representation_headers)
76
85
  // batch
77
86
  // .all is used deliberately instead of .use so that the matched path is not stripped from req properties
78
87
  .all('/\\$batch', require('./middleware/batch')(this))
79
88
  // handle
80
- // REVISIT: with old adapter, we return 405 for HEAD requests -> check OData spec
81
89
  .head('*', (_, res) => res.sendStatus(405))
82
90
  .post('*', operation4(this), create4(this))
83
91
  .get('*', operation4(this), stream4(this), read4(this))
84
- .use('*', (req, res, next) => {
85
- // operations must have been handled above (POST or GET)
86
- const { operation } = req._query.SELECT?.from.ref?.slice(-1)[0] || {}
87
- next(operation ? { code: 405 } : undefined)
88
- })
92
+ .use(validate_operation_http_method)
89
93
  .put('*', update4(this), create4(this, 'upsert'))
90
94
  .patch('*', update4(this), create4(this, 'upsert'))
91
95
  .delete('*', delete4(this))
@@ -62,7 +62,6 @@ const _validateBatch = body => {
62
62
  throw _deserializationError(`Method '${method}' is not allowed. Only DELETE, GET, PATCH, POST or PUT are.`)
63
63
 
64
64
  _validateProperty('url', url, 'string')
65
- // TODO: need similar validation in multipart/mixed batch
66
65
  if (url.startsWith('/$batch')) throw _deserializationError('Nested batch requests are not allowed.')
67
66
 
68
67
  // TODO: support for non JSON bodies?
@@ -30,6 +30,13 @@ exports.normalizeError = (err, req, cleanse = ODATA_PROPERTIES) => {
30
30
  return err
31
31
  }
32
32
 
33
+ // TODO: This should go somewhere else ...
34
+ exports.getLocalizedMessages = (messages, req) => {
35
+ const locale = cds.i18n.locale.from(req)
36
+ for (let m of messages) _normalize(m, locale, SAP_MSG_PROPERTIES)
37
+ return messages
38
+ }
39
+
33
40
  exports.getSapMessages = (messages, req) => {
34
41
  const locale = cds.i18n.locale.from(req)
35
42
  for (let m of messages) _normalize(m, locale, SAP_MSG_PROPERTIES)
@@ -2,7 +2,7 @@ const cds = require('../../../')
2
2
 
3
3
  const { pipeline } = require('node:stream/promises')
4
4
 
5
- const { cds2edm, handleSapMessages } = require('../utils')
5
+ const { handleSapMessages } = require('../utils')
6
6
  const getODataMetadata = require('../utils/metadata')
7
7
  const postProcess = require('../utils/postProcess')
8
8
  const getODataResult = require('../utils/result')
@@ -143,12 +143,6 @@ module.exports = adapter => {
143
143
  return res.sendStatus(204)
144
144
  }
145
145
 
146
- if (operation.returns._type?.match?.(/^cds\./)) {
147
- const context = `${'../'.repeat(query?.SELECT?.from?.ref?.length)}$metadata#${cds2edm[operation.returns._type]}`
148
- result = { '@odata.context': context, value: result }
149
- return res.send(result)
150
- }
151
-
152
146
  if (res.statusCode === 201 && !res.hasHeader('location')) {
153
147
  const location = location4(operation.returns, service, result)
154
148
  if (location) res.set('location', location)
@@ -160,21 +154,21 @@ module.exports = adapter => {
160
154
  }
161
155
 
162
156
  // REVISIT: enterprise search result? -> simply return what was provided
163
- if (operation.returns.type !== 'sap.esh.SearchResult') {
164
- const isCollection = !!operation.returns.items
165
- const _target = operation.returns.items ?? operation.returns
166
- const options = { result, isCollection }
167
- if (!_target.name) {
168
- // case: return inline type def
169
- options.edmName = _opResultName({ service, operation, returnType: _target })
170
- }
171
- const SELECT = {
172
- from: query ? { ref: [...query.SELECT.from.ref, { operation: operation.name }] } : {},
173
- one: !isCollection
174
- }
175
- const metadata = getODataMetadata({ SELECT, _target }, options)
176
- result = getODataResult(result, metadata, { isCollection })
157
+ if (operation.returns.type === 'sap.esh.SearchResult') return res.send(result)
158
+
159
+ const isCollection = !!operation.returns.items
160
+ const _target = operation.returns.items ?? operation.returns
161
+ const options = { result, isCollection }
162
+ if (!_target.name) {
163
+ // case: return inline type def
164
+ options.edmName = _opResultName({ service, operation, returnType: _target })
165
+ }
166
+ const SELECT = {
167
+ from: query ? { ref: [...query.SELECT.from.ref, { operation: operation.name }] } : {},
168
+ one: !isCollection
177
169
  }
170
+ const metadata = getODataMetadata({ SELECT, _target }, options)
171
+ result = getODataResult(result, metadata, { isCollection })
178
172
 
179
173
  res.send(result)
180
174
  })
@@ -12,8 +12,7 @@ function _getDefinition(definition, name, namespace) {
12
12
  return (
13
13
  definition?.definitions?.[name] ||
14
14
  definition?.elements?.[name] ||
15
- (definition.actions && (definition.actions[name] || definition.actions[name.replace(namespace + '.', '')])) ||
16
- definition[name]
15
+ (definition.actions && (definition.actions[name] || definition.actions[name.replace(namespace + '.', '')]))
17
16
  )
18
17
  }
19
18
 
@@ -250,13 +249,17 @@ function _handleCollectionBoundActions(current, ref, i, namespace, one) {
250
249
  )
251
250
 
252
251
  if (onCollection && one) {
253
- const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${action.name}" must be called on a collection of ${current.name}`
252
+ const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${
253
+ action.name
254
+ }" must be called on a collection of ${current.name}`
254
255
  throw Object.assign(new Error(msg), { statusCode: 400 })
255
256
  }
256
257
 
257
258
  if (incompleteKeys) {
258
259
  if (!onCollection) {
259
- const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${action.name}" must be called on a single instance of ${current.name}`
260
+ const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${
261
+ action.name
262
+ }" must be called on a single instance of ${current.name}`
260
263
  throw Object.assign(new Error(msg), { statusCode: 400 })
261
264
  }
262
265
 
@@ -511,7 +514,9 @@ function _processSegments(from, model, namespace, cqn, protocol) {
511
514
 
512
515
  if (incompleteKeys) {
513
516
  // > last segment not fully qualified
514
- const msg = `Entity "${current.name}" has ${keysOf(current).length} keys. Only ${keyCount} ${keyCount === 1 ? 'was' : 'were'} provided.`
517
+ const msg = `Entity "${current.name}" has ${keysOf(current).length} keys. Only ${keyCount} ${
518
+ keyCount === 1 ? 'was' : 'were'
519
+ } provided.`
515
520
  throw Object.assign(new Error(msg), { statusCode: 400 })
516
521
  }
517
522
 
@@ -821,7 +826,13 @@ module.exports = (cqn, model, namespace, protocol) => {
821
826
 
822
827
  // hierarchy requests, quick check to avoid unnecessary traversing
823
828
  // REVISIT: Should be done via annotation on backlink, would make lookup easier
824
- if (target?.elements?.LimitedDescendantCount) {
829
+ const _getRecurse = SELECT => {
830
+ if (SELECT.recurse) return SELECT.recurse
831
+ if (SELECT.from && SELECT.from.SELECT) return _getRecurse(SELECT.from.SELECT)
832
+ }
833
+
834
+ const _recurse = _getRecurse(cqn.SELECT)
835
+ if (_recurse) {
825
836
  let uplinkName
826
837
  for (const key in target) {
827
838
  if (key.match(/@Aggregation\.RecursiveHierarchy\s*#.*\.ParentNavigationProperty/)) {
@@ -830,10 +841,12 @@ module.exports = (cqn, model, namespace, protocol) => {
830
841
  break
831
842
  }
832
843
  }
833
- if (uplinkName) {
834
- let r = cqn.SELECT.recurse
835
- if (r) r.ref[0] = uplinkName
836
- }
844
+ if (!uplinkName || !target.elements[uplinkName])
845
+ throw new cds.error(
846
+ 500,
847
+ 'Cannot resolve `ParentNavigationProperty` in `@Aggregation.RecursiveHierarchy` annotation'
848
+ )
849
+ _recurse.ref[0] = uplinkName
837
850
  }
838
851
 
839
852
  // REVISIT: better
@@ -167,8 +167,8 @@ function _xpr(expr, target, kind, isLambda, navPrefix = []) {
167
167
  const operator = isOrIsNotValue[1] /* 'is not' */ ? 'ne' : 'eq'
168
168
  res.push(...[operator, _format({ val: isOrIsNotValue[2] })])
169
169
  } else if (cur === 'between') {
170
- // ref gt low.val and ref lt high.val
171
- const between = [expr[i - 1], 'gt', expr[i + 1], 'and', expr[i - 1], 'lt', expr[i + 3]]
170
+ // between is inclusive, so we need to use ge and le
171
+ const between = [expr[i - 1], 'ge', expr[i + 1], 'and', expr[i - 1], 'le', expr[i + 3]]
172
172
  // cleanup previous ref
173
173
  res.pop()
174
174
  res.push(`(${_xpr(between, target, kind, isLambda, navPrefix)})`)