@sap/cds 8.7.2 → 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 +50 -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/compile/parse.js +1 -1
- 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/ql/cds.ql-predicates.js +2 -1
- package/lib/req/request.js +5 -2
- package/lib/req/validate.js +4 -2
- 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/restrict.js +1 -1
- package/libx/_runtime/common/generic/auth/service.js +2 -2
- package/libx/_runtime/common/generic/auth/utils.js +2 -1
- 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 +5 -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 +21 -1
- package/libx/odata/parse/grammar.peggy +108 -26
- package/libx/odata/parse/multipartToJson.js +17 -10
- package/libx/odata/parse/parser.js +1 -1
- package/libx/odata/utils/metadata.js +28 -5
- package/libx/odata/utils/normalizeTimeData.js +11 -8
- 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
|
@@ -10,13 +10,11 @@ module.exports = function ODataAdapter(srv) {
|
|
|
10
10
|
router.use(function odata_log(req, _, next) {
|
|
11
11
|
let url = decodeURI(req.originalUrl)
|
|
12
12
|
LOG && LOG(req.method, url, req.body || '')
|
|
13
|
-
if (/\$batch/.test(req.url))
|
|
14
|
-
req.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
})
|
|
19
|
-
|
|
13
|
+
if (/\$batch/.test(req.url)) req._okra_logger = req => {
|
|
14
|
+
let path = decodeURI(req._.odataReq?._rawODataPath || '')
|
|
15
|
+
LOG && LOG('>', req.event, path, req._queryOptions || '')
|
|
16
|
+
if (LOG._debug && req.query) LOG.debug(req.query) //> why only for batch subrequests?
|
|
17
|
+
}
|
|
20
18
|
next()
|
|
21
19
|
})
|
|
22
20
|
router.use(legacy_adapter4(srv))
|
package/lib/srv/srv-dispatch.js
CHANGED
|
@@ -15,11 +15,6 @@ exports.dispatch = async function dispatch (req) { //NOSONAR
|
|
|
15
15
|
if (!this.context) return this.run (tx => tx.dispatch(req))
|
|
16
16
|
if (!req.tx) req.tx = this // `this` is a tx from now on...
|
|
17
17
|
|
|
18
|
-
// REVISIT: remove together with okra
|
|
19
|
-
// Inform potential listeners
|
|
20
|
-
let _is_root = req.constructor.name in { ODataRequest:1 }
|
|
21
|
-
if (_is_root) req._.req.emit ('dispatch',req)
|
|
22
|
-
|
|
23
18
|
// Handle batches of queries
|
|
24
19
|
if (_is_array(req.query)) return Promise.all (req.query.map (
|
|
25
20
|
q => this.dispatch ({ query:q, context: req.context, __proto__:req })
|
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
|
+
}
|
|
@@ -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) {
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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 (
|