@sap/cds 7.6.4 → 7.7.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 +39 -1
- package/_i18n/i18n.properties +3 -0
- package/app/index.js +14 -8
- package/bin/serve.js +51 -19
- package/common.cds +16 -0
- package/lib/auth/ias-auth.js +2 -2
- package/lib/auth/index.js +1 -1
- package/lib/auth/jwt-auth.js +1 -1
- package/lib/compile/cdsc.js +23 -11
- package/lib/compile/for/nodejs.js +2 -2
- package/lib/compile/for/odata.js +4 -0
- package/lib/compile/load.js +7 -2
- package/lib/compile/to/sql.js +3 -0
- package/lib/dbs/cds-deploy.js +197 -220
- package/lib/env/defaults.js +2 -1
- package/lib/index.js +8 -2
- package/lib/linked/types.js +1 -0
- package/lib/log/format/json.js +4 -1
- package/lib/plugins.js +2 -2
- package/lib/ql/SELECT.js +8 -8
- package/lib/req/context.js +22 -13
- package/lib/req/request.js +10 -4
- package/lib/srv/cds-connect.js +9 -3
- package/lib/srv/cds-serve.js +5 -3
- package/lib/srv/middlewares/ctx-model.js +1 -1
- package/lib/srv/protocols/odata-v4.js +38 -9
- package/lib/srv/srv-api.js +98 -140
- package/lib/srv/srv-models.js +2 -2
- package/lib/srv/srv-tx.js +1 -0
- package/lib/utils/cds-utils.js +32 -23
- package/lib/utils/data.js +1 -1
- package/lib/utils/tar.js +1 -1
- package/libx/_runtime/cds-services/adapter/odata-v4/handlers/metadata.js +1 -3
- package/libx/_runtime/cds-services/adapter/odata-v4/handlers/read.js +0 -2
- package/libx/_runtime/cds-services/adapter/odata-v4/handlers/update.js +18 -1
- package/libx/_runtime/cds-services/adapter/odata-v4/odata-to-cqn/index.js +1 -1
- package/libx/_runtime/cds-services/adapter/odata-v4/odata-to-cqn/utils.js +7 -3
- package/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-commons/uri/UriParser.js +2 -1
- package/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-server/index.js +5 -0
- package/libx/_runtime/cds-services/adapter/odata-v4/utils/data.js +71 -25
- package/libx/_runtime/cds-services/adapter/odata-v4/utils/metaInfo.js +10 -2
- package/libx/_runtime/cds-services/adapter/odata-v4/utils/readAfterWrite.js +6 -1
- package/libx/_runtime/cds-services/util/assert.js +50 -240
- package/libx/_runtime/cds.js +5 -0
- package/libx/_runtime/common/aspects/any.js +53 -45
- package/libx/_runtime/common/generic/input.js +14 -10
- package/libx/_runtime/common/generic/paging.js +1 -1
- package/libx/_runtime/common/utils/cqn.js +1 -1
- package/libx/_runtime/common/utils/cqn2cqn4sql.js +1 -1
- package/libx/_runtime/common/utils/keys.js +1 -1
- package/libx/_runtime/common/utils/quotingStyles.js +1 -1
- package/libx/_runtime/common/utils/resolveStructured.js +4 -1
- package/libx/_runtime/common/utils/rewriteAsterisks.js +5 -12
- package/libx/_runtime/common/utils/stream.js +2 -16
- package/libx/_runtime/common/utils/streamProp.js +16 -6
- package/libx/_runtime/common/utils/ucsn.js +1 -0
- package/libx/_runtime/db/expand/expandCQNToJoin.js +1 -1
- package/libx/_runtime/db/sql-builder/InsertBuilder.js +1 -1
- package/libx/_runtime/db/utils/columns.js +6 -1
- package/libx/_runtime/fiori/generic/activate.js +11 -3
- package/libx/_runtime/fiori/generic/edit.js +8 -2
- package/libx/_runtime/fiori/lean-draft.js +94 -30
- package/libx/_runtime/hana/execute.js +2 -5
- package/libx/_runtime/messaging/enterprise-messaging-utils/registerEndpoints.js +12 -22
- package/libx/_runtime/messaging/service.js +6 -2
- package/libx/common/assert/index.js +232 -0
- package/libx/common/assert/type.js +109 -0
- package/libx/common/assert/utils.js +125 -0
- package/libx/common/assert/validation.js +109 -0
- package/libx/odata/index.js +5 -5
- package/libx/odata/middleware/create.js +83 -0
- package/libx/odata/middleware/delete.js +38 -0
- package/libx/odata/middleware/error.js +8 -0
- package/libx/odata/{metadata.js → middleware/metadata.js} +8 -6
- package/libx/odata/middleware/operation.js +78 -0
- package/libx/odata/middleware/parse.js +11 -0
- package/libx/odata/{read.js → middleware/read.js} +42 -20
- package/libx/odata/{service-document.js → middleware/service-document.js} +2 -1
- package/libx/odata/middleware/stream.js +237 -0
- package/libx/odata/middleware/update.js +165 -0
- package/libx/odata/{afterburner.js → parse/afterburner.js} +79 -29
- package/libx/odata/{cqn2odata.js → parse/cqn2odata.js} +5 -3
- package/libx/odata/{parseToCqn.js → parse/parseToCqn.js} +3 -6
- package/libx/odata/{utils.js → utils/index.js} +95 -9
- package/libx/outbox/index.js +2 -1
- package/libx/rest/RestAdapter.js +0 -1
- package/libx/rest/middleware/operation.js +6 -4
- package/libx/rest/middleware/parse.js +20 -2
- package/package.json +1 -1
- package/server.js +43 -71
- package/libx/odata/create.js +0 -44
- package/libx/odata/delete.js +0 -25
- package/libx/odata/error.js +0 -12
- package/libx/odata/update.js +0 -110
- /package/libx/odata/{grammar.peggy → parse/grammar.peggy} +0 -0
- /package/libx/odata/{parser.js → parse/parser.js} +0 -0
- /package/libx/odata/{result.js → utils/result.js} +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const cds = require('../../../')
|
|
2
|
+
const { UPDATE, DELETE } = cds.ql
|
|
3
|
+
|
|
4
|
+
const { odataError, getKeysFromPath } = require('../utils')
|
|
5
|
+
|
|
6
|
+
module.exports = srv =>
|
|
7
|
+
function deleete(req, res, next) {
|
|
8
|
+
if (req._preferReturn) {
|
|
9
|
+
const message = `The 'return' preference is not allowed in ${req.method} requests`
|
|
10
|
+
return res.status(400).json({ error: { code: '400', message } })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { _query: query } = req
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
SELECT: { one, from }
|
|
17
|
+
} = query
|
|
18
|
+
|
|
19
|
+
if (!one) {
|
|
20
|
+
// REVISIT: don't use "ENTITY.COLLECTION" as that's an okra term
|
|
21
|
+
return res.status(405).json(odataError('405', `Method DELETE not allowed for ENTITY.COLLECTION`))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// for read and delete, we provide keys in req.data
|
|
25
|
+
const data = getKeysFromPath(query.SELECT.from, srv)
|
|
26
|
+
|
|
27
|
+
// REVISIT: better
|
|
28
|
+
if (query._propertyAccess) data[query._propertyAccess] = null
|
|
29
|
+
|
|
30
|
+
// REVISIT: maybe also just dispatch a cds request here?
|
|
31
|
+
return srv
|
|
32
|
+
.run(query._propertyAccess ? UPDATE(from).set({ [query._propertyAccess]: null }) : DELETE.from(from), data)
|
|
33
|
+
.then(result => {
|
|
34
|
+
if (result === 0) return res.status(404).json({ error: { code: '404', message: 'Not Found' } })
|
|
35
|
+
res.sendStatus(204)
|
|
36
|
+
})
|
|
37
|
+
.catch(next)
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const { normalizeError } = require('../../_runtime/common/error/frontend')
|
|
2
|
+
|
|
3
|
+
module.exports = _srv => (err, req, res, _next) => {
|
|
4
|
+
const { error, statusCode } = normalizeError(err, req)
|
|
5
|
+
|
|
6
|
+
// NOTE: normalizeError already does sanatization -> we can use as is
|
|
7
|
+
res.status(statusCode).json({ error })
|
|
8
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
const cds = require('
|
|
1
|
+
const cds = require('../../../')
|
|
2
2
|
const LOG = cds.log('odata')
|
|
3
|
+
|
|
3
4
|
const crypto = require('crypto')
|
|
4
|
-
|
|
5
|
+
|
|
6
|
+
const { odataError } = require('../utils')
|
|
5
7
|
|
|
6
8
|
const _requestedFormat = (queryOption, header) => {
|
|
7
9
|
if (queryOption) return queryOption.match(/json/i) ? 'json' : 'xml'
|
|
@@ -91,10 +93,10 @@ module.exports = srv =>
|
|
|
91
93
|
// REVISIT: remove check later
|
|
92
94
|
if (mpSupportsEmptyLocale()) {
|
|
93
95
|
// If no extensibility nor fts, do not provide model to mtxs
|
|
94
|
-
const modelNeeded =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
const modelNeeded = cds.env.requires.extensibility || cds.context.features?.given
|
|
97
|
+
edmx =
|
|
98
|
+
metadataCache.edm ||
|
|
99
|
+
(await mps.getEdmx({ tenant, model: modelNeeded && srv.model, service: srv.definition.name }))
|
|
98
100
|
metadataCache.edm = edmx
|
|
99
101
|
const extBundle = cds.env.requires.extensibility && (await mps.getI18n({ tenant, locale }))
|
|
100
102
|
edmx = cds.localize(srv.model, locale, edmx, extBundle)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const cds = require('../../../')
|
|
2
|
+
|
|
3
|
+
const { toODataResult } = require('../utils/result')
|
|
4
|
+
const { cds2edm } = require('../utils')
|
|
5
|
+
|
|
6
|
+
const { deepCopy } = require('../../_runtime/common/utils/copy')
|
|
7
|
+
|
|
8
|
+
// REVISIT: move to or rewrite in libx/odata
|
|
9
|
+
const metaInfo = require('../../_runtime/cds-services/adapter/odata-v4/utils/metaInfo')
|
|
10
|
+
|
|
11
|
+
module.exports = srv => (req, res, next) => {
|
|
12
|
+
let { operation, args } = req._query.SELECT.from.ref.slice(-1)[0]
|
|
13
|
+
if (!operation) return next() //> create or read
|
|
14
|
+
|
|
15
|
+
// unbound vs. bound
|
|
16
|
+
let entity
|
|
17
|
+
if (srv.model.definitions[operation]) {
|
|
18
|
+
operation = srv.model.definitions[operation]
|
|
19
|
+
} else {
|
|
20
|
+
req._query.SELECT.from.ref.pop()
|
|
21
|
+
// TODO: this does not work when navigating to the entity
|
|
22
|
+
const lastRef = req._query.SELECT.from.ref.slice(-1)[0]
|
|
23
|
+
entity = lastRef.id || lastRef
|
|
24
|
+
entity = srv.model.definitions[entity]
|
|
25
|
+
operation = entity.actions[operation]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = args || deepCopy(req.body)
|
|
29
|
+
|
|
30
|
+
// assert payload
|
|
31
|
+
const assertOptions = { filter: true, http: { req }, mandatories: true }
|
|
32
|
+
const errs = cds.assert(data, operation, assertOptions)
|
|
33
|
+
if (errs) {
|
|
34
|
+
if (errs.length === 1) throw errs[0]
|
|
35
|
+
throw Object.assign(new Error('MULTIPLE_ERRORS'), { statusCode: 400, details: errs })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// REVISIT: when is operation.name actually prefixed with the service name?
|
|
39
|
+
const event = operation.name.replace(`${srv.name}.`, '')
|
|
40
|
+
|
|
41
|
+
// TODO: params
|
|
42
|
+
const cdsReq = new cds.Request({ query: entity ? req._query : undefined, event, data, params: [] })
|
|
43
|
+
|
|
44
|
+
srv
|
|
45
|
+
.dispatch(cdsReq)
|
|
46
|
+
.then(result => {
|
|
47
|
+
// REVISIT: result === undefined valid for modelled return type?
|
|
48
|
+
if (!operation.returns || result === undefined) return res.sendStatus(204)
|
|
49
|
+
|
|
50
|
+
if (operation.returns._type?.match?.(/^cds\./)) {
|
|
51
|
+
// TODO: check result type
|
|
52
|
+
return res.json({
|
|
53
|
+
'@odata.context': `${entity ? '../' : ''}$metadata#${cds2edm[operation.returns._type]}`,
|
|
54
|
+
value: result
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const assertOptions = { mandatories: true } //> TODO: more needed?
|
|
59
|
+
// TODO: error targets are not correct if return type is "many X"
|
|
60
|
+
const assertDefinition = operation.returns.items || operation.returns
|
|
61
|
+
const errs = cds.assert(result, assertDefinition, assertOptions)
|
|
62
|
+
if (errs) {
|
|
63
|
+
// TODO: proper error handling
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const info = metaInfo(req._query, event, srv, result, req)
|
|
67
|
+
// FIXME: info.metadata.isCollection is incorrect for draftActivate
|
|
68
|
+
if (event === 'draftActivate') info.metadata.isCollection = false
|
|
69
|
+
result = toODataResult(result, info)
|
|
70
|
+
|
|
71
|
+
// TODO: toODataResult() doesn't seem to handle this case
|
|
72
|
+
if (entity && !result['@odata.context'].match(/^\.\.\//))
|
|
73
|
+
result['@odata.context'] = '../' + result['@odata.context']
|
|
74
|
+
|
|
75
|
+
res.json(result)
|
|
76
|
+
})
|
|
77
|
+
.catch(next)
|
|
78
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const cds = require('../../../')
|
|
2
|
+
|
|
3
|
+
module.exports = srv => (req, _, next) => {
|
|
4
|
+
// if not a GET, use req.path instead of req.url to ignore query parameters
|
|
5
|
+
req._query = cds.odata.parse(req.method === 'GET' ? req.url : req.path, { service: srv, baseUrl: req.baseUrl })
|
|
6
|
+
|
|
7
|
+
const preferReturn = req.headers.prefer?.match(/\W?return=(\w+)/i)
|
|
8
|
+
if (preferReturn) req._preferReturn = preferReturn[1]
|
|
9
|
+
|
|
10
|
+
next()
|
|
11
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
const { toODataResult } = require('./result')
|
|
1
|
+
const cds = require('../../../')
|
|
2
|
+
const { toODataResult } = require('../utils/result')
|
|
4
3
|
const querystring = require('node:querystring')
|
|
5
|
-
const { getPageSize } = require('
|
|
4
|
+
const { getPageSize } = require('../../_runtime/common/generic/paging')
|
|
5
|
+
const { handleStreamProperties } = require('../../_runtime/common/utils/streamProp')
|
|
6
|
+
|
|
7
|
+
const { getKeysFromPath } = require('../utils')
|
|
8
|
+
|
|
9
|
+
// REVISIT: move to or rewrite in libx/odata
|
|
10
|
+
const metaInfo = require('../../_runtime/cds-services/adapter/odata-v4/utils/metaInfo')
|
|
6
11
|
|
|
7
12
|
const _getCount = result =>
|
|
8
13
|
Array.isArray(result)
|
|
@@ -16,17 +21,15 @@ const _calculateNextLink = (req, result) => {
|
|
|
16
21
|
if ($skiptoken) {
|
|
17
22
|
const queryParamsWithSkipToken = { ...req.http.req.query, $skiptoken }
|
|
18
23
|
// REVISIT: slice replaces leading '/'. Always starts with '/'?
|
|
19
|
-
result.$nextLink =
|
|
20
|
-
req.http.req.path.slice(1) +
|
|
21
|
-
|
|
24
|
+
result.$nextLink =
|
|
25
|
+
req.http.req.path.slice(1) +
|
|
26
|
+
'?' +
|
|
27
|
+
querystring.stringify(queryParamsWithSkipToken, '&', '=', { encodeURIComponent: e => e })
|
|
22
28
|
}
|
|
23
|
-
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
const _calculateSkiptoken = (req, result) => {
|
|
27
|
-
const limit = Array.isArray(req.query)
|
|
28
|
-
? getPageSize(req.query[0]._target).max
|
|
29
|
-
: req.query.SELECT.limit?.rows?.val
|
|
32
|
+
const limit = Array.isArray(req.query) ? getPageSize(req.query[0]._target).max : req.query.SELECT.limit?.rows?.val
|
|
30
33
|
const top = parseInt(req.http.req.query.$top)
|
|
31
34
|
if (limit === result.length && limit !== top) {
|
|
32
35
|
const token = req.http.req.query.$skiptoken
|
|
@@ -64,18 +67,34 @@ const _reliablePagingPossible = req => {
|
|
|
64
67
|
|
|
65
68
|
module.exports = srv =>
|
|
66
69
|
function read(req, res, next) {
|
|
67
|
-
|
|
70
|
+
if (req._preferReturn) {
|
|
71
|
+
const message = `The 'return' preference is not allowed in ${req.method} requests`
|
|
72
|
+
return res.status(400).json({ error: { code: '400', message } })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { _query: query } = req
|
|
76
|
+
|
|
77
|
+
// mainly for @odata.context
|
|
78
|
+
const info = metaInfo(query, 'READ', srv, {}, req, false)
|
|
79
|
+
|
|
80
|
+
const lastPathElement = req.path.split('/').slice(-1)[0]
|
|
81
|
+
|
|
82
|
+
handleStreamProperties(query.target, query.SELECT.columns, srv.model)
|
|
68
83
|
|
|
69
84
|
// we need the cds request, so we can access the modified query, which is cloned due to lean-draft, so we need to use dispatch here and pass a cds req
|
|
70
|
-
const cdsReq = new cds.Request({query})
|
|
85
|
+
const cdsReq = new cds.Request({ query })
|
|
86
|
+
// for read and delete, we provide keys in req.data
|
|
87
|
+
cdsReq.data = getKeysFromPath(query.SELECT.from, srv)
|
|
88
|
+
|
|
89
|
+
// REVISIT: what is this for? some tests fail without it... we should find a better solution!
|
|
90
|
+
Object.defineProperty(query.SELECT, '_4odata', { value: true })
|
|
91
|
+
|
|
71
92
|
return srv
|
|
72
93
|
.dispatch(cdsReq)
|
|
73
94
|
.then(result => {
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
}
|
|
95
|
+
if (result == null && query._target._isSingleton && query._target['@odata.singleton.nullable'])
|
|
96
|
+
return res.sendStatus(204)
|
|
77
97
|
|
|
78
|
-
const lastPathElement = req.path.split('/').slice(-1)[0]
|
|
79
98
|
if (lastPathElement === '$count') {
|
|
80
99
|
result = _getCount(result)
|
|
81
100
|
return res.send(result.toString())
|
|
@@ -83,12 +102,15 @@ module.exports = srv =>
|
|
|
83
102
|
return res.send(result[query._propertyAccess].toString())
|
|
84
103
|
}
|
|
85
104
|
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
if (query._propertyAccess && result[query._propertyAccess] === null) return res.sendStatus(204)
|
|
106
|
+
|
|
107
|
+
if (result == null) return res.status(404).json({ error: { code: '404', message: 'Not Found' } })
|
|
108
|
+
|
|
109
|
+
if (info.metadata.isCollection && !result.$nextLink) _calculateNextLink(cdsReq, result)
|
|
88
110
|
result = toODataResult(result, info)
|
|
89
111
|
|
|
90
112
|
// Express interprets numbers as HTTP status codes
|
|
91
|
-
|
|
113
|
+
res.send(typeof result === 'number' ? result.toString() : result)
|
|
92
114
|
})
|
|
93
115
|
.catch(next)
|
|
94
116
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
const cds = require('../../../')
|
|
2
|
+
const { Readable } = require('node:stream')
|
|
3
|
+
const getError = require('../../_runtime/common/error')
|
|
4
|
+
const { getTransition } = require('../../_runtime/common/utils/resolveView')
|
|
5
|
+
const LOG = cds.log('odata')
|
|
6
|
+
const { getKeysFromPath } = require('../utils')
|
|
7
|
+
|
|
8
|
+
const _resolveContentProperty = (target, annotName, resolvedProp) => {
|
|
9
|
+
if (target.elements[resolvedProp]) {
|
|
10
|
+
return resolvedProp
|
|
11
|
+
}
|
|
12
|
+
LOG._warn &&
|
|
13
|
+
LOG.warn(
|
|
14
|
+
`"${annotName}" in entity "${target.name}" points to property "${resolvedProp}" which was renamed or is not part of the projection. You must update the annotation value.`
|
|
15
|
+
)
|
|
16
|
+
const mapping = getTransition(target, cds.db).mapping
|
|
17
|
+
const key = [...mapping.entries()].find(({ 1: val }) => val.ref[0] === resolvedProp)
|
|
18
|
+
return key?.length && key[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isStream = query => {
|
|
22
|
+
const { _propertyAccess, target } = query
|
|
23
|
+
const element = target.elements[_propertyAccess]
|
|
24
|
+
return Boolean(element?.['@Core.MediaType'])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isStreamByDollarValue = (query, previous, last) => {
|
|
28
|
+
return query.SELECT.one && last === '$value' && !(previous in query.target.elements)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const _addMetadataProperty = (query, property, annotName, odataName) => {
|
|
32
|
+
if (typeof property[annotName] === 'object') {
|
|
33
|
+
const contentProperty = _resolveContentProperty(
|
|
34
|
+
query.target,
|
|
35
|
+
annotName,
|
|
36
|
+
property[annotName]['='].replaceAll(/\./g, '_')
|
|
37
|
+
)
|
|
38
|
+
query.target.elements[contentProperty]
|
|
39
|
+
? query.SELECT.columns.push({ ref: [contentProperty], as: odataName })
|
|
40
|
+
: LOG._warn &&
|
|
41
|
+
LOG.warn(`"${annotName.split('.')[1]}" ${contentProperty} not found in entity "${query.target.name}".`)
|
|
42
|
+
} else {
|
|
43
|
+
query.SELECT.columns.push({ val: property[annotName], as: odataName })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const addStreamMetadata = query => {
|
|
48
|
+
// new odata parser sets streaming property in SELECT.from
|
|
49
|
+
const ref = query.SELECT.columns?.[0].ref || query.SELECT.from.ref
|
|
50
|
+
const propertyName = ref[ref.length - 1]
|
|
51
|
+
let mediaTypeProperty
|
|
52
|
+
for (let key in query.target.elements) {
|
|
53
|
+
const val = query.target.elements[key]
|
|
54
|
+
if (val['@Core.MediaType'] && val.name === propertyName) {
|
|
55
|
+
mediaTypeProperty = val
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_addMetadataProperty(query, mediaTypeProperty, '@Core.MediaType', '$mediaContentType')
|
|
61
|
+
|
|
62
|
+
if (mediaTypeProperty['@Core.ContentDisposition.Filename']) {
|
|
63
|
+
_addMetadataProperty(
|
|
64
|
+
query,
|
|
65
|
+
mediaTypeProperty,
|
|
66
|
+
'@Core.ContentDisposition.Filename',
|
|
67
|
+
'$mediaContentDispositionFilename'
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (mediaTypeProperty['@Core.ContentDisposition.Type']) {
|
|
72
|
+
query.SELECT.columns.push({
|
|
73
|
+
val: mediaTypeProperty['@Core.ContentDisposition.Type'],
|
|
74
|
+
as: '$mediaContentDispositionType'
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const validateStream = (req, res, result) => {
|
|
80
|
+
// REVISIT: compat, should actually be treated as object
|
|
81
|
+
if (!Array.isArray(result)) result = [result]
|
|
82
|
+
|
|
83
|
+
// Reading one entity or a property of it should yield only a result length of one.
|
|
84
|
+
if (result.length === 0 || result[0] === undefined) {
|
|
85
|
+
if (req.headers['if-none-match']) {
|
|
86
|
+
// TODO this should probably end the request?
|
|
87
|
+
res.status(304)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
throw getError(404)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result.length > 1) throw getError(400)
|
|
94
|
+
|
|
95
|
+
if (result[0] === null) return null
|
|
96
|
+
|
|
97
|
+
result = result[0]
|
|
98
|
+
|
|
99
|
+
const headers = req.headers
|
|
100
|
+
const contentType = result.$mediaContentType
|
|
101
|
+
|
|
102
|
+
if (!headers?.accept || !contentType) return
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
!headers.accept.includes('*/*') &&
|
|
106
|
+
!headers.accept.includes(contentType) &&
|
|
107
|
+
!headers.accept.includes(contentType.split('/')[0] + '/*')
|
|
108
|
+
) {
|
|
109
|
+
throw Object.assign(new Error(`Content type "${contentType}" not listed in accept header "${headers.accept}".`), {
|
|
110
|
+
statusCode: 406
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const _ensureStream = stream => {
|
|
116
|
+
if (stream === null) return null
|
|
117
|
+
// temp workaround for url streaming
|
|
118
|
+
const stream_ = new Readable()
|
|
119
|
+
stream_.push(stream)
|
|
120
|
+
stream_.push(null)
|
|
121
|
+
return stream_
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const normalizeStream = (result, propertyName, lastPathElement, target) => {
|
|
125
|
+
let readable = result
|
|
126
|
+
if (typeof result === 'object') {
|
|
127
|
+
if (propertyName && result[propertyName] !== undefined) {
|
|
128
|
+
readable = result[propertyName]
|
|
129
|
+
}
|
|
130
|
+
// implicit streaming
|
|
131
|
+
else if (lastPathElement === '$value') {
|
|
132
|
+
const property = Object.values(target.elements).find(
|
|
133
|
+
el => el.type === 'cds.LargeBinary' && result[el.name] !== undefined
|
|
134
|
+
)
|
|
135
|
+
readable = property && result[property.name]
|
|
136
|
+
}
|
|
137
|
+
// result.value can be obtained from custom handlers
|
|
138
|
+
else if (result.value !== undefined) {
|
|
139
|
+
readable = result.value
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!(readable instanceof Readable)) {
|
|
144
|
+
readable = _ensureStream(readable)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (readable) {
|
|
148
|
+
readable.on('error', () => {
|
|
149
|
+
readable.removeAllListeners('error')
|
|
150
|
+
// readable.destroy() does not end stream in node 10 and 12
|
|
151
|
+
readable.push(null)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return readable
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const setStreamingHeaders = (result, res) => {
|
|
159
|
+
// backwards compatibility for content-type in stream
|
|
160
|
+
if (result['$mediaContentType']) res.setHeader('Content-Type', result.$mediaContentType)
|
|
161
|
+
else if (result['*@odata.mediaContentType']) res.setHeader('Content-Type', result['*@odata.mediaContentType'])
|
|
162
|
+
else res.setHeader('Content-Type', 'application/octet-stream')
|
|
163
|
+
|
|
164
|
+
if ('$mediaContentDispositionFilename' in result) {
|
|
165
|
+
const cdt = result.$mediaContentDispositionType || 'attachment'
|
|
166
|
+
res.setHeader(
|
|
167
|
+
'Content-Disposition',
|
|
168
|
+
`${cdt}; filename="${encodeURIComponent(result.$mediaContentDispositionFilename)}"`
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stream = srv =>
|
|
174
|
+
function streamHandler(req, res, next) {
|
|
175
|
+
const { _query: query } = req
|
|
176
|
+
|
|
177
|
+
const [previous, lastPathElement] = req.path.split('/').slice(-2)
|
|
178
|
+
const _isStreamByDollarValue = isStreamByDollarValue(query, previous, lastPathElement)
|
|
179
|
+
|
|
180
|
+
if (_isStreamByDollarValue) {
|
|
181
|
+
for (const k in query.target.elements) {
|
|
182
|
+
if (query.target.elements[k]['@Core.MediaType']) {
|
|
183
|
+
query.SELECT.columns = [{ ref: [k] }]
|
|
184
|
+
query._propertyAccess = k
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
query.SELECT.columns ??= ['*']
|
|
191
|
+
|
|
192
|
+
const _isStream = isStream(query) || _isStreamByDollarValue
|
|
193
|
+
|
|
194
|
+
if (!_isStream) {
|
|
195
|
+
return next(null, req, res)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!query.target['@cds.persistence.skip']) addStreamMetadata(query)
|
|
199
|
+
|
|
200
|
+
// we need the cds request, so we can access the modified query, which is cloned due to lean-draft, so we need to use dispatch here and pass a cds req
|
|
201
|
+
const cdsReq = new cds.Request({ query })
|
|
202
|
+
// for read and delete, we provide keys in req.data
|
|
203
|
+
cdsReq.data = getKeysFromPath(query.SELECT.from, srv)
|
|
204
|
+
|
|
205
|
+
// REVISIT: what is this for? some tests fail without it... we should find a better solution!
|
|
206
|
+
Object.defineProperty(query.SELECT, '_4odata', { value: true })
|
|
207
|
+
|
|
208
|
+
return srv.tx(() => {
|
|
209
|
+
return srv
|
|
210
|
+
.dispatch(cdsReq)
|
|
211
|
+
.then(async result => {
|
|
212
|
+
validateStream(req, res, result)
|
|
213
|
+
|
|
214
|
+
const stream = normalizeStream(result, query._propertyAccess, lastPathElement, query.target)
|
|
215
|
+
if (stream === null) {
|
|
216
|
+
if (req.headers['if-none-match']) {
|
|
217
|
+
return res.status(304).json({})
|
|
218
|
+
}
|
|
219
|
+
return res.status(204).json({})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setStreamingHeaders(result, res)
|
|
223
|
+
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
stream.pipe(res)
|
|
226
|
+
stream.on('end', () => resolve(result))
|
|
227
|
+
stream.once('error', reject)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
.catch(next)
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
stream,
|
|
236
|
+
isStream
|
|
237
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const cds = require('../../../')
|
|
2
|
+
const { INSERT, UPDATE } = cds.ql
|
|
3
|
+
|
|
4
|
+
const { toODataResult } = require('../utils/result')
|
|
5
|
+
const { odataError, getKeysFromPath } = require('../utils')
|
|
6
|
+
|
|
7
|
+
const { deepCopy } = require('../../_runtime/common/utils/copy')
|
|
8
|
+
|
|
9
|
+
// REVISIT: move to or rewrite in libx/odata
|
|
10
|
+
const { readAfterWrite } = require('../../_runtime/cds-services/adapter/odata-v4/utils/readAfterWrite')
|
|
11
|
+
const metaInfo = require('../../_runtime/cds-services/adapter/odata-v4/utils/metaInfo')
|
|
12
|
+
|
|
13
|
+
const _isUpsertAllowed = ({ target, data, event }) => {
|
|
14
|
+
return (
|
|
15
|
+
!(cds.env.runtime && cds.env.runtime.allow_upsert === false) &&
|
|
16
|
+
!(target && target._isDraftEnabled && (!cds.env.fiori.lean_draft || (!data.IsActiveEntity && event === 'PATCH')))
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const _isNavigationWithKeyInParent = (keys, data, pathExpression, model) => {
|
|
21
|
+
// keys not in data
|
|
22
|
+
if (keys && Object.keys(keys).some(key => key in data)) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nav = pathExpression.ref && pathExpression.ref.length !== 0 && pathExpression.ref[1]
|
|
27
|
+
const parent = pathExpression.ref && pathExpression.ref[0].id
|
|
28
|
+
|
|
29
|
+
// not a navigation
|
|
30
|
+
if (!parent || !nav) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const navID = typeof nav === 'string' ? nav : nav.id
|
|
35
|
+
const navElement = model.definitions[parent].elements[navID]
|
|
36
|
+
|
|
37
|
+
// not a containment
|
|
38
|
+
if (!navElement._isContained) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const where = pathExpression.ref[0].where
|
|
43
|
+
return parent && navElement && where
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const _hasEtag = target => target._etag
|
|
47
|
+
|
|
48
|
+
module.exports = srv =>
|
|
49
|
+
function update(req, res, next) {
|
|
50
|
+
const { _query: query } = req
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
SELECT: { one, from }
|
|
54
|
+
} = query
|
|
55
|
+
|
|
56
|
+
// REVISIT: patch on collection is allowed in odata 4.01
|
|
57
|
+
if (!one) {
|
|
58
|
+
return res.status(405).json(odataError('405', `Method ${req.method} not allowed for ENTITY.COLLECTION`))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// REVISIT: better
|
|
62
|
+
const isPropertyAccess = !!query._propertyAccess
|
|
63
|
+
|
|
64
|
+
const updateData = isPropertyAccess ? { [query._propertyAccess]: req.body.value } : deepCopy(req.body)
|
|
65
|
+
|
|
66
|
+
if (!isPropertyAccess) {
|
|
67
|
+
// add keys from url into payload (overwriting if already present)
|
|
68
|
+
Object.assign(updateData, getKeysFromPath(from, srv))
|
|
69
|
+
|
|
70
|
+
// assert complex
|
|
71
|
+
const assertOptions = { filter: true, http: { req }, mandatories: req.method === 'PUT' || undefined }
|
|
72
|
+
const errs = cds.assert(updateData, query.target, assertOptions)
|
|
73
|
+
if (errs) {
|
|
74
|
+
if (errs.length === 1) throw errs[0]
|
|
75
|
+
throw Object.assign(new Error('MULTIPLE_ERRORS'), { statusCode: 400, details: errs })
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// TODO: assert primitive
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const updateQuery = UPDATE.entity(from).with(updateData)
|
|
82
|
+
|
|
83
|
+
// we need the cds request, so we can access req._.readAfterWrite
|
|
84
|
+
const cdsReq = new cds.Request({ query: updateQuery })
|
|
85
|
+
|
|
86
|
+
// REVISIT: adjust in getter?
|
|
87
|
+
if (req.method === 'PUT') cdsReq.method = 'PUT'
|
|
88
|
+
|
|
89
|
+
// rewrite event for draft-enabled entities
|
|
90
|
+
if (query.target._isDraftEnabled) cdsReq.event = 'PATCH'
|
|
91
|
+
|
|
92
|
+
return srv
|
|
93
|
+
.dispatch(cdsReq)
|
|
94
|
+
.then(async result => {
|
|
95
|
+
if (!(isPropertyAccess && !_hasEtag(query.target)) && cdsReq._.readAfterWrite) {
|
|
96
|
+
// TODO see if in old odata impl for other checks that should happen
|
|
97
|
+
result = await readAfterWrite(cdsReq, srv, { operation: { result } })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// REVISIT: metaInfo needs original query in case of property access, but why?
|
|
101
|
+
const info = metaInfo(isPropertyAccess ? query : updateQuery, 'UPDATE', srv, result, req)
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
result == null ||
|
|
105
|
+
req._preferReturn === 'minimal' ||
|
|
106
|
+
(isPropertyAccess && result[query._propertyAccess] == null) ||
|
|
107
|
+
info.metadata.isStream
|
|
108
|
+
)
|
|
109
|
+
return res.sendStatus(204)
|
|
110
|
+
|
|
111
|
+
result = toODataResult(result, info)
|
|
112
|
+
return res.send(result)
|
|
113
|
+
})
|
|
114
|
+
.catch(async e => {
|
|
115
|
+
// UPSERT
|
|
116
|
+
const is404 = e.code === 404 || e.status === 404 || e.statusCode === 404
|
|
117
|
+
if (
|
|
118
|
+
is404 &&
|
|
119
|
+
!isPropertyAccess &&
|
|
120
|
+
_isUpsertAllowed({ target: query.target, data: updateData, event: req.method })
|
|
121
|
+
) {
|
|
122
|
+
// PUT / PATCH with if-match header means "only if already exists" -> no insert if it does not
|
|
123
|
+
if (req.headers['if-match']) throw Object.assign(new Error('412'), { statusCode: 412 })
|
|
124
|
+
|
|
125
|
+
// check only works with req.body and not with updateDate
|
|
126
|
+
if (_isNavigationWithKeyInParent(query.target.keys, req.body, from, srv.model)) {
|
|
127
|
+
// REVISIT: better error message
|
|
128
|
+
return res.status(422).json(odataError('422', `Unprocessable Entity`))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// REVISIT:
|
|
132
|
+
// can we somehow "replay" the request with POST?
|
|
133
|
+
// or should we call the create handler directly?
|
|
134
|
+
|
|
135
|
+
const insertData = deepCopy(req.body)
|
|
136
|
+
|
|
137
|
+
// add keys from url into payload (overwriting if already present)
|
|
138
|
+
Object.assign(insertData, getKeysFromPath(from, srv))
|
|
139
|
+
|
|
140
|
+
// assert payload
|
|
141
|
+
const assertOptions = { filter: true, http: { req }, mandatories: true }
|
|
142
|
+
const errs = cds.assert(insertData, query.target, assertOptions)
|
|
143
|
+
if (errs) {
|
|
144
|
+
if (errs.length === 1) throw errs[0]
|
|
145
|
+
throw Object.assign(new Error('MULTIPLE_ERRORS'), { statusCode: 400, details: errs })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// REVISIT: up_XX needs to be looked up -> composition of aspect
|
|
149
|
+
const insertQuery = INSERT.into(from).entries(insertData)
|
|
150
|
+
const cdsReq = new cds.Request({ query: insertQuery })
|
|
151
|
+
let result = await srv.dispatch(cdsReq)
|
|
152
|
+
|
|
153
|
+
if (cdsReq._.readAfterWrite) {
|
|
154
|
+
// TODO see if in old odata impl for other checks that should happen
|
|
155
|
+
result = await readAfterWrite(cdsReq, srv, { operation: { result } })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const info = metaInfo(insertQuery, 'CREATE', srv, result, req)
|
|
159
|
+
result = toODataResult(result, info)
|
|
160
|
+
return res.status(201).send(result)
|
|
161
|
+
}
|
|
162
|
+
throw e
|
|
163
|
+
})
|
|
164
|
+
.catch(next)
|
|
165
|
+
}
|