@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.
- package/CHANGELOG.md +36 -0
- package/bin/serve.js +11 -9
- package/lib/compile/for/lean_drafts.js +29 -7
- package/lib/dbs/cds-deploy.js +5 -3
- package/lib/env/cds-requires.js +1 -1
- package/lib/env/defaults.js +0 -11
- package/lib/env/schemas/cds-rc.js +214 -6
- package/lib/i18n/locale.js +2 -1
- package/lib/req/request.js +1 -1
- package/lib/req/validate.js +1 -2
- package/lib/srv/cds-connect.js +1 -1
- package/lib/srv/middlewares/auth/xssec.js +1 -1
- package/lib/utils/inflect.js +2 -2
- package/lib/utils/tar.js +60 -23
- package/libx/_runtime/common/generic/crud.js +1 -3
- package/libx/_runtime/common/generic/input.js +2 -2
- package/libx/_runtime/common/generic/temporal.js +0 -6
- package/libx/_runtime/fiori/lean-draft.js +487 -141
- package/libx/_runtime/remote/utils/client.js +1 -0
- package/libx/odata/ODataAdapter.js +47 -43
- package/libx/odata/middleware/batch.js +0 -1
- package/libx/odata/middleware/error.js +7 -0
- package/libx/odata/middleware/operation.js +15 -21
- package/libx/odata/parse/afterburner.js +23 -10
- package/libx/odata/parse/cqn2odata.js +2 -2
- package/libx/odata/parse/grammar.peggy +182 -133
- package/libx/odata/parse/parser.js +1 -1
- package/libx/odata/utils/index.js +0 -35
- package/libx/odata/utils/metadata.js +34 -1
- package/libx/odata/utils/odataBind.js +2 -1
- package/libx/odata/utils/result.js +22 -20
- package/libx/queue/index.js +6 -2
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
-
.
|
|
43
|
-
.
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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)} "${
|
|
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)} "${
|
|
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} ${
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
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
|
-
//
|
|
171
|
-
const between = [expr[i - 1], '
|
|
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)})`)
|