@sap/cds 8.7.2 → 8.8.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 +33 -0
- package/_i18n/i18n.properties +3 -0
- package/_i18n/i18n_cs.properties +6 -6
- package/_i18n/i18n_de.properties +3 -0
- package/_i18n/i18n_en.properties +3 -0
- package/_i18n/i18n_es.properties +3 -0
- package/_i18n/i18n_fr.properties +3 -0
- package/_i18n/i18n_it.properties +3 -0
- package/_i18n/i18n_ja.properties +3 -0
- package/_i18n/i18n_pl.properties +7 -4
- package/_i18n/i18n_pt.properties +3 -0
- package/_i18n/i18n_ru.properties +3 -0
- package/app/index.js +2 -30
- package/lib/env/cds-env.js +1 -1
- package/lib/env/cds-requires.js +16 -9
- package/lib/env/schemas/cds-package.js +1 -1
- package/lib/env/schemas/cds-rc.js +17 -4
- package/lib/index.js +1 -1
- package/lib/ql/SELECT.js +6 -1
- package/lib/req/request.js +5 -2
- package/lib/req/validate.js +3 -1
- package/lib/srv/bindings.js +31 -20
- package/lib/srv/cds-connect.js +1 -1
- package/lib/srv/middlewares/auth/mocked-users.js +1 -0
- package/lib/srv/protocols/okra.js +5 -7
- package/lib/srv/srv-dispatch.js +0 -5
- package/lib/test/cds-test.js +34 -6
- package/libx/_runtime/cds-services/adapter/odata-v4/ODataRequest.js +1 -0
- package/libx/_runtime/common/generic/auth/service.js +2 -2
- package/libx/_runtime/common/generic/input.js +1 -1
- package/libx/_runtime/common/utils/binary.js +1 -35
- package/libx/_runtime/common/utils/rewriteAsterisks.js +5 -8
- package/libx/_runtime/fiori/lean-draft.js +2 -4
- package/libx/common/utils/path.js +1 -5
- package/libx/common/utils/streaming.js +76 -0
- package/libx/odata/middleware/create.js +1 -1
- package/libx/odata/middleware/delete.js +1 -1
- package/libx/odata/middleware/operation.js +48 -4
- package/libx/odata/middleware/read.js +1 -1
- package/libx/odata/middleware/stream.js +29 -101
- package/libx/odata/middleware/update.js +1 -1
- package/libx/odata/parse/afterburner.js +20 -1
- package/libx/odata/parse/grammar.peggy +108 -26
- package/libx/odata/parse/parser.js +1 -1
- package/libx/rest/RestAdapter.js +2 -16
- package/libx/rest/middleware/operation.js +38 -18
- package/libx/rest/middleware/parse.js +5 -25
- package/libx/rest/post-processing.js +33 -0
- package/libx/rest/pre-processing.js +38 -0
- package/package.json +1 -1
- package/libx/common/utils/index.js +0 -5
package/lib/test/cds-test.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
module.exports = require_local('@cap-js/cds-test')
|
|
2
|
+
|
|
3
|
+
// TODO remove the code below with cds 9
|
|
4
|
+
if (!module.exports) {
|
|
5
|
+
|
|
6
|
+
const { RED, RESET } = require('../utils/colors')
|
|
7
|
+
// eslint-disable-next-line no-console
|
|
8
|
+
console.error(RED+'Test support will move to package @cap-js/cds-test in @sap/cds v9.\nGet ready and add a dependency to it:\n npm add -D @cap-js/cds-test\n'+RESET)
|
|
9
|
+
|
|
1
10
|
/**
|
|
2
11
|
* Instances of this class are constructed and returned by cds.test().
|
|
3
12
|
*/
|
|
@@ -149,15 +158,23 @@ class Test extends require('./axios') {
|
|
|
149
158
|
* Lazily loads and returns an instance of chai
|
|
150
159
|
*/
|
|
151
160
|
get chai() {
|
|
152
|
-
|
|
161
|
+
const chai = require('chai')
|
|
153
162
|
chai.use (require('chai-subset'))
|
|
154
|
-
|
|
163
|
+
const chaip = require('chai-as-promised')
|
|
164
|
+
chai.use (chaip.default/*v8 on ESM*/ ?? chaip/*v7*/)
|
|
155
165
|
return chai
|
|
156
166
|
function require (mod) { try { return module.require(mod) } catch(e) {
|
|
157
|
-
if (e.code === 'MODULE_NOT_FOUND')
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
if (e.code === 'MODULE_NOT_FOUND')
|
|
168
|
+
throw new Error (`Failed to load required package '${mod}'. Please add it thru:`
|
|
169
|
+
+ `\n npm add -D chai chai-as-promised chai-subset`, {cause: e})
|
|
170
|
+
else if (e.name === 'SyntaxError') // Jest stumbling over ESM
|
|
171
|
+
throw new Error (`Jest failed to load ESM package '${mod}'.`
|
|
172
|
+
+ `\nDowngrade '${mod}' to the major version before, or use a different test runner like 'node --test'.\n`, {cause: e})
|
|
173
|
+
else if (e.code === 'ERR_REQUIRE_ESM') // node --test on older Node versions
|
|
174
|
+
throw new Error (`Failed to load ESM package '${mod}'. This only is supported on Node.js >= 23.`
|
|
175
|
+
+ `\nUpgrade your Node.js installation or downgrade '${mod}' to the major version before.\n`, {cause: e})
|
|
176
|
+
else throw e
|
|
177
|
+
}}
|
|
161
178
|
}
|
|
162
179
|
set expect(x) { super.expect = x }
|
|
163
180
|
get expect() { return _expect || this.chai.expect }
|
|
@@ -235,3 +252,14 @@ let _expect = undefined
|
|
|
235
252
|
}))
|
|
236
253
|
}
|
|
237
254
|
})()
|
|
255
|
+
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function require_local(id) {
|
|
259
|
+
const cds = require('..')
|
|
260
|
+
try {
|
|
261
|
+
return require(require.resolve(id, { paths: [cds.root, __dirname] }))
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (e.code !== 'MODULE_NOT_FOUND') throw e
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -14,8 +14,8 @@ function handler(req) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const requires = _getRequiresAsArray(this.definition)
|
|
17
|
-
|
|
18
|
-
if (!requires || requires.some(role => req.user.is(role))) return
|
|
17
|
+
// internal-user is considered as a concept to protect the endpoints, local app service calls are always allowed
|
|
18
|
+
if (!requires || requires.some(role => req.user.is(role)) || requires.includes('internal-user')) return
|
|
19
19
|
reject(req, getRejectReason(req, '@requires', this.definition))
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -260,7 +260,7 @@ async function commonGenericInput(req) {
|
|
|
260
260
|
const bound = req.target.actions?.[req.event] || req.target.actions?.[req._.event]
|
|
261
261
|
if (bound) assertOptions.path = [bound['@cds.odata.bindingparameter.name'] || 'in']
|
|
262
262
|
|
|
263
|
-
if (req.protocol) assertOptions.rejectIgnore = true
|
|
263
|
+
if (req.protocol && !_is_activate) assertOptions.rejectIgnore = true
|
|
264
264
|
|
|
265
265
|
const errs = cds.validate(req.data, req.target, assertOptions)
|
|
266
266
|
if (errs) {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const getTemplate = require('./template')
|
|
2
|
-
|
|
3
1
|
// convert the standard base64 encoding to the URL-safe variant
|
|
4
2
|
const toBase64url = value => {
|
|
5
3
|
const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value, 'base64')
|
|
@@ -29,40 +27,8 @@ const isInvalidBase64string = value => {
|
|
|
29
27
|
return base64value.length > normalized.length
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
const _picker = element => {
|
|
33
|
-
const categories = {}
|
|
34
|
-
if (Array.isArray(element)) return
|
|
35
|
-
if (element.type !== 'cds.Binary' && element.type !== 'cds.LargeBinary') return
|
|
36
|
-
categories['convert_binary'] = true
|
|
37
|
-
return categories
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const _processorFn =
|
|
41
|
-
toBuffer =>
|
|
42
|
-
({ row, key, plain: categories }) => {
|
|
43
|
-
if (categories['convert_binary'] && row[key] != null) {
|
|
44
|
-
if (toBuffer && typeof row[key] === 'string') row[key] = Buffer.from(row[key], 'base64')
|
|
45
|
-
if (!toBuffer && Buffer.isBuffer(row[key])) row[key] = row[key].toString('base64')
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const _processBinaryData = (data, srv, definition, toBuffer) => {
|
|
50
|
-
const template = getTemplate('rest-payload', srv, definition, { pick: _picker })
|
|
51
|
-
template.process(data, _processorFn(toBuffer))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const base64ToBuffer = (data, srv, definition) => {
|
|
55
|
-
_processBinaryData(data, srv, definition, true)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const bufferToBase64 = (data, srv, definition) => {
|
|
59
|
-
_processBinaryData(data, srv, definition, false)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
30
|
module.exports = {
|
|
63
31
|
normalizeBase64string,
|
|
64
32
|
isInvalidBase64string,
|
|
65
|
-
toBase64url
|
|
66
|
-
base64ToBuffer,
|
|
67
|
-
bufferToBase64
|
|
33
|
+
toBase64url
|
|
68
34
|
}
|
|
@@ -24,9 +24,11 @@ const _expandColumn = (column, target, _4db) => {
|
|
|
24
24
|
|
|
25
25
|
const _resolveTarget = (ref, target) => {
|
|
26
26
|
if (ref.length > 1) {
|
|
27
|
-
|
|
27
|
+
const element = target.elements[ref[0]]
|
|
28
|
+
if (element) {
|
|
29
|
+
if (element.isAssociation) throw cds.error(`Navigation "${ref.join('/')}" in expand is not supported`)
|
|
28
30
|
// structured
|
|
29
|
-
return _resolveTarget(ref.slice(1),
|
|
31
|
+
return _resolveTarget(ref.slice(1), element)
|
|
30
32
|
} else {
|
|
31
33
|
// in case there is an alias, try with the next entry
|
|
32
34
|
return _resolveTarget(ref.slice(1), target)
|
|
@@ -107,12 +109,7 @@ const rewriteAsterisks = (query, model, options) => {
|
|
|
107
109
|
// REVISIT these are two nasty hacks for UNION and JOIN,
|
|
108
110
|
// which should be implemented generically.
|
|
109
111
|
// Please, do not continue to develop here if possible.
|
|
110
|
-
if (
|
|
111
|
-
query.SELECT.from.SET &&
|
|
112
|
-
query.SELECT.from.SET.args[0] &&
|
|
113
|
-
query.SELECT.from.SET.args[0].SELECT &&
|
|
114
|
-
query.SELECT.from.SET.args[0].SELECT.columns
|
|
115
|
-
) {
|
|
112
|
+
if (query.SELECT.from.SET?.args[0]?.SELECT?.columns) {
|
|
116
113
|
// > best-effort derive column list from first join element if given
|
|
117
114
|
query.SELECT.columns = query.SELECT.from.SET.args[0].SELECT.columns.map(c => ({
|
|
118
115
|
ref: [c.as || c.ref[c.ref.length - 1]]
|
|
@@ -384,11 +384,9 @@ cds.ApplicationService.prototype.handle = async function (req) {
|
|
|
384
384
|
(query.DELETE && 'DELETE') ||
|
|
385
385
|
req.event
|
|
386
386
|
_req.params = req.params
|
|
387
|
-
_req._ = Object.assign({}, req._ || {})
|
|
388
|
-
_req._.params = req.params
|
|
389
|
-
_req._.query = query
|
|
390
387
|
if (req.protocol) _req.protocol = req.protocol
|
|
391
388
|
_req._ = req._
|
|
389
|
+
if (!_req._.event) _req._.event = req.event
|
|
392
390
|
const cqnData = _req.query.UPDATE?.data || _req.query.INSERT?.entries?.[0]
|
|
393
391
|
if (cqnData) _req.data = cqnData // must point to the same object
|
|
394
392
|
Object.defineProperty(_req, '_messages', {
|
|
@@ -922,7 +920,7 @@ const Read = {
|
|
|
922
920
|
const orderByExpr = query.SELECT.orderBy
|
|
923
921
|
const getOrderByColumns = columns => {
|
|
924
922
|
const selectAll = columns === undefined || columns.includes('*')
|
|
925
|
-
const queryColumns = !selectAll && columns && columns.map(column => column?.ref?.[0]).filter(c => c)
|
|
923
|
+
const queryColumns = !selectAll && columns && columns.map(column => column.as || column?.ref?.[0]).filter(c => c)
|
|
926
924
|
const newColumns = []
|
|
927
925
|
|
|
928
926
|
for (const column of orderByExpr) {
|
|
@@ -3,7 +3,7 @@ const { getKeysForNavigationFromRefPath } = require('../../_runtime/common/utils
|
|
|
3
3
|
|
|
4
4
|
// REVISIT: do we already have something like this _without using okra api_?
|
|
5
5
|
// REVISIT: should we still support process.env.CDS_FEATURES_PARAMS? probably nobody uses it...
|
|
6
|
-
|
|
6
|
+
exports.getKeysAndParamsFromPath = (from, { model }) => {
|
|
7
7
|
if (!from.ref || !from.ref.length) return {}
|
|
8
8
|
|
|
9
9
|
const keys = {}
|
|
@@ -44,7 +44,3 @@ const getKeysAndParamsFromPath = (from, { model }) => {
|
|
|
44
44
|
|
|
45
45
|
return { keys, params }
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
module.exports = {
|
|
49
|
-
getKeysAndParamsFromPath
|
|
50
|
-
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { Readable } = require('node:stream')
|
|
2
|
+
|
|
3
|
+
exports.validateMimetypeIsAcceptedOrThrow = (headers, contentType) => {
|
|
4
|
+
if (!contentType || !headers?.accept) return
|
|
5
|
+
if (headers.accept.includes('*/*')) return
|
|
6
|
+
if (headers.accept.includes(contentType)) return
|
|
7
|
+
if (headers.accept.includes(contentType.slice(0,contentType.indexOf('/')) + '/*')) return
|
|
8
|
+
const msg = `Content type "${contentType}" is not listed in accept header "${headers.accept}"`
|
|
9
|
+
throw Object.assign(new Error(msg), { statusCode: 406 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// REVISIT: We should use express' res.type(...) instead of res.set('Content-Type', ...)
|
|
13
|
+
const _mimetypes = {
|
|
14
|
+
'.pdf': 'application/pdf',
|
|
15
|
+
'.csv': 'text/csv',
|
|
16
|
+
'.html': 'text/html',
|
|
17
|
+
'.css': 'text/css',
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.jpeg': 'image/jpeg',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { extname } = require('path')
|
|
24
|
+
const _mimetype4 = filename => {
|
|
25
|
+
if (!filename) return
|
|
26
|
+
const filetype = extname(filename).toLowerCase()
|
|
27
|
+
return _mimetypes[filetype]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const _annotation = (def,a) => {
|
|
31
|
+
if (!def) return
|
|
32
|
+
if (typeof def[a] === 'string') return def[a]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// REVISIT: Such helpers are a pain -> use classes with methods instead, e.g. RestAdapter extends HttpAdapter, ODataAdapter extends RestAdapter, etc.
|
|
36
|
+
exports.collectStreamMetadata = (result, operation, query) => {
|
|
37
|
+
const element = query?._propertyAccess ? query.target?.elements?.[query._propertyAccess] : undefined
|
|
38
|
+
const returns = operation?.returns
|
|
39
|
+
|
|
40
|
+
const filename =
|
|
41
|
+
result.$mediaContentDispositionFilename ?? // legacy -> support for odata only?
|
|
42
|
+
result.filename ??
|
|
43
|
+
_annotation (returns, '@Core.ContentDisposition.Filename')
|
|
44
|
+
_annotation (element, '@Core.ContentDisposition.Filename')
|
|
45
|
+
|
|
46
|
+
const disposition =
|
|
47
|
+
result.$mediaContentDispositionType ?? // legacy -> support for odata only?
|
|
48
|
+
_annotation (returns, '@Core.ContentDisposition.Type') ??
|
|
49
|
+
_annotation (element, '@Core.ContentDisposition.Type') ??
|
|
50
|
+
(filename ? 'attachment' : 'inline')
|
|
51
|
+
|
|
52
|
+
const mimetype =
|
|
53
|
+
result['*@odata.mediaContentType'] ?? // compat -> support for odata only?
|
|
54
|
+
result.$mediaContentType ?? // legacy -> support for odata only?
|
|
55
|
+
result.mimetype ??
|
|
56
|
+
_mimetype4 (filename) ??
|
|
57
|
+
_mimetype4 (result.path) ?? // e.g. for file downloads
|
|
58
|
+
_annotation (returns, '@Core.MediaType') ??
|
|
59
|
+
_annotation (element, '@Core.MediaType') ??
|
|
60
|
+
'application/octet-stream' // REVISIT: or rather default to undefined?
|
|
61
|
+
|
|
62
|
+
return { mimetype, filename, disposition }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
exports.getReadable = function readable4 (result) {
|
|
66
|
+
if (result == null) return
|
|
67
|
+
if (typeof result !== 'object') {
|
|
68
|
+
const stream = new Readable()
|
|
69
|
+
stream.push(result)
|
|
70
|
+
stream.push(null)
|
|
71
|
+
return stream
|
|
72
|
+
}
|
|
73
|
+
if (result instanceof Readable) return result
|
|
74
|
+
if (result.value) return readable4 (result.value) // REVISIT: for OData legacy only?
|
|
75
|
+
if (Array.isArray(result)) return readable4 (result[0]) // compat // REVISIT: remove ?
|
|
76
|
+
}
|
|
@@ -8,7 +8,7 @@ const readAfterWrite4 = require('../utils/readAfterWrite')
|
|
|
8
8
|
const getODataResult = require('../utils/result')
|
|
9
9
|
const normalizeTimeData = require('../utils/normalizeTimeData')
|
|
10
10
|
|
|
11
|
-
const { getKeysAndParamsFromPath } = require('../../common/utils')
|
|
11
|
+
const { getKeysAndParamsFromPath } = require('../../common/utils/path')
|
|
12
12
|
|
|
13
13
|
module.exports = (adapter, isUpsert) => {
|
|
14
14
|
// REVISIT: adapter should be this
|
|
@@ -3,7 +3,7 @@ const { UPDATE, DELETE } = cds.ql
|
|
|
3
3
|
|
|
4
4
|
const { handleSapMessages, getPreferReturnHeader } = require('../utils')
|
|
5
5
|
|
|
6
|
-
const { getKeysAndParamsFromPath } = require('../../common/utils')
|
|
6
|
+
const { getKeysAndParamsFromPath } = require('../../common/utils/path')
|
|
7
7
|
|
|
8
8
|
module.exports = adapter => {
|
|
9
9
|
const { service } = adapter
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
const cds = require('../../../')
|
|
2
2
|
|
|
3
|
-
const { cds2edm, handleSapMessages, calculateLocationHeader } = require('../utils')
|
|
4
3
|
const getODataMetadata = require('../utils/metadata')
|
|
5
4
|
const postProcess = require('../utils/postProcess')
|
|
6
5
|
const getODataResult = require('../utils/result')
|
|
7
6
|
|
|
8
|
-
const {
|
|
7
|
+
const { cds2edm, handleSapMessages, calculateLocationHeader } = require('../utils')
|
|
8
|
+
const { getKeysAndParamsFromPath } = require('../../common/utils/path')
|
|
9
|
+
const {
|
|
10
|
+
collectStreamMetadata,
|
|
11
|
+
getReadable,
|
|
12
|
+
validateMimetypeIsAcceptedOrThrow
|
|
13
|
+
} = require('../../common/utils/streaming')
|
|
9
14
|
|
|
10
15
|
const _findEdmNameFor = (definition, namespace, fullyQualified = false) => {
|
|
11
16
|
let name
|
|
@@ -93,20 +98,59 @@ module.exports = adapter => {
|
|
|
93
98
|
// we need a cds.Request for multiple reasons, incl. params, headers, sap-messages, read after write, ...
|
|
94
99
|
const cdsReq = adapter.request4({ query, event, data, params, headers, target: query?.target, req, res })
|
|
95
100
|
|
|
101
|
+
const _isStream = operation.returns?._type === 'cds.LargeBinary' && !!operation.returns['@Core.MediaType']
|
|
102
|
+
|
|
96
103
|
// NOTES:
|
|
97
104
|
// - only via srv.run in combination with srv.dispatch inside,
|
|
98
105
|
// we automatically either use a single auto-managed tx for the req (i.e., insert and read after write in same tx)
|
|
99
106
|
// or the auto-managed tx opened for the respective atomicity group, if exists
|
|
100
107
|
// - in the then block of .run(), the transaction is committed (i.e., before sending the response) if a single auto-managed tx is used
|
|
101
108
|
return service
|
|
102
|
-
.run(() =>
|
|
109
|
+
.run(() =>
|
|
110
|
+
service.dispatch(cdsReq).then(result => {
|
|
111
|
+
if (res.headersSent) return result
|
|
112
|
+
if (!_isStream) return result
|
|
113
|
+
handleSapMessages(cdsReq, req, res)
|
|
114
|
+
|
|
115
|
+
const stream = getReadable(result)
|
|
116
|
+
if (!stream) return res.sendStatus(204)
|
|
117
|
+
|
|
118
|
+
const { mimetype, filename, disposition } = collectStreamMetadata(result, operation, query)
|
|
119
|
+
validateMimetypeIsAcceptedOrThrow(req.headers, mimetype)
|
|
120
|
+
if (!res.get('content-type')) res.set('content-type', mimetype)
|
|
121
|
+
if (filename && !res.get('content-disposition'))
|
|
122
|
+
res.set('content-disposition', `${disposition}; filename="${encodeURIComponent(filename)}"`)
|
|
123
|
+
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
if (res.destroyed)
|
|
126
|
+
return reject(
|
|
127
|
+
new cds.error({ code: 'ERR_STREAM_PREMATURE_CLOSE', message: 'Response was closed while streaming' })
|
|
128
|
+
)
|
|
129
|
+
stream.pipe(res)
|
|
130
|
+
stream.on('end', () => resolve(result))
|
|
131
|
+
stream.once('error', reject)
|
|
132
|
+
let finished = false
|
|
133
|
+
res.on('finish', () => (finished = true))
|
|
134
|
+
res.on(
|
|
135
|
+
'close',
|
|
136
|
+
() =>
|
|
137
|
+
!finished &&
|
|
138
|
+
reject(
|
|
139
|
+
new cds.error({
|
|
140
|
+
code: 'ERR_STREAM_PREMATURE_CLOSE',
|
|
141
|
+
message: 'Response was closed while streaming'
|
|
142
|
+
})
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
}).then(() => res.end())
|
|
146
|
+
})
|
|
147
|
+
)
|
|
103
148
|
.then(result => {
|
|
104
149
|
if (res.headersSent) return
|
|
105
150
|
|
|
106
151
|
handleSapMessages(cdsReq, req, res)
|
|
107
152
|
|
|
108
153
|
if (operation.returns?.items && result == null) result = []
|
|
109
|
-
|
|
110
154
|
if (!operation.returns || result == null) return res.sendStatus(204)
|
|
111
155
|
|
|
112
156
|
if (operation.returns._type?.match?.(/^cds\./)) {
|
|
@@ -7,7 +7,7 @@ const getODataMetadata = require('../utils/metadata')
|
|
|
7
7
|
const postProcess = require('../utils/postProcess')
|
|
8
8
|
const getODataResult = require('../utils/result')
|
|
9
9
|
|
|
10
|
-
const { getKeysAndParamsFromPath } = require('../../common/utils')
|
|
10
|
+
const { getKeysAndParamsFromPath } = require('../../common/utils/path')
|
|
11
11
|
|
|
12
12
|
const { getPageSize } = require('../../_runtime/common/generic/paging')
|
|
13
13
|
const { handleStreamProperties } = require('../../_runtime/common/utils/streamProp')
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
const cds = require('../../../')
|
|
2
2
|
const LOG = cds.log('odata')
|
|
3
|
-
|
|
4
|
-
const { Readable } = require('node:stream')
|
|
3
|
+
const getError = require('../../_runtime/common/error')
|
|
5
4
|
|
|
6
5
|
const { handleSapMessages, validateIfNoneMatch, isStream, isRedirect } = require('../utils')
|
|
7
|
-
|
|
8
|
-
const {
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
const { getKeysAndParamsFromPath } = require('../../common/utils/path')
|
|
7
|
+
const {
|
|
8
|
+
collectStreamMetadata,
|
|
9
|
+
validateMimetypeIsAcceptedOrThrow,
|
|
10
|
+
getReadable
|
|
11
|
+
} = require('../../common/utils/streaming')
|
|
11
12
|
const { getTransition } = require('../../_runtime/common/utils/resolveView')
|
|
12
13
|
|
|
13
14
|
const _resolveContentProperty = (target, annotName, resolvedProp) => {
|
|
@@ -71,94 +72,6 @@ const _addStreamMetadata = query => {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
const _validateStream = (req, result) => {
|
|
75
|
-
// REVISIT: compat, should actually be treated as object
|
|
76
|
-
if (!Array.isArray(result)) result = [result]
|
|
77
|
-
|
|
78
|
-
// Reading one entity or a property of it should yield only a result length of one.
|
|
79
|
-
if (result.length === 0 || result[0] === undefined) throw getError(404)
|
|
80
|
-
|
|
81
|
-
if (result.length > 1) throw getError(400)
|
|
82
|
-
|
|
83
|
-
if (result[0] === null) return
|
|
84
|
-
|
|
85
|
-
result = result[0]
|
|
86
|
-
|
|
87
|
-
const headers = req.headers
|
|
88
|
-
const contentType = result.$mediaContentType
|
|
89
|
-
|
|
90
|
-
if (!headers?.accept || !contentType) return
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
!headers.accept.includes('*/*') &&
|
|
94
|
-
!headers.accept.includes(contentType) &&
|
|
95
|
-
!headers.accept.includes(contentType.split('/')[0] + '/*')
|
|
96
|
-
) {
|
|
97
|
-
const msg = `Content type "${contentType}" is not listed in accept header "${headers.accept}"`
|
|
98
|
-
throw Object.assign(new Error(msg), { statusCode: 406 })
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const _ensureStream = stream => {
|
|
103
|
-
if (stream === null) return null
|
|
104
|
-
// temp workaround for url streaming
|
|
105
|
-
const stream_ = new Readable()
|
|
106
|
-
stream_.push(stream)
|
|
107
|
-
stream_.push(null)
|
|
108
|
-
return stream_
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const _normalizeStream = (result, propertyName, lastPathElement, target) => {
|
|
112
|
-
if (!result) return null
|
|
113
|
-
|
|
114
|
-
let readable = result
|
|
115
|
-
if (typeof result === 'object') {
|
|
116
|
-
if (propertyName && result[propertyName] !== undefined) {
|
|
117
|
-
readable = result[propertyName]
|
|
118
|
-
}
|
|
119
|
-
// implicit streaming
|
|
120
|
-
else if (lastPathElement === '$value') {
|
|
121
|
-
const property = Object.values(target.elements).find(
|
|
122
|
-
el => el.type === 'cds.LargeBinary' && result[el.name] !== undefined
|
|
123
|
-
)
|
|
124
|
-
readable = property && result[property.name]
|
|
125
|
-
}
|
|
126
|
-
// result.value can be obtained from custom handlers
|
|
127
|
-
else if (result.value !== undefined) {
|
|
128
|
-
readable = result.value
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!(readable instanceof Readable)) {
|
|
133
|
-
readable = _ensureStream(readable)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (readable) {
|
|
137
|
-
readable.on('error', () => {
|
|
138
|
-
readable.removeAllListeners('error')
|
|
139
|
-
// readable.destroy() does not end stream in node 10 and 12
|
|
140
|
-
readable.push(null)
|
|
141
|
-
})
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return readable
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const _setStreamingHeaders = (result, res) => {
|
|
148
|
-
// backwards compatibility for Content-Type in stream
|
|
149
|
-
if (result['$mediaContentType']) res.setHeader('Content-Type', result.$mediaContentType)
|
|
150
|
-
else if (result['*@odata.mediaContentType']) res.setHeader('Content-Type', result['*@odata.mediaContentType'])
|
|
151
|
-
else res.setHeader('Content-Type', 'application/octet-stream')
|
|
152
|
-
|
|
153
|
-
if ('$mediaContentDispositionFilename' in result) {
|
|
154
|
-
const cdt = result.$mediaContentDispositionType || 'attachment'
|
|
155
|
-
res.setHeader(
|
|
156
|
-
'content-disposition',
|
|
157
|
-
`${cdt}; filename="${encodeURIComponent(result.$mediaContentDispositionFilename)}"`
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
75
|
module.exports = adapter => {
|
|
163
76
|
const { service } = adapter
|
|
164
77
|
|
|
@@ -171,7 +84,7 @@ module.exports = adapter => {
|
|
|
171
84
|
if (isRedirect(query)) {
|
|
172
85
|
const cdsReq = adapter.request4({ query, req, res })
|
|
173
86
|
|
|
174
|
-
service.dispatch(cdsReq).then(result => {
|
|
87
|
+
return service.dispatch(cdsReq).then(result => {
|
|
175
88
|
if (result[query._propertyAccess]) res.set('Location', result[query._propertyAccess])
|
|
176
89
|
return res.sendStatus(307)
|
|
177
90
|
})
|
|
@@ -219,17 +132,32 @@ module.exports = adapter => {
|
|
|
219
132
|
.run(() => {
|
|
220
133
|
return service.dispatch(cdsReq).then(async result => {
|
|
221
134
|
if (res.headersSent) return
|
|
222
|
-
|
|
223
|
-
|
|
135
|
+
if (result === undefined) throw getError(404) // entity is not existing
|
|
136
|
+
if (result === null) return res.sendStatus(204) // custom handler returns null
|
|
224
137
|
|
|
225
138
|
if (validateIfNoneMatch(cdsReq.target, req.headers?.['if-none-match'], result)) return res.sendStatus(304)
|
|
226
139
|
|
|
227
|
-
const
|
|
228
|
-
if (
|
|
140
|
+
const { mimetype, filename, disposition } = collectStreamMetadata(result, undefined, query)
|
|
141
|
+
if (pdfMimeType && !mimetype) result.mimetype = 'application/pdf' // REVISIT: Is compat still needed?
|
|
142
|
+
|
|
143
|
+
// REVISIT: If accessed property is undefined - why prevent 404?
|
|
144
|
+
if (query._propertyAccess && result[query._propertyAccess] !== undefined) {
|
|
145
|
+
result = result[query._propertyAccess]
|
|
146
|
+
} else if (lastPathElement === '$value') {
|
|
147
|
+
// Implicit streaming
|
|
148
|
+
const property = Object.values(query.target.elements).find(
|
|
149
|
+
el => el.type === 'cds.LargeBinary' && result[el.name] !== undefined
|
|
150
|
+
)
|
|
151
|
+
result = property && result[property.name]
|
|
152
|
+
}
|
|
229
153
|
|
|
230
|
-
|
|
154
|
+
const stream = getReadable(result)
|
|
155
|
+
if (!stream) return res.sendStatus(204)
|
|
231
156
|
|
|
232
|
-
|
|
157
|
+
validateMimetypeIsAcceptedOrThrow(req.headers, mimetype)
|
|
158
|
+
if (!res.get('content-type')) res.set('content-type', mimetype)
|
|
159
|
+
if (filename && !res.get('content-disposition'))
|
|
160
|
+
res.set('content-disposition', `${disposition}; filename="${encodeURIComponent(filename)}"`)
|
|
233
161
|
|
|
234
162
|
return new Promise((resolve, reject) => {
|
|
235
163
|
if (res.destroyed)
|
|
@@ -8,7 +8,7 @@ const readAfterWrite4 = require('../utils/readAfterWrite')
|
|
|
8
8
|
const getODataResult = require('../utils/result')
|
|
9
9
|
const normalizeTimeData = require('../utils/normalizeTimeData')
|
|
10
10
|
|
|
11
|
-
const { getKeysAndParamsFromPath } = require('../../common/utils')
|
|
11
|
+
const { getKeysAndParamsFromPath } = require('../../common/utils/path')
|
|
12
12
|
|
|
13
13
|
const _isUpsertAllowed = ({ target, data, event }) => {
|
|
14
14
|
return (
|
|
@@ -411,7 +411,8 @@ function _processSegments(from, model, namespace, cqn, protocol) {
|
|
|
411
411
|
ref[i].args = current['@open']
|
|
412
412
|
? Object.assign({}, from._params)
|
|
413
413
|
: Object.keys(from._params).reduce((acc, cur) => {
|
|
414
|
-
|
|
414
|
+
const param = cur.startsWith('@') ? cur.slice(1) : cur
|
|
415
|
+
if (current.params && param in current.params) acc[param] = from._params[cur]
|
|
415
416
|
return acc
|
|
416
417
|
}, {})
|
|
417
418
|
_resolveImplicitFunctionParameters(ref[i].args)
|
|
@@ -803,6 +804,24 @@ module.exports = (cqn, model, namespace, protocol) => {
|
|
|
803
804
|
// one?
|
|
804
805
|
if (one) cqn.SELECT.one = true
|
|
805
806
|
|
|
807
|
+
// hierarchy requests, quick check to avoid unnecessary traversing
|
|
808
|
+
// REVISIT: Should be done via annotation on backlink, would make lookup easier
|
|
809
|
+
if (target?.elements?.LimitedDescendantCount) {
|
|
810
|
+
let uplinkName
|
|
811
|
+
for (const key in target) {
|
|
812
|
+
if (key.match(/@Aggregation\.RecursiveHierarchy#.*\.ParentNavigationProperty/)) {
|
|
813
|
+
// Qualifiers are bad for lookups
|
|
814
|
+
uplinkName = target[key]['=']
|
|
815
|
+
break
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const setRecurseRef = SELECT => {
|
|
819
|
+
if (SELECT.from.SELECT) setRecurseRef(SELECT.from.SELECT)
|
|
820
|
+
if (SELECT.recurse) SELECT.recurse.ref[0] = uplinkName
|
|
821
|
+
}
|
|
822
|
+
if (uplinkName) setRecurseRef(cqn.SELECT)
|
|
823
|
+
}
|
|
824
|
+
|
|
806
825
|
// REVISIT: better
|
|
807
826
|
// set target (csn definition) for later retrieval
|
|
808
827
|
cqn.__target = current.parent?.kind === 'entity' ? `${current.parent.name}:$:${current.name}` : current.name
|