UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

198 lines (160 loc) 7.79 kB
const cds = require('../../cds') const LOG = cds.log('remote') const { convertV2PayloadData } = require('./data') const { Readable } = require('stream') /** * Normalizes server path. * * Adds / in the beginning of the path if not exists. * Removes / in the end of the path if exists. * * @param {*} path - to be normalized */ const _formatPath = (path = '') => { let formattedPath = path if (!path.startsWith('/')) formattedPath = `/${formattedPath}` if (path.endsWith('/')) formattedPath = formattedPath.substring(0, formattedPath.length - 1) return formattedPath } const _cqnWithPublicEntries = query => { return Object.fromEntries(Object.entries(query).filter(([key]) => !key.startsWith('_'))) } const _cqnToHcqlRequestConfig = cqn => { if (cqn.SELECT && cqn.SELECT.from) return { method: 'GET', data: { SELECT: _cqnWithPublicEntries(cqn.SELECT) } } if (cqn.INSERT && cqn.INSERT.into) return { method: 'POST', data: { INSERT: _cqnWithPublicEntries(cqn.INSERT) } } if (cqn.UPDATE && cqn.UPDATE.entity) return { method: 'PATCH', data: { UPDATE: _cqnWithPublicEntries(cqn.UPDATE) } } if (cqn.UPSERT && cqn.UPSERT.into) return { method: 'PUT', data: { UPSERT: _cqnWithPublicEntries(cqn.UPSERT) } } if (cqn.DELETE && cqn.DELETE.from) return { method: 'DELETE', data: { DELETE: _cqnWithPublicEntries(cqn.DELETE) } } cds.error(400, 'Invalid CQN object can not be processed.', JSON.stringify(cqn), _cqnToHcqlRequestConfig) } const _cqnToRequestConfig = (query, service, req) => { const { kind, model } = service const queryObject = cds.odata.urlify(query, { kind, model, method: req.method }) const requestConfig = { method: queryObject.method, url: queryObject.path // REVISIT: remove when we can assume that number of remote services running Okra is negligible // ugly workaround for Okra not allowing spaces in ( x eq 1 ) .replace(/\( /g, '(') .replace(/ \)/g, ')') } if (queryObject.method !== 'GET' && queryObject.method !== 'HEAD') { requestConfig.data = kind === 'odata-v2' ? convertV2PayloadData(queryObject.body, req.target) : queryObject.body } return requestConfig } const _stringToRequestConfig = (query, data, target) => { const cleanQuery = query.trim() const blankIndex = cleanQuery.substring(0, 8).indexOf(' ') const requestConfig = { method: cleanQuery.substring(0, blankIndex).toUpperCase() || 'GET', url: encodeURI(_formatPath(cleanQuery.substring(blankIndex, cleanQuery.length).trim())) } if (data && requestConfig.method !== 'GET' && requestConfig.method !== 'HEAD') { requestConfig.data = this.kind === 'odata-v2' ? Object.assign({}, convertV2PayloadData(data, target)) : data } return requestConfig } const _pathToRequestConfig = (method, path, data, target, srvName) => { let url = path if (!url.startsWith('/')) { // extract entity name and instance identifier (either in "()" or after "/") from fully qualified path const parts = path.match(/([\w.]*)([\W.]*)(.*)/) if (!parts) url = '/' + path.match(/\w*$/)[0] else if (url.startsWith(srvName)) url = '/' + parts[1].replace(srvName + '.', '') + parts[2] + parts[3] else url = '/' + parts[1].match(/\w*$/)[0] + parts[2] + parts[3] // normalize in case parts[2] already starts with / url = url.replace(/^\/\//, '/') } const requestConfig = { method, url } if (data && requestConfig.method !== 'GET' && requestConfig.method !== 'HEAD') { requestConfig.data = this.kind === 'odata-v2' ? Object.assign({}, convertV2PayloadData(data, target)) : data } return requestConfig } const _hasHeader = (headers, header) => Object.keys(headers || []) .map(k => k.toLowerCase()) .includes(header) const KINDS_SUPPORTING_BATCH = { odata: true, 'odata-v2': true, 'odata-v4': true } const _extractRequestConfig = (req, query, service) => { if (service.kind === 'hcql' && typeof query === 'object') return _cqnToHcqlRequestConfig(query) if (service.kind === 'hcql') throw new Error('The request has no query and cannot be served by HCQL remote services!') if (typeof query === 'object') return _cqnToRequestConfig(query, service, req) if (typeof query === 'string') return _stringToRequestConfig(query, req.data, req.target) //> no model, no service.definition return _pathToRequestConfig(req.method, req.path, req.data, req.target, service.definition?.name || service.namespace) } const _batchEnvelope = (requestConfig, maxGetUrlLength) => { if (LOG._debug) { LOG.debug( `URL of remote request exceeds the configured max length of ${maxGetUrlLength}. Converting it to a $batch request.` ) } const batchedRequest = [ '--batch1', 'Content-Type: application/http', 'Content-Transfer-Encoding: binary', '', `${requestConfig.method} ${requestConfig.url.replace(/^\//, '')} HTTP/1.1`, ...Object.keys(requestConfig.headers).map(k => `${k}: ${requestConfig.headers[k]}`), '', '', '--batch1--', '' ].join('\r\n') requestConfig.method = 'POST' requestConfig.url = '/$batch' requestConfig.headers['accept'] = 'multipart/mixed' requestConfig.headers['content-type'] = 'multipart/mixed; boundary=batch1' requestConfig._autoBatchedGet = true requestConfig.data = batchedRequest } module.exports.extractRequestConfig = (req, query, service) => { const requestConfig = _extractRequestConfig(req, query, service) if (service.kind === 'odata-v2' && req.event === 'READ' && requestConfig.url?.match(/(\/any\()|(\/all\()/)) { req.reject(501, 'Lambda expressions are not supported in OData v2') } requestConfig.url = _formatPath(requestConfig.url) requestConfig.headers = { accept: 'application/json,text/plain' } // Forward the locale properties from the original request (including region variants or weight factors) if (!_hasHeader(req.headers, 'accept-language') && req._locale) { // If not given, it's taken from the user's locale (normalized and simplified) requestConfig.headers['accept-language'] = req._locale } // Forward all dwc-* headers if (service.options.forward_dwc_headers) { const originalHeaders = req.context?.http.req.headers || {} for (const k in originalHeaders) if (k.match(/^dwc-/)) requestConfig.headers[k] = originalHeaders[k] } // Ensure stream request body & appropriate content headers if ( requestConfig.data && requestConfig.method !== 'GET' && requestConfig.method !== 'HEAD' && !(requestConfig.data instanceof Readable) ) { if (typeof requestConfig.data === 'object' && !Buffer.isBuffer(requestConfig.data)) { requestConfig.headers['content-type'] = 'application/json' requestConfig.headers['content-length'] = Buffer.byteLength(JSON.stringify(requestConfig.data)) } else if (typeof requestConfig.data === 'string') { requestConfig.headers['content-length'] = Buffer.byteLength(requestConfig.data) } else if (Buffer.isBuffer(requestConfig.data)) { requestConfig.headers['content-length'] = Buffer.byteLength(requestConfig.data) if (!_hasHeader(req.headers, 'content-type')) requestConfig.headers['content-type'] = 'application/octet-stream' } } // Add batch envelope if needed const maxGetUrlLength = service.options.max_get_url_length ?? cds.env.remote?.max_get_url_length ?? 1028 if ( requestConfig.method === 'GET' && KINDS_SUPPORTING_BATCH[service.kind] && requestConfig.url.length > maxGetUrlLength ) { _batchEnvelope(requestConfig, maxGetUrlLength) } if (service.path) requestConfig.url = `${encodeURI(service.path)}${requestConfig.url}` // Set axios responseType to 'arraybuffer' if returning binary in rest if (req._binary) requestConfig.responseType = 'arraybuffer' return requestConfig }