UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

306 lines (250 loc) 12.6 kB
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