@sap/cds 8.8.0 → 8.8.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 +17 -0
- package/lib/compile/parse.js +1 -1
- package/lib/ql/cds.ql-predicates.js +2 -1
- package/lib/req/validate.js +1 -1
- package/libx/_runtime/common/generic/auth/restrict.js +1 -1
- package/libx/_runtime/common/generic/auth/utils.js +2 -1
- package/libx/odata/middleware/create.js +4 -0
- package/libx/odata/parse/afterburner.js +1 -0
- package/libx/odata/parse/multipartToJson.js +17 -10
- package/libx/odata/utils/metadata.js +28 -5
- package/libx/odata/utils/normalizeTimeData.js +11 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
5
5
|
- This project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## Version 8.8.1 - 2025-03-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Requests violating `cds.odata.max_batch_header_size` are terminated with `431 Request Header Fields Too Large` instead of `400 - Bad Request`
|
|
12
|
+
- `cds.parse.<x>` writing directly to `stdout`
|
|
13
|
+
- Instance-based authorization for programmatic action invocations
|
|
14
|
+
- Implicit function parameter calls with Array or Object values
|
|
15
|
+
- OData: Throw an error by `POST` with payload that contains array of entity representation
|
|
16
|
+
- `cds.validate` filters out annotations according to OData V4 spec
|
|
17
|
+
- Crash for requests with invalid time data format
|
|
18
|
+
- Add missing 'and' between conditions in object notation of QL
|
|
19
|
+
- Multiline payloads in `$batch` sub requests
|
|
20
|
+
- Instance-based authorization for modeling like `$user.<property> is null`
|
|
21
|
+
- Respect `cds.odata.contextAbsoluteUrl` in new OData adapter
|
|
22
|
+
- `cds.odata.context_with_columns` also applies to singletons
|
|
23
|
+
|
|
7
24
|
## Version 8.8.0 - 2025-03-03
|
|
8
25
|
|
|
9
26
|
### Added
|
package/lib/compile/parse.js
CHANGED
|
@@ -29,7 +29,7 @@ exports.path = function path (x,...etc) {
|
|
|
29
29
|
const [,head,tail] = /^([\w._]+)(?::(\w+))?$/.exec(x)||[]
|
|
30
30
|
if (tail) return {ref:[head,...tail.split('.')]}
|
|
31
31
|
if (head) return {ref:[head]}
|
|
32
|
-
const {SELECT} = cdsc.parse.cql('SELECT from '+x)
|
|
32
|
+
const {SELECT} = cdsc.parse.cql('SELECT from '+x, undefined, { messages: [] })
|
|
33
33
|
return SELECT.from
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -48,6 +48,8 @@ function _qbe (o, xpr=[]) {
|
|
|
48
48
|
let count = 0
|
|
49
49
|
for (let k in o) { const x = o[k]
|
|
50
50
|
|
|
51
|
+
if (k !== 'and' && k !== 'or' && count++) xpr.push('and') //> add 'and' between conditions
|
|
52
|
+
|
|
51
53
|
if (k.startsWith('not ')) { xpr.push('not'); k = k.slice(4) }
|
|
52
54
|
switch (k) { // handle special cases like {and:{...}} or {or:{...}}
|
|
53
55
|
case 'between':
|
|
@@ -83,7 +85,6 @@ function _qbe (o, xpr=[]) {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
const a = cds.parse.ref(k) //> turn key into a ref for the left side of the expression
|
|
86
|
-
if (count++) xpr.push('and') //> add 'and' between conditions
|
|
87
88
|
if (!x || typeof x !== 'object') xpr.push (a,'=',{val:x})
|
|
88
89
|
else if (is_array(x)) xpr.push (a,'in',{list:x.map(_val)})
|
|
89
90
|
else if (x.SELECT || x.list) xpr.push (a,'in',x)
|
package/lib/req/validate.js
CHANGED
|
@@ -48,7 +48,7 @@ class Validation {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
unknown(e,d,input) {
|
|
51
|
-
if (e.
|
|
51
|
+
if (e.match(/@.*\./)) return delete input[e] //> skip all annotations, like @odata.Type (according to OData spec annotations contain an "@" and a ".")
|
|
52
52
|
d['@open'] || cds.error (`Property "${e}" does not exist in ${d.name}`, {status:400})
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -296,7 +296,7 @@ const isBoundToCollection = action =>
|
|
|
296
296
|
const restrictBoundActionFunctions = async (req, resolvedApplicables, definition, srv) => {
|
|
297
297
|
if (req.target?.actions?.[req.event] && !isBoundToCollection(req.target.actions[req.event])) {
|
|
298
298
|
// Clone to avoid target modification, which would cause a different query
|
|
299
|
-
const query = cds.ql.clone(req.query)
|
|
299
|
+
const query = req.query ? cds.ql.clone(req.query) : SELECT.one.from(req.subject)
|
|
300
300
|
_addRestrictionsToRead({ query: query, target: req.target }, cds.model, resolvedApplicables)
|
|
301
301
|
const result = await (cds.env.features.compat_restrict_bound ? srv : cds.tx(req)).run(query)
|
|
302
302
|
if (!result || result.length === 0) {
|
|
@@ -126,7 +126,8 @@ const resolveUserAttrs = (where, req) => {
|
|
|
126
126
|
} else if (where[i + 2] && operators.has(where[i + 1])) {
|
|
127
127
|
where.splice(i, 3, { val: '1' }, '=', { val: '2' })
|
|
128
128
|
} else if (where[i + 1] === 'is') {
|
|
129
|
-
|
|
129
|
+
val = null
|
|
130
|
+
break
|
|
130
131
|
}
|
|
131
132
|
} else val = val?.[attr]
|
|
132
133
|
}
|
|
@@ -33,6 +33,10 @@ module.exports = (adapter, isUpsert) => {
|
|
|
33
33
|
|
|
34
34
|
// payload & params
|
|
35
35
|
const data = req.body
|
|
36
|
+
if (Array.isArray(data)) {
|
|
37
|
+
const msg = 'Only single entity representations are allowed'
|
|
38
|
+
throw Object.assign(new Error(msg), { statusCode: 400 })
|
|
39
|
+
}
|
|
36
40
|
normalizeTimeData(data, model, target)
|
|
37
41
|
const { keys, params } = getKeysAndParamsFromPath(from, { model })
|
|
38
42
|
// add keys from url into payload (overwriting if already present)
|
|
@@ -415,6 +415,7 @@ function _processSegments(from, model, namespace, cqn, protocol) {
|
|
|
415
415
|
if (current.params && param in current.params) acc[param] = from._params[cur]
|
|
416
416
|
return acc
|
|
417
417
|
}, {})
|
|
418
|
+
ref[i].args = _getDataFromParams(ref[i].args, current) //resolve parameter if Object or Array
|
|
418
419
|
_resolveImplicitFunctionParameters(ref[i].args)
|
|
419
420
|
}
|
|
420
421
|
}
|
|
@@ -116,28 +116,35 @@ const _parseStream = async function* (body, boundary) {
|
|
|
116
116
|
.toString()
|
|
117
117
|
.replace(/^--(.*)$/gm, (_, g) => `HEAD /${g} HTTP/1.1${g.slice(-2) === '--' ? CRLF : ''}`)
|
|
118
118
|
// correct content-length for non-HEAD requests is inserted below
|
|
119
|
-
.replace(/content-length: \d+\r\n/gim, '')
|
|
119
|
+
.replace(/content-length: \d+\r\n/gim, '') // if content-length is given it should be taken
|
|
120
120
|
.replace(/ \$/g, ' /$')
|
|
121
121
|
|
|
122
122
|
// HACKS!!!
|
|
123
123
|
// ensure URLs start with slashes
|
|
124
124
|
changed = changed.replaceAll(/\r\n(GET|PUT|POST|PATCH|DELETE) (\w)/g, `\r\n$1 /$2`)
|
|
125
125
|
// add content-length headers
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
126
|
+
changed = changed
|
|
127
|
+
.split(CRLF + CRLF)
|
|
128
|
+
.map((line, i, arr) => {
|
|
129
|
+
if (/^(PUT|POST|PATCH) /.test(line) && !/content-length/i.test(line)) {
|
|
130
|
+
const body = arr[i + 1].split('\r\nHEAD')[0]
|
|
131
|
+
if (body) return `${line}${CRLF}content-length: ${Buffer.byteLength(body)}`
|
|
132
|
+
}
|
|
133
|
+
return line
|
|
134
|
+
})
|
|
135
|
+
.join(CRLF + CRLF)
|
|
134
136
|
// remove strange "Group ID" appendix
|
|
135
137
|
changed = changed.split(`${CRLF}Group ID`)[0] + CRLF
|
|
136
138
|
|
|
137
139
|
let ret = parser.execute(Buffer.from(changed))
|
|
138
140
|
|
|
139
141
|
if (typeof ret !== 'number') {
|
|
140
|
-
if (ret.
|
|
142
|
+
if (ret.code === 'HPE_HEADER_OVERFLOW') {
|
|
143
|
+
// same error conversion as node http server
|
|
144
|
+
ret.status = 431
|
|
145
|
+
ret.code = '431'
|
|
146
|
+
ret.message = 'Request Header Fields Too Large'
|
|
147
|
+
} else if (ret.message === 'Parse Error') {
|
|
141
148
|
ret.statusCode = 400
|
|
142
149
|
ret.message = `Error while parsing batch body at position ${ret.bytesParsed}: ${ret.reason}`
|
|
143
150
|
}
|
|
@@ -1,7 +1,28 @@
|
|
|
1
|
-
const { cds2edm } = require('./index')
|
|
2
1
|
const cds = require('../../../lib')
|
|
2
|
+
const LOG = cds.log('odata')
|
|
3
|
+
const { appURL } = require('../../_runtime/common/utils/vcap')
|
|
4
|
+
const { cds2edm } = require('./index')
|
|
3
5
|
const { where2obj } = require('../../_runtime/common/utils/cqn')
|
|
4
6
|
|
|
7
|
+
const _getContextAbsoluteUrl = _req => {
|
|
8
|
+
const { contextAbsoluteUrl } = cds.env.odata
|
|
9
|
+
if (!contextAbsoluteUrl) return ''
|
|
10
|
+
|
|
11
|
+
if (typeof contextAbsoluteUrl === 'string') {
|
|
12
|
+
try {
|
|
13
|
+
const userDefinedURL = new URL(contextAbsoluteUrl, contextAbsoluteUrl).toString()
|
|
14
|
+
return (!userDefinedURL.endsWith('/') && `${userDefinedURL}/`) || userDefinedURL
|
|
15
|
+
} catch (e) {
|
|
16
|
+
e.message = `cds.odata.contextAbsoluteUrl could not be parsed as URL: ${contextAbsoluteUrl}`
|
|
17
|
+
LOG._warn && LOG.warn(e)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const reqURL = _req && _req.get && _req.get('host') && `${_req.protocol || 'https'}://${_req.get('host')}`
|
|
21
|
+
const baseAppURL = appURL || reqURL || ''
|
|
22
|
+
const serviceUrl = `${(_req && _req.baseUrl) || ''}/`
|
|
23
|
+
return baseAppURL && new URL(serviceUrl, baseAppURL).toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
const _isNavToDraftAdmin = path => path.length > 1 && path[path.length - 1] === 'DraftAdministrativeData'
|
|
6
27
|
|
|
7
28
|
const _lastValidRef = ref => {
|
|
@@ -14,7 +35,9 @@ const _lastValidRef = ref => {
|
|
|
14
35
|
const _toBinaryKeyValue = value => `binary'${value.toString('base64')}'`
|
|
15
36
|
|
|
16
37
|
const _odataContext = (query, options) => {
|
|
17
|
-
|
|
38
|
+
const { contextAbsoluteUrl, context_with_columns } = cds.env.odata
|
|
39
|
+
|
|
40
|
+
let path = _getContextAbsoluteUrl(query._req) + '$metadata'
|
|
18
41
|
if (query._target.kind === 'service') return path
|
|
19
42
|
|
|
20
43
|
const {
|
|
@@ -45,14 +68,14 @@ const _odataContext = (query, options) => {
|
|
|
45
68
|
if (serviceName && !isType) edmName = edmName.replace(serviceName + '.', '').replace(/\./g, '_')
|
|
46
69
|
|
|
47
70
|
// prepend "../" parent segments for relative path
|
|
48
|
-
if (ref.length > 1) path = '../'.repeat(ref.length - 1) + path
|
|
71
|
+
if (!contextAbsoluteUrl && ref.length > 1) path = '../'.repeat(ref.length - 1) + path
|
|
49
72
|
|
|
50
73
|
path += edmName
|
|
51
74
|
|
|
52
75
|
const lastRef = ref.at(-1)
|
|
53
76
|
|
|
54
77
|
if (propertyAccess || isNavToDraftAdmin) {
|
|
55
|
-
if (propertyAccess) path = '../' + path
|
|
78
|
+
if (!contextAbsoluteUrl && propertyAccess) path = '../' + path
|
|
56
79
|
|
|
57
80
|
const keyValuePairs = []
|
|
58
81
|
|
|
@@ -92,7 +115,7 @@ const _odataContext = (query, options) => {
|
|
|
92
115
|
if (propertyAccess) path += '/' + propertyAccess
|
|
93
116
|
}
|
|
94
117
|
|
|
95
|
-
if (
|
|
118
|
+
if (context_with_columns && query.SELECT && !propertyAccess) {
|
|
96
119
|
const _calculateStringFromColumn = column => {
|
|
97
120
|
if (column === '*') return
|
|
98
121
|
|
|
@@ -7,14 +7,17 @@ const _processorFn = elementInfo => {
|
|
|
7
7
|
for (const category of plain.categories) {
|
|
8
8
|
const { row, key } = elementInfo
|
|
9
9
|
if (!(row[key] == null) && row[key] !== '$now') {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const dt = typeof row[key] === 'string' && new Date(row[key])
|
|
11
|
+
if (!isNaN(dt)) {
|
|
12
|
+
switch (category) {
|
|
13
|
+
case 'cds.DateTime':
|
|
14
|
+
row[key] = new Date(row[key]).toISOString().replace(/\.\d\d\d/, '')
|
|
15
|
+
break
|
|
16
|
+
case 'cds.Timestamp':
|
|
17
|
+
row[key] = normalizeTimestamp(row[key])
|
|
18
|
+
break
|
|
19
|
+
// no default
|
|
20
|
+
}
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
23
|
}
|