@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
306 lines (250 loc) • 12.6 kB
JavaScript
const cds = require('../cds')
const { getCloudSdk, getCloudSdkConnectivity, getCloudSdkResilience } = require('./utils/cloudSdkProvider')
const { run } = require('./utils/client')
const { extractRequestConfig } = require('./utils/query')
const { hasAliasedColumns } = require('./utils/data')
const postProcess = require('../common/utils/postProcess')
const { formatVal } = require('../../odata/utils')
const _getHeaders = (defaultHeaders, req) => {
const normalizedHeaders = Object.keys(req.headers).reduce((acc, cur) => {
acc[cur.toLowerCase()] = req.headers[cur]
return acc
}, {})
return { ...defaultHeaders, ...normalizedHeaders }
}
const _setCorrectValue = (el, data, params, kind) => {
if (data[el] === undefined) return "'undefined'"
return typeof data[el] === 'object' && kind !== 'odata-v2'
? JSON.stringify(data[el])
: formatVal(data[el], el, { elements: params }, kind)
}
// v4: builds url like /function(p1=@p1,p2=@p2,p3=@p3)?@p1=val&@p2={...}&@p3=[...]
// v2: builds url like /function?p1=val1&p2=val2 for functions and actions
const _buildPartialUrlFunctions = (url, data, params, kind = 'odata-v4') => {
const funcParams = []
const queryOptions = []
// REVISIT: take params from params after importer fix (the keys should not be part of params)
for (const param in _extractParamsFromData(data, params)) {
if (data[param] === undefined) continue
if (kind === 'odata-v2') {
funcParams.push(`${param}=${_setCorrectValue(param, data, params, kind)}`)
} else {
funcParams.push(`${param}=@${param}`)
queryOptions.push(`@${param}=${_setCorrectValue(param, data, params, kind)}`)
}
}
return kind === 'odata-v2'
? `${url}?${funcParams.join('&')}`
: `${url}(${funcParams.join(',')})?${queryOptions.join('&')}`
}
const _extractParamsFromData = (data, params = {}) => {
return Object.keys(data).reduce((res, el) => {
if (params[el]) Object.assign(res, { [el]: data[el] })
return res
}, {})
}
const _buildKeys = (req, kind) => {
const keys = []
if (req.params && req.params.length > 0) {
const p1 = req.params[0]
if (typeof p1 !== 'object') return [p1]
for (const key in req.target.keys) {
keys.push(`${key}=${formatVal(p1[key], key, req.target, kind)}`)
}
} else {
// REVISIT: shall we keep that or remove it?
for (const key in req.target.keys) {
keys.push(`${key}=${formatVal(req.data[key], key, req.target, kind)}`)
}
}
return keys
}
const _handleBoundActionFunction = (srv, def, req, url) => {
if (def.kind === 'action') {
return srv.post(url, def.params ? _extractParamsFromData(req.data, def.params) : {}, req.headers)
}
if (def.params) {
const data = _extractParamsFromData(req.data, def.params)
url = _buildPartialUrlFunctions(url, data, def.params)
} else {
url = `${url}()`
}
return srv.get(url, {}, req.headers)
}
const _handleUnboundActionFunction = (srv, def, req, event) => {
if (def.kind === 'action') {
// REVISIT: only for "rest" unbound actions/functions, we enforce axios to return a buffer
// required by cds-mt
const isBinary = srv.kind === 'rest' && def?.returns?.type?.match(/binary/i)
const { headers, data } = req
return srv.send({ method: 'POST', path: `/${event}`, headers, data, _binary: isBinary })
}
const url =
Object.keys(req.data).length > 0 ? _buildPartialUrlFunctions(`/${event}`, req.data, def.params) : `/${event}()`
return srv.get(url, {}, req.headers)
}
const _sendV2RequestActionFunction = (srv, def, req, url) => {
const { headers } = req
return def.kind === 'function'
? srv.send({ method: 'GET', path: url, headers, _returnType: def.returns })
: srv.send({ method: 'POST', path: url, headers, data: {}, _returnType: def.returns })
}
const _handleV2ActionFunction = (srv, def, req, event, kind) => {
const url =
Object.keys(req.data).length > 0 ? _buildPartialUrlFunctions(`/${event}`, req.data, def.params, kind) : `/${event}`
return _sendV2RequestActionFunction(srv, def, req, url)
}
const _handleV2BoundActionFunction = (srv, def, req, event, kind) => {
const params = []
const data = req.data
// REVISIT: take params from def.params, after importer fix (the keys should not be part of params)
for (const param in _extractParamsFromData(req.data, def.params)) {
params.push(`${param}=${formatVal(data[param], param, { elements: def.params }, kind)}`)
}
const keys = _buildKeys(req, this.kind)
if (keys.length === 1 && typeof req.params[0] !== 'object') {
params.push(`${Object.keys(req.target.keys)[0]}=${keys[0]}`)
} else {
params.push(...keys)
}
const url = `${`/${event}`}?${params.join('&')}`
return _sendV2RequestActionFunction(srv, def, req, url)
}
const _addHandlerActionFunction = (srv, def, target) => {
const event = def.name.match(/\w*$/)[0]
if (target) {
srv.on(event, target, async function (req) {
const shortEntityName = req.target.name.replace(`${this.definition.name}.`, '')
if (this.kind === 'odata-v2') return _handleV2BoundActionFunction(srv, def, req, event, this.kind)
const url = `/${shortEntityName}(${_buildKeys(req, this.kind).join(',')})/${this.definition.name}.${event}`
return _handleBoundActionFunction(srv, def, req, url)
})
} else {
srv.on(event, async function (req) {
if (this.kind === 'odata-v2') return _handleV2ActionFunction(srv, def, req, event, this.kind)
return _handleUnboundActionFunction(srv, def, req, event)
})
}
}
const _isSelectWithAliasedColumns = q => q?.SELECT && !q._transitions && q.SELECT.columns?.some(hasAliasedColumns)
const resolvedTargetOfQuery = q => q?._transitions?.at(-1)?.target
const _resolveSelectionStrategy = selectionStrategy => {
const { DestinationSelectionStrategies } = getCloudSdkConnectivity()
const strategy = DestinationSelectionStrategies[selectionStrategy]
if (typeof strategy !== 'function') cds.error`Unsupported destination selection strategy "${selectionStrategy}".`
return strategy
}
const _getKind = options => {
let kind = options.credentials?.kind ?? options.kind
if (typeof kind === 'object') {
kind = Object.keys(kind).find(
key => key === 'odata' || key === 'odata-v4' || key === 'odata-v2' || key === 'rest' || key === 'hcql'
)
}
// odata-v4 is equivalent of odata
return kind === 'odata-v4' ? 'odata' : kind
}
const _getDestination = (name, credentials) => {
// Cloud SDK wants property "queryParameters" but we have documented "queries"
if (credentials.queries && !credentials.queryParameters) credentials.queryParameters = credentials.queries
return { name, ...credentials }
}
class RemoteService extends cds.Service {
init() {
if (([...this.entities].length || [...this.operations].length) && !this.options.credentials)
cds.error`No credentials configured for "${this.name}".`
this.kind = _getKind(this.options) // TODO: Simplify
if (this.options.credentials) {
this.datasource = this.options.datasource
this.destinationOptions = this.options.destinationOptions || {}
if (typeof this.destinationOptions.selectionStrategy === 'string') {
this.destinationOptions.selectionStrategy = _resolveSelectionStrategy(this.destinationOptions.selectionStrategy)
}
if (this.options.credentials.destination) this.destination = this.options.credentials.destination
else this.destination = _getDestination(this.definition?.name ?? this.datasource, this.options.credentials)
this.path = this.options.credentials.path
// API `requestTimeout` is kept because it once was public
this.requestTimeout = this.options.credentials.requestTimeout ?? 60000
this.csrf = this.options.csrf
this.csrfInBatch = this.options.csrfInBatch
// We're using this as an object to allow remote services without the need for Cloud SDK
// This is required for BAS creating remote services only for events
// Middlewares are created at time of the first request, on the fly
this.middlewares = {
timeout: getCloudSdkResilience().timeout(this.requestTimeout),
csrf: this.csrf && getCloudSdk().csrf(this.csrf)
}
}
// Add 'initial' handler to clear keys from data
const clearKeysFromData = function (req) {
if (req.target && req.target.keys) for (const k of Object.keys(req.target.keys)) delete req.data[k]
}
this.before('UPDATE', '*', Object.assign(clearKeysFromData, { _initial: true }))
// Add handlers for bound & unbound operations
for (const each of this.entities)
for (const a in each.actions) _addHandlerActionFunction(this, each.actions[a], each)
for (const each of this.operations) _addHandlerActionFunction(this, each)
// IMPORTANT: regular function is used on purpose, don't switch to arrow function.
this.on('*', async function on_handler(req, next) {
const { query } = req
if (!query && !(typeof req.path === 'string')) return next()
// Early validation on first request for use case without remote API
// Ideally, that's done on bootstrap of the remote service
if (typeof this.destination === 'object' && !this.destination.url)
cds.error`"url" or "destination" property must be configured in "credentials" of "${this.name}".`
const requestConfig = extractRequestConfig(req, query, this)
requestConfig.headers = _getHeaders(requestConfig.headers, req)
// REVISIT: we should not have to set the content-type at all for that
if (requestConfig.headers.accept?.match(/stream|image|tar/)) requestConfig.responseType = 'stream'
// Ensure request correlation (even with systems that use x-correlationid)
const correlationId = requestConfig.headers['x-correlation-id'] || cds.context?.id
requestConfig.headers['x-correlation-id'] = correlationId
requestConfig.headers['x-correlationid'] = correlationId
// Compile Cloud SDK options
const additionalOptions = {
kind: this.kind,
destination: this.destination,
destinationOptions: this.destinationOptions,
resolvedTarget:
resolvedTargetOfQuery(query) ||
(typeof query === 'object' ? cds.infer.target(query) || query?._target : undefined) ||
req.target,
returnType: req._returnType
}
// REVISIT: i don't believe req.context.headers is an official API
let jwt = req?.context?.headers?.authorization?.split(/^bearer /i)[1]
if (!jwt) jwt = req?.context?.http?.req?.headers?.authorization?.split(/^bearer /i)[1]
if (jwt) additionalOptions.jwt = jwt
// Mount resilience and csrf middlewares for SAP Cloud SDK
requestConfig.middleware = [this.middlewares.timeout]
const fetchCsrfToken = !!(requestConfig._autoBatchedGet ? this.csrfInBatch : this.csrf)
if (fetchCsrfToken) requestConfig.middleware.push(this.middlewares.csrf)
// Hidden compat flag to suppress logging the response body of failed request
if (req._suppressRemoteResponseBody) {
additionalOptions.suppressRemoteResponseBody = req._suppressRemoteResponseBody
}
let result = await run(requestConfig, additionalOptions)
result = typeof query === 'object' && query.SELECT?.one && Array.isArray(result) ? result[0] : result
return result
})
return super.init()
}
// Overload .handle in order to resolve projections up to a definition that is known by the remote service instance.
// Result is post processed according to the inverse projection in order to reflect the correct result of the original query.
async handle(req) {
if (!this._requires_resolving(req))
return super.handle(req).then(
// we need to post process if alias was explicitly set in query
result => (_isSelectWithAliasedColumns(req.query) ? postProcess(req.query, result, this, true) : result)
)
// rewrite the query to a target entity served by this service...
const query = this.resolve(req.query)
if (!query) cds.error`Target ${req.target.name} cannot be resolved for service ${this.name}`
const target = query._target || req.target
// we need to provide target explicitly because it's cached within ensure_target
const _req = new cds.Request({ query, target, _resolved: true, headers: req.headers, method: req.method })
return await super.dispatch(_req).then(result => postProcess(query, result, this, true))
}
}
RemoteService.prototype.isExternal = true
module.exports = RemoteService