@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 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
@@ -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)
@@ -48,7 +48,7 @@ class Validation {
48
48
  }
49
49
 
50
50
  unknown(e,d,input) {
51
- if (e.startsWith('@')) return delete input[e] //> skip all annotations, like @odata.Type
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) ?? SELECT.from(req.subject)
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
- where.splice(i, where[i + 2] === 'not' ? 4 : 3, { val: '1' }, '=', { val: '2' })
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
- let lastIndex = 0
127
- changed = changed.replaceAll(/(\r\n){2,}(.+)[\r\n]+HEAD/g, (match, _, p1, index, original) => {
128
- const part = original.substring(lastIndex, index)
129
- lastIndex = index
130
- return part.match(/(PUT|POST|PATCH)\s\//g) && !part.match(/content-length/i) && !p1.startsWith('HEAD /')
131
- ? `${CRLF}content-length: ${Buffer.byteLength(p1)}${match}`
132
- : match
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.message === 'Parse Error') {
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
- let path = '$metadata'
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 (cds.env.odata.context_with_columns && query.SELECT && !isSingleton && !propertyAccess) {
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
- switch (category) {
11
- case 'cds.DateTime':
12
- row[key] = new Date(row[key]).toISOString().replace(/\.\d\d\d/, '')
13
- break
14
- case 'cds.Timestamp':
15
- row[key] = normalizeTimestamp(row[key])
16
- break
17
- // no default
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sap/cds",
3
- "version": "8.8.0",
3
+ "version": "8.8.1",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [