@sap/cds 8.8.0 → 8.8.2

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,30 @@
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.2 - 2025-03-13
8
+
9
+ ### Fixed
10
+
11
+ - Consuming REST actions returning anonymous structures
12
+ - `i18n.labels/messages` were occasionally missing
13
+
14
+ ## Version 8.8.1 - 2025-03-07
15
+
16
+ ### Fixed
17
+
18
+ - Requests violating `cds.odata.max_batch_header_size` are terminated with `431 Request Header Fields Too Large` instead of `400 - Bad Request`
19
+ - `cds.parse.<x>` writing directly to `stdout`
20
+ - Instance-based authorization for programmatic action invocations
21
+ - Implicit function parameter calls with Array or Object values
22
+ - OData: Throw an error by `POST` with payload that contains array of entity representation
23
+ - `cds.validate` filters out annotations according to OData V4 spec
24
+ - Crash for requests with invalid time data format
25
+ - Add missing 'and' between conditions in object notation of QL
26
+ - Multiline payloads in `$batch` sub requests
27
+ - Instance-based authorization for modeling like `$user.<property> is null`
28
+ - Respect `cds.odata.contextAbsoluteUrl` in new OData adapter
29
+ - `cds.odata.context_with_columns` also applies to singletons
30
+
7
31
  ## Version 8.8.0 - 2025-03-03
8
32
 
9
33
  ### 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
 
@@ -1,5 +1,7 @@
1
1
  const cds = require('..'), {i18n} = cds.env
2
2
  const I18nFiles = require ('./files')
3
+ const DEFAULTS = i18n.default_language
4
+ const FALLBACK = ''
3
5
 
4
6
 
5
7
  class I18nBundle {
@@ -7,14 +9,14 @@ class I18nBundle {
7
9
  constructor (options={}) {
8
10
  this.files = new I18nFiles (options)
9
11
  this.file = this.files.basename
12
+ this.fallback = this.#translations[FALLBACK] = Object.assign ({}, ...this.files.content4(FALLBACK))
13
+ this.defaults = this.#translations[DEFAULTS] = Object.assign (
14
+ i18n.fatjson ? {...this.fallback} : {__proto__:this.fallback}, ...this.files.content4(DEFAULTS)
15
+ )
10
16
  }
11
17
 
12
18
  #translations = {}
13
19
 
14
- /** The default texts to use as fallbacks if a requested translation is not found. */
15
- get defaults() { return super.defaults = this.texts4 (i18n.default_language, this.fallback) }
16
- get fallback() { return super.fallback = this.texts4 ('',false) }
17
-
18
20
  /** Synonym for {@link at `this.at`} */
19
21
  get for() { return this.at }
20
22
 
@@ -23,6 +25,7 @@ class I18nBundle {
23
25
  * Looks up the entry for the given key and locale.
24
26
  * - if `locale` is omitted, the current locale is used.
25
27
  * - if `args` are provided, fills in placeholders with them.
28
+ * @example cds.i18n.labels.at ('CreatedAt','de')
26
29
  * @returns {string|undefined}
27
30
  */
28
31
  at (key, locale, args) {
@@ -62,32 +65,29 @@ class I18nBundle {
62
65
 
63
66
  /**
64
67
  * Returns translated texts for a specific locale.
68
+ * @example cds.i18n.labels.texts4 ('de')
65
69
  */
66
- texts4 (locale='', _defaults) {
70
+ texts4 (locale='') {
67
71
  const $ = this.#translations; if (locale in $) return $[locale]
68
72
  const suffix = locale.replace(/-/g,'_'); if (suffix in $) return $[locale] = $[suffix]
69
- // nothing cached yet, load content and create translations ...
70
- let all = this.files.content4 (locale, suffix) // load content from all folders
71
- if (all.length === 0 && suffix.includes('_')) { // nothing found, try w/o region ...
72
- const lang = suffix.match(/(\w+)_/)[1] // i.e. en_UK => en
73
- if (lang in $) return $[locale] = $[lang] // already cached -> leave method
74
- else all = this.files.content4 (lang, lang) // load content for language only
73
+ const all = this.files.content4 (locale, suffix) // load content from all folders
74
+ if (!all.length) { // nothing found, try w/o region, or return defaults
75
+ const _ = suffix.indexOf('_')
76
+ return $[locale] = $[suffix] = _ < 0 ? this.defaults : this.texts4 (suffix.slice(0,_))
75
77
  }
76
- const defaults = (_defaults ?? this.defaults) || Object.prototype
77
- const texts = i18n.fatjson ? Object.assign ({},defaults) : Object.create (defaults)
78
- return $[locale] = $[suffix] = Object.assign (texts, ...all)
78
+ const texts = i18n.fatjson ? {...this.defaults} : {__proto__:this.defaults}
79
+ return $[locale] = $[suffix] = Object.assign (texts, ...all )
79
80
  }
80
81
 
81
82
 
82
83
  /**
83
84
  * Returns all translations for an array of locales or all locales.
84
- * @example { de, fr } = cds.i18n.labels.translations('de','fr')
85
+ * @example { de, fr } = cds.i18n.labels.translations4 ('de','fr')
85
86
  * @param { 'all' | string[] } [locale]
86
87
  * @returns {{ [locale:string]: Record<string,string> }}
87
88
  */
88
89
  translations4 (...locales) {
89
- let first = locales[0]
90
- if (first == null) locales = cds.env.i18n.languages
90
+ let first = locales[0] || cds.env.i18n.languages
91
91
  if (first == 'all') locales = this.files.locales()
92
92
  else if (Array.isArray(first)) locales = first
93
93
  return locales.reduce ((all,l) => (all[l] = this.texts4(l),all), {})
package/lib/i18n/files.js CHANGED
@@ -36,7 +36,7 @@ class I18nFiles {
36
36
  const leafs = model?.$sources.map(path.dirname) ?? roots, visited = {}
37
37
  ;[...new Set(leafs)].reverse() .forEach (function _visit (dir) {
38
38
  if (dir in visited) return; else visited[dir] = true
39
- LOG.debug ('searching for i18n files in the neighborhood of', dir)
39
+ LOG.debug ('fetching', basename, 'bundles in', dir, relative_folders)
40
40
  // is there an i18n folder in the currently visited directory?
41
41
  for (const each of relative_folders) {
42
42
  const f = path.join(dir,each), _exists = _folders[f] ??= exists(f)
@@ -60,6 +60,8 @@ class I18nFiles {
60
60
  const matches = (_entries[f] ??= fs.readdirSync(f)) .filter (f => f.match(base))
61
61
  if (matches.length) return files[f] = matches
62
62
  }
63
+
64
+ LOG.debug ('found', basename, 'bundles in these folders', Object.keys(files))
63
65
  }
64
66
 
65
67
 
@@ -68,7 +70,7 @@ class I18nFiles {
68
70
  * @returns {entries[]} An array of entries, one for each file found.
69
71
  */
70
72
  content4 (locale, suffix = locale?.replace(/-/g,'_')) {
71
- const content = [], cached = I18nFiles.content ??= {}
73
+ const content = [], cached = I18nFiles[this.basename] ??= {}
72
74
  const _suffix = suffix ? '_'+ suffix : ''
73
75
  for (let dir in this) {
74
76
  const all = cached[dir] ??= this.load('.json',dir) || this.load('.csv',dir) || false
@@ -87,7 +89,7 @@ class I18nFiles {
87
89
  case '.json': return _load_json(file)
88
90
  case '.csv': return _load_csv(file)
89
91
  }}
90
- finally { LOG.debug ('read:', file) }
92
+ finally { LOG.debug ('loading:', file) }
91
93
  }
92
94
 
93
95
 
package/lib/i18n/index.js CHANGED
@@ -27,7 +27,7 @@ class I18nFacade {
27
27
  * The default bundle for UI labels and texts.
28
28
  */
29
29
  get labels() {
30
- return super.labels = this.bundle4 (cds.context?.model || cds.model)
30
+ return super.labels = this.bundle4 (cds.model)
31
31
  }
32
32
 
33
33
  /**
@@ -62,7 +62,7 @@ function localize (input,...etc) {
62
62
  }
63
63
 
64
64
  exports.edmx4 = service => new class extends Localize { get input(){
65
- const model = this.model || cds.context?.model || cds.model
65
+ const model = this.model || cds.model
66
66
  return super.input = cds.compile.to.edmx (model,{service})
67
67
  }}
68
68
 
@@ -124,7 +124,7 @@ exports.json = json => {
124
124
  }
125
125
 
126
126
  /** @deprecated */ exports.bundle4 = function (model, locale) {
127
- if (typeof model === 'string') [ model, locale ] = [ cds.context?.model || cds.model, model ]
127
+ if (typeof model === 'string') [ model, locale ] = [ cds.model, model ]
128
128
  const b = i18n.bundle4 (model)
129
129
  return b.texts4 (locale)
130
130
  }
@@ -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
  }
@@ -93,7 +93,7 @@ const _handleUnboundActionFunction = (srv, def, req, event) => {
93
93
  if (def.kind === 'action') {
94
94
  // REVISIT: only for "rest" unbound actions/functions, we enforce axios to return a buffer
95
95
  // required by cds-mt
96
- const isBinary = srv.kind === 'rest' && def?.returns?.type.match(/binary/i)
96
+ const isBinary = srv.kind === 'rest' && def?.returns?.type?.match(/binary/i)
97
97
  const { headers, data } = req
98
98
 
99
99
  return srv.send({ method: 'POST', path: `/${event}`, headers, data, _binary: isBinary })
@@ -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.2",
4
4
  "description": "SAP Cloud Application Programming Model - CDS for Node.js",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [