UNPKG

@cap-js-community/odata-v2-adapter

Version:
1,391 lines (1,302 loc) 179 kB
"use strict"; // Suppress deprecation warning in Node 22 due to http-proxy using util._extend() require("util")._extend = Object.assign; // OData V2/V4 Delta: http://docs.oasis-open.org/odata/new-in-odata/v4.0/cn01/new-in-odata-v4.0-cn01.html const os = require("os"); const fs = require("fs"); const fsPath = require("path"); const URL = require("url"); const { pipeline } = require("stream"); const express = require("express"); const expressFileUpload = require("express-fileupload"); const cds = require("@sap/cds"); const { promisify } = require("util"); const { createProxyMiddleware } = require("http-proxy-middleware"); const bodyParser = require("body-parser"); require("body-parser-xml")(bodyParser); const xml2js = require("xml2js"); const xmlParser = new xml2js.Parser({ async: false, tagNameProcessors: [xml2js.processors.stripPrefix], }); const cacheSymbol = Symbol("cov2ap"); const CACHE_DIR = fs.realpathSync(os.tmpdir()); const SeverityMap = { 1: "success", 2: "info", 3: "warning", 4: "error", }; // Support HANA's SYSUUID, which does not conform to real UUID formats const UUIDLikeRegex = /guid'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/gi; // https://www.w3.org/TR/xmlschema11-2/#nt-duDTFrag const DurationRegex = /^P(?:(\d)Y)?(?:(\d{1,2})M)?(?:(\d{1,2})D)?T(?:(\d{1,2})H)?(?:(\d{2})M)?(?:(\d{2}(?:\.\d+)?)S)?$/i; // Unsupported Draft Filter const UnsupportedDraftFilterRegex = /\(IsActiveEntity eq true and (.*?)\) or \(IsActiveEntity eq false and \((.*?) or HasActiveEntity eq false\)\)/; // https://cap.cloud.sap/docs/cds/types const DataTypeMap = { "cds.UUID": { v2: `guid'$1'`, v4: UUIDLikeRegex }, // "cds.Boolean" - no transformation // "cds.UInt8" - no transformation // "cds.Int16" - no transformation // "cds.Int32" - no transformation // "cds.Integer" - no transformation "cds.Int64": { v2: `$1L`, v4: /([-]?[0-9]+?)L/gi }, "cds.Integer64": { v2: `$1L`, v4: /([-]?[0-9]+?)L/gi }, "cds.Decimal": { v2: `$1m`, v4: /([-]?[0-9]+?\.?[0-9]*)m/gi }, "cds.DecimalFloat": { v2: `$1f`, v4: /([-]?[0-9]+?\.?[0-9]*)f/gi }, "cds.Double": { v2: `$1d`, v4: /([-]?[0-9]+?\.?[0-9]*(?:E[+-]?[0-9]+?)?)d/gi }, "cds.Date": { v2: `datetime'$1'`, v4: /datetime'(.+?)'/gi }, "cds.Time": { v2: `time'$1'`, v4: /time'(.+?)'/gi }, "cds.DateTime": { v2: `datetimeoffset'$1'`, v4: /datetime(?:offset)?'(.+?)'/gi }, "cds.Timestamp": { v2: `datetimeoffset'$1'`, v4: /datetime(?:offset)?'(.+?)'/gi }, "cds.String": { v2: `'$1'`, v4: /(.*)/gis }, "cds.Binary": { v2: `binary'$1'`, v4: /X'(?:[0-9a-f][0-9a-f])+?'/gi }, "cds.LargeBinary": { v2: `binary'$1'`, v4: /X'(?:[0-9a-f][0-9a-f])+?'/gi }, "cds.LargeString": { v2: `'$1'`, v4: /(.*)/gis }, }; // https://www.odata.org/documentation/odata-version-2-0/overview/ (6. Primitive Data Types) // https://cap.cloud.sap/docs/advanced/odata#type-mapping const DataTypeOData = { Binary: "cds.Binary", Boolean: "cds.Boolean", Byte: "cds.UInt8", DateTime: "cds.DateTime", Decimal: "cds.Decimal", Double: "cds.Double", Single: "cds.Double", Guid: "cds.UUID", Int16: "cds.Int16", Int32: "cds.Integer", Int64: "cds.Integer64", SByte: "cds.Integer", String: "cds.String", Time: "cds.Time", DateTimeOffset: "cds.Timestamp", Date: "cds.Date", TimeOfDay: "cds.Time", _Decimal: "cds.DecimalFloat", _Binary: "cds.LargeBinary", _String: "cds.LargeString", }; // https://cap.cloud.sap/docs/advanced/odata#type-mapping const ODataType = { "cds.UUID": "Edm.Guid", "cds.Boolean": "Edm.Boolean", "cds.UInt8": "Edm.Byte", "cds.Int16": "Edm.Int16", "cds.Int32": "Edm.Int32", "cds.Integer": "Edm.Int32", "cds.Int64": "Edm.Int64", "cds.Integer64": "Edm.Int64", "cds.Decimal": "Edm.Decimal", "cds.DecimalFloat": "Edm.Decimal", "cds.Double": "Edm.Double", "cds.Date": "Edm.DateTime", "cds.Time": "Edm.Time", "cds.DateTime": "Edm.DateTime", "cds.Timestamp": "Edm.DateTimeOffset", "cds.String": "Edm.String", "cds.Binary": "Edm.Binary", "cds.LargeBinary": "Edm.Binary", "cds.LargeString": "Edm.String", }; const AggregationMap = { SUM: "sum", MIN: "min", MAX: "max", AVG: "average", COUNT: "$count", COUNT_DISTINCT: "countdistinct", NONE: "none", NOP: "nop", }; const DefaultAggregation = AggregationMap.SUM; const FilterFunctions = { "substringof($,$)": "contains($2,$1)", "gettotaloffsetminutes($)": "totaloffsetminutes($1)", }; const FilterFunctionsCaseInsensitive = { "substringof($,$)": "contains(tolower($2),tolower($1))", "startswith($,$)": "startswith(tolower($1),tolower($2))", "endswith($,$)": "endswith(tolower($1),tolower($2))", }; const ProcessingDirection = { Request: "req", Response: "res", }; const DefaultHost = "localhost"; const DefaultPort = 4004; const DefaultTenant = "00000000-0000-0000-0000-000000000000"; const AggregationPrefix = "__AGGREGATION__"; const IEEE754Compatible = "IEEE754Compatible=true"; const MessageTargetTransient = "/#TRANSIENT#"; const MessageTargetTransientPrefix = `${MessageTargetTransient}/`; function convertToNodeHeaders(webHeaders) { return Array.from(webHeaders.entries()).reduce((result, [key, value]) => { result[key] = value; return result; }, {}); } /** * The OData V2 adapter for CDS instantiates an Express router. The following options are available: * @param {object} [options] OData V2 adapter for CDS options object. * @param {string} [options.base] Base path under which the service is reachable. Default is ''. * @param {string} [options.path] Path under which the service is reachable. Default is `'odata/v2'`. Default path is `'v2'` for CDS <7 or `middlewares` deactivated. * @param {string|string[]|object} [options.model] CDS service model (path(s) or CSN). Default is 'all'. * @param {number} [options.port] Target port which points to OData V4 backend port. Default is process.env.PORT or 4004. * @param {string} [options.target] Target which points to OData V4 backend host:port. Use 'auto' to infer the target from server url after listening. Default is e.g. 'auto'. * @param {string} [options.targetPath] Target path to which is redirected. Default is `'odata/v4'`. Default path is `''` for CDS <7 or `middlewares` deactivated. * @param {object} [options.services] Service mapping object from url path name to service name. Default is {}. * @param {boolean} [options.mtxRemote] CDS model is retrieved remotely via MTX endpoint for multitenant scenario (classic MTX only). Default is false. * @param {string} [options.mtxEndpoint] Endpoint to retrieve MTX metadata when option 'mtxRemote' is active (classic MTX only). Default is '/mtx/v1'. * @param {boolean} [options.ieee754Compatible] Edm.Decimal and Edm.Int64 are serialized IEEE754 compatible. Default is true. * @param {number} [options.fileUploadSizeLimit] File upload file size limit (in bytes) for multipart/form-data requests. Default is 10485760 (10 MB). * @param {boolean} [options.continueOnError] Indicates to OData V4 backend to continue on error. Default is false. * @param {boolean} [options.isoTime] Use ISO 8601 format for type cds.Time (Edm.Time). Default is false. * @param {boolean} [options.isoDate] Use ISO 8601 format for type cds.Date (Edm.DateTime). Default is false. * @param {boolean} [options.isoDateTime] Use ISO 8601 format for type cds.DateTime (Edm.DateTimeOffset). Default is false. * @param {boolean} [options.isoTimestamp] Use ISO 8601 format for type cds.Timestamp (Edm.DateTimeOffset). Default is false. * @param {boolean} [options.isoDateTimeOffset] Use ISO 8601 format for type Edm.DateTimeOffset (cds.DateTime, cds.Timestamp). Default is false. * @param {string} [options.bodyParserLimit] Request and response body parser size limit. Default is '100mb'. * @param {boolean} [options.returnCollectionNested] Collection of entity type is returned nested into a results section. Default is true. * @param {boolean} [options.returnComplexNested] Function import return structure of complex type (non collection) is nested using function import name. Default is true. * @param {boolean} [options.returnPrimitiveNested] Function import return structure of primitive type (non collection) is nested using function import name. Default is true. * @param {boolean} [options.returnPrimitivePlain] Function import return value of primitive type is rendered as plain JSON value. Default is true. * @param {string} [options.messageTargetDefault] Specifies the message target default, if target is undefined. Default is '/#TRANSIENT#'. * @param {boolean} [options.caseInsensitive] Transforms search functions i.e. substringof, startswith, endswith to case-insensitive variant. Default is false. * @param {boolean} [options.propagateMessageToDetails] Propagates root error or message always to details section. Default is false. * @param {boolean} [options.contentDisposition] Default content disposition for media streams (inline, attachment), if not available or calculated. Default is 'attachment'. * @param {boolean} [options.calcContentDisposition] Calculate content disposition for media streams even if already available. Default is false. * @param {boolean} [options.quoteSearch] Specifies if search expression is quoted automatically. Default is true. * @param {boolean} [options.fixDraftRequests] Specifies if unsupported draft requests are converted to a working version. Default is false. * @param {string} [options.changesetDeviationLogLevel] Log level of batch changeset content-id deviation logs (none, debug, info, warn, error). Default is 'info'. * @param {string} [options.defaultFormat] Specifies the default entity response format (json, atom). Default is 'json'. * @param {boolean} [options.processForwardedHeaders] Specifies if 'x-forwarded' headers are processed. Default is true. * @param {boolean} [options.cacheDefinitions] Specifies if the definition elements are cached. Default is true. * @param {string} [options.cacheMetadata] Specifies the caching and provisioning strategy of metadata (e.g. edmx) (memory, disk, stream). Default is 'memory'. * @param {string} [options.registerOnListening] Routes are registered on CDS `listening` event instead of registering routes immediately. Default is true. * @param {boolean} [options.excludeNonSelectedKeys] Excludes non-selected keys from entity response (OData V4 auto-includes keys). Default is 'false'. * @returns {express.Router} OData V2 adapter for CDS Express Router */ function cov2ap(options = {}) { if (cov2ap._singleton) { return cov2ap._singleton; } const router = express.Router(); const optionWithFallback = (name, fallback) => { if (options && Object.prototype.hasOwnProperty.call(options, name)) { return options[name]; } if (cds.env.cov2ap && Object.prototype.hasOwnProperty.call(cds.env.cov2ap, name)) { return cds.env.cov2ap[name]; } const scName = name.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`); if (cds.env.cov2ap && Object.prototype.hasOwnProperty.call(cds.env.cov2ap, scName)) { return cds.env.cov2ap[scName]; } const acName = name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); if (cds.env.cov2ap && Object.prototype.hasOwnProperty.call(cds.env.cov2ap, acName)) { return cds.env.cov2ap[acName]["undefined"]; } return fallback; }; let oDataV2Path = "v2"; let oDataV4Path = ""; const oDataProtocolPrefixActive = parseInt(cds.version, 10) >= 7 && cds.env.requires.middlewares; if (oDataProtocolPrefixActive) { oDataV2Path = "odata/v2"; oDataV4Path = "odata/v4"; if (cds.env.protocols) { if (cds.env.protocols["odata-v2"]) { oDataV2Path = cds.env.protocols["odata-v2"].path; } if (cds.env.protocols.odata) { oDataV4Path = cds.env.protocols.odata.path; } else if (cds.env.protocols["odata-v4"]) { oDataV4Path = cds.env.protocols["odata-v4"].path; } } } const oDataV2RelativePath = oDataV2Path.replace(/^\//, ""); const oDataV4RelativePath = oDataV4Path.replace(/^\//, ""); const metadataCache = {}; const base = optionWithFallback("base", ""); const path = optionWithFallback("path", oDataV2RelativePath); const sourcePath = `${base ? "/" + base : ""}/${path}`; const targetPath = optionWithFallback("targetPath", oDataV4RelativePath); const rewritePath = `${base ? "/" + base : ""}${targetPath ? "/" : ""}${targetPath}`; let port = optionWithFallback("port", process.env.PORT || DefaultPort); const defaultTarget = `http://${DefaultHost}:${port}`; let target = optionWithFallback("target", "auto"); const services = optionWithFallback("services", {}); const mtxRemote = optionWithFallback("mtxRemote", false); const mtxEndpoint = optionWithFallback("mtxEndpoint", "/mtx/v1"); const ieee754Compatible = optionWithFallback("ieee754Compatible", true); const fileUploadSizeLimit = optionWithFallback("fileUploadSizeLimit", 10 * 1024 * 1024); const continueOnError = optionWithFallback("continueOnError", false); const isoTime = optionWithFallback("isoTime", false); const isoDate = optionWithFallback("isoDate", false); const isoDateTime = optionWithFallback("isoDateTime", false); const isoTimestamp = optionWithFallback("isoTimestamp", false); const isoDateTimeOffset = optionWithFallback("isoDateTimeOffset", false); const bodyParserLimit = optionWithFallback("bodyParserLimit", "100mb"); const returnCollectionNested = optionWithFallback("returnCollectionNested", true); const returnComplexNested = optionWithFallback("returnComplexNested", true); const returnPrimitiveNested = optionWithFallback("returnPrimitiveNested", true); const returnPrimitivePlain = optionWithFallback("returnPrimitivePlain", true); const messageTargetDefault = optionWithFallback("messageTargetDefault", MessageTargetTransient); const caseInsensitive = optionWithFallback("caseInsensitive", false); const propagateMessageToDetails = optionWithFallback("propagateMessageToDetails", false); const contentDisposition = optionWithFallback("contentDisposition", "attachment"); const calcContentDisposition = optionWithFallback("calcContentDisposition", false); const quoteSearch = optionWithFallback("quoteSearch", true); const fixDraftRequests = optionWithFallback("fixDraftRequests", false); const changesetDeviationLogLevel = optionWithFallback("changesetDeviationLogLevel", "info"); const defaultFormat = optionWithFallback("defaultFormat", "json"); const processForwardedHeaders = optionWithFallback("processForwardedHeaders", true); const cacheDefinitions = optionWithFallback("cacheDefinitions", true); const cacheMetadata = optionWithFallback("cacheMetadata", "memory"); const registerOnListening = optionWithFallback("registerOnListening", true); const excludeNonSelectedKeys = optionWithFallback("excludeNonSelectedKeys", false); if (cds.env.protocols) { cds.env.protocols["odata-v2"] = { path: sourcePath, impl: __filename, }; } if (caseInsensitive) { Object.assign(FilterFunctions, FilterFunctionsCaseInsensitive); } const fileUpload = expressFileUpload({ abortOnLimit: true, limits: { files: 1, fileSize: fileUploadSizeLimit, }, }); let model = optionWithFallback("model", "all"); if (Array.isArray(model)) { model = model.map((entry) => (entry === "all" ? "*" : entry)); } else { model = model === "all" ? "*" : model; } model = cds.resolve(model); async function clearMetadataCache(tenant) { if (metadataCache[tenant]) { const tenantCache = metadataCache[tenant]; delete metadataCache[tenant]; if (cacheDefinitions) { const csn = await callCached(tenantCache, "csn"); if (csn) { for (const name in csn.definitions) { const definition = csn.definitions[name]; delete definition[cacheSymbol]; } } } } } cds.on("serving", (service) => { const isOData = isServedViaOData(service); if (!isOData) { return; } const odataV2Path = serviceODataV2Path(service); const odataV4Path = serviceODataV4Path(service); const protocolPath = `${sourcePath}${sourceServicePath(odataV4Path)}`; let endpointPath = protocolPath; // Protocol re-routing if (odataV2Path && protocolPath !== odataV2Path) { endpointPath = endpointPath.replace(protocolPath, odataV2Path); router.all([`${odataV2Path}`, `${odataV2Path}/*`], (req, res, next) => { req.url = req.url.replace(odataV2Path, protocolPath); req.originalUrl = req.url; req.endpointRewrite = (url) => { return url.replace(protocolPath, odataV2Path); }; next(); }); } const provider = (entity, endpoint) => { if (endpoint && !endpoint.kind.startsWith("odata")) { return; } const href = `${endpointPath}/${entity || "$metadata"}`; return { href, name: `${entity || "$metadata"} (V2)`, title: "OData V2" }; }; service.$linkProviders = service.$linkProviders || []; service.$linkProviders.push(provider); }); if (cds.mtx && cds.mtx.eventEmitter) { cds.mtx.eventEmitter.on(cds.mtx.events.TENANT_UPDATED, async (tenant) => { try { await clearMetadataCache(tenant); } catch (err) { logError({ tenant }, "Cache", err); } }); } cds.on("cds.xt.TENANT_UPDATED", async ({ tenant }) => { try { await clearMetadataCache(tenant); } catch (err) { logError({ tenant }, "Cache", err); } }); async function routeInitRequest(req, res, next) { req.now = new Date(); req.contextId = req.headers["x-correlation-id"] || req.headers["x-correlationid"] || req.headers["x-request-id"] || req.headers["x-vcap-request-id"] || cds.utils.uuid(); res.set("x-request-id", req.contextId); res.set("x-correlation-id", req.contextId); res.set("x-correlationid", req.contextId); try { // Authorization is validated within target request (-> simple decode) const [authType, token] = (req.headers.authorization && req.headers.authorization.split(" ")) || []; if (authType && token) { let jwtBody; switch (authType) { case "Basic": req.user = { id: decodeBase64(token).split(":")[0], }; if (req.user.id && cds.env.requires.auth && ["basic", "mocked"].includes(cds.env.requires.auth.kind)) { const user = (cds.env.requires.auth.users || {})[req.user.id]; req.tenant = user && (user.tenant || (user.jwt && user.jwt.zid)); } break; case "Bearer": jwtBody = decodeJwtTokenBody(token); req.user = { id: jwtBody.user_name || jwtBody.client_id, }; req.tenant = jwtBody.zid; if (!req.authInfo) { req.authInfo = { getSubdomain: () => { return jwtBody.ext_attr && jwtBody.ext_attr.zdn; }, }; } break; } } } catch (err) { logError(req, "Authorization", err); } if (req.tenant) { req.tenant = String(req.tenant); } if (["constructor", "prototype", "__proto__"].includes(req.tenant)) { logWarn(req, "Authorization", "Invalid tenant", { tenant: req.tenant }); req.tenant = undefined; } next(); } async function routeGetMetadata(req, res) { let serviceValid = true; const urlPath = targetUrl(req.originalUrl); try { const metadataUrl = URL.parse(urlPath, true); let metadataPath = metadataUrl.pathname.substring(0, metadataUrl.pathname.length - 9); const { csn } = await getMetadata(req); req.csn = csn; const service = serviceFromRequest(req); if (service.absolute && metadataPath.startsWith(`/${targetPath}`)) { metadataPath = metadataPath.substring(targetPath.length + 1); } const serviceUrl = target + metadataPath; // Trace traceRequest(req, "Request", req.method, urlPath, req.headers, req.body); traceRequest(req, "ProxyRequest", req.method, metadataPath, req.headers, req.body); const result = await Promise.all([ fetch(serviceUrl, { method: "GET", headers: { ...propagateHeaders(req), accept: "application/json", }, }), (async () => { if (service && service.name) { serviceValid = service.valid; const { edmx } = await getMetadata(req, service.name); return edmx; } })(), ]); const [metadataResponse, edmx] = result; const metadataBody = await metadataResponse.text(); const headers = convertBasicHeaders(convertToNodeHeaders(metadataResponse.headers)); delete headers["content-encoding"]; let body; if (metadataResponse.ok || metadataResponse.status === 304) { headers["content-type"] = "application/xml"; if (cacheMetadata === "disk") { body = await fs.promises.readFile(edmx, "utf8"); } else if (cacheMetadata === "stream") { body = fs.createReadStream(edmx); } else { body = edmx; } } else { body = metadataBody; } setContentLength(headers, body); // Trace traceResponse( req, "Proxy Response", metadataResponse.status, metadataResponse.statusMessage, headers, metadataBody, ); respond(req, res, metadataResponse.status, headers, body); } catch (err) { if (serviceValid) { // Error if (err.statusCode === 400) { logWarn(req, "MetadataRequest", err); } else { logError(req, "MetadataRequest", err); } // Trace logWarn(req, "MetadataRequest", "Request with Error", { method: req.method, url: urlPath, target, }); if (err.statusCode) { res.status(err.statusCode).send(err.message); } else if (err.message === "fetch failed") { res.status(400).send(err.message); } else { res.status(500).send("Internal Server Error"); } } else { res.status(404).send("Not Found"); } } } async function routeBodyParser(req, res, next) { const contentType = req.header("content-type"); if (!contentType) { return next(); } if (isApplicationJSON(contentType)) { express.json({ limit: bodyParserLimit })(req, res, next); } else if (isXML(contentType)) { bodyParser.xml({ limit: bodyParserLimit, xmlParseOptions: { tagNameProcessors: [xml2js.processors.stripPrefix], }, })(req, res, next); } else if (isMultipartMixed(contentType)) { express.text({ type: "multipart/mixed", limit: bodyParserLimit })(req, res, next); } else { req.checkUploadBinary = req.method === "POST"; next(); } } async function routeSetContext(req, res, next) { try { const { csn } = await getMetadata(req); req.csn = csn; } catch (err) { // Error logError(req, "Request", err); res.status(500).send("Internal Server Error"); return; } try { const service = serviceFromRequest(req); req.base = base; req.service = service.name; req.servicePath = service.path; req.serviceAbsolute = service.absolute; req.context = {}; req.contexts = []; req.contentId = {}; req.lookupContext = {}; next(); } catch (err) { // Error if (err.statusCode === 400) { logWarn(req, "Request", err); } else { logError(req, "Request", err); } // Trace logWarn(req, "Request", "Request with Error", { method: req.method, url: req.url, target, }); if (err.statusCode) { res.status(err.statusCode).send(err.message); } else { res.status(500).send("Internal Server Error"); } } } async function routeFileUpload(req, res, next) { if (!req.checkUploadBinary) { return next(); } const urlPath = targetUrl(req.originalUrl); const url = parseUrl(urlPath, req); const definition = contextFromUrl(url, req); if (!definition) { return next(); } convertUrl(url, req); const elements = definitionElements(definition); const mediaDataElementName = findElementByAnnotation(elements, "@Core.MediaType") || findElementByType(elements, DataTypeOData._Binary, req) || findElementByType(elements, DataTypeOData.Binary, req); if (!mediaDataElementName) { return next(); } const handleMediaEntity = async (contentType, filename, headers = {}) => { try { contentType = contentType || "application/octet-stream"; const body = {}; // Custom body const caseInsensitiveElements = Object.keys(elements).reduce((result, name) => { result[name.toLowerCase()] = elements[name]; return result; }, {}); Object.keys(headers).forEach((name) => { const element = caseInsensitiveElements[name.toLowerCase()]; if (element) { const value = convertDataTypeToV4(headers[name], elementType(element, req), definition, headers); body[element.name] = decodeHeaderValue(definition, element, element.name, value); } }); const mediaDataElement = elements[mediaDataElementName]; const mediaTypeElementName = (mediaDataElement["@Core.MediaType"] && mediaDataElement["@Core.MediaType"]["="]) || findElementByAnnotation(elements, "@Core.IsMediaType"); if (mediaTypeElementName) { body[mediaTypeElementName] = contentType; } const contentDispositionFilenameElementName = findElementValueByAnnotation(elements, "@Core.ContentDisposition.Filename") || findElementValueByAnnotation(elements, "@Common.ContentDisposition.Filename"); if (contentDispositionFilenameElementName && filename) { const element = elements[contentDispositionFilenameElementName]; body[contentDispositionFilenameElementName] = decodeHeaderValue(definition, element, element.name, filename); } const postUrl = target + url.pathname; const postHeaders = propagateHeaders(req, { ...headers, "content-type": "application/json", }); delete postHeaders["transfer-encoding"]; // Trace traceRequest(req, "ProxyRequest", "POST", postUrl, postHeaders, body); const postBody = JSON.stringify(body); postHeaders["content-length"] = postBody.length; const response = await fetch(postUrl, { method: "POST", headers: postHeaders, body: postBody, }); const responseBody = await response.json(); const responseHeaders = convertToNodeHeaders(response.headers); if (!response.ok) { res .status(response.status) .set({ "content-type": "application/json", }) .send(convertResponseError(responseBody, responseHeaders, definition, req)); return; } // Rewrite req.method = "PUT"; req.url += `(${entityKey(responseBody, definition, elements, req)})/${mediaDataElementName}`; req.originalUrl = req.url; req.overwriteResponse = { kind: "uploadBinary", statusCode: response.status, headers: responseHeaders, body: responseBody, }; // Trace traceResponse(req, "ProxyResponse", response.status, response.statusText, responseHeaders, responseBody); next(); } catch (err) { // Error logError(req, "FileUpload", err); res.status(500).send("Internal Server Error"); } }; const headers = req.headers; if (isMultipartFormData(headers["content-type"])) { fileUpload(req, res, async () => { await handleMediaEntity( req.body && req.body["content-type"], req.body && (req.body["slug"] || req.body["filename"] || contentDispositionFilename(req.body) || contentDispositionFilename(headers) || req.body["name"]), req.body, ); }); } else { await handleMediaEntity( headers["content-type"], headers["slug"] || headers["filename"] || contentDispositionFilename(headers) || headers["name"], headers, ); } } function routeBeforeRequest(req, res, next) { if (typeof router.before === "function") { router.before(req, res, next); } else if (Array.isArray(router.before) && router.before.length > 0) { const routes = router.before.slice(0); function call() { try { const route = routes.shift(); if (!route) { return next(null); } route(req, res, (err) => { if (err) { next(err); } else { call(); } }); } catch (err) { next(err); } } call(); } else { next(); } } function bindRoutes() { const routeMiddleware = createProxyMiddleware({ target: `${target}${rewritePath}`, changeOrigin: true, selfHandleResponse: true, on: { error: convertProxyError, proxyReq: convertProxyRequest, proxyRes: convertProxyResponse, }, logger: cds.log("cov2ap/hpm"), }); router.use(`/${path}`, routeBeforeRequest); router.use(`/${path}`, routeInitRequest); router.get(`/${path}/*\\$metadata`, routeGetMetadata); router.use(`/${path}`, routeBodyParser, routeSetContext, routeFileUpload, routeMiddleware); } if (registerOnListening) { cds.on("listening", ({ server, url }) => { if (target === "auto") { target = url; port = server.address().port; } bindRoutes(); }); } else { if (target === "auto") { target = defaultTarget; } bindRoutes(); } function contentDispositionFilename(headers) { const contentDispositionHeader = headers["content-disposition"] || headers["Content-Disposition"]; if (contentDispositionHeader) { const filenameMatch = contentDispositionHeader.match(/^.*filename="(.*)"$/is); return filenameMatch && filenameMatch.pop(); } return null; } function decodeHeaderValue(entity, element, name, value) { if (value === undefined || value === null || value === "" || typeof value !== "string") { return value; } let decodes = []; if (Array.isArray(element["@cov2ap.headerDecode"])) { decodes = element["@cov2ap.headerDecode"]; } else if (typeof element["@cov2ap.headerDecode"] === "string") { decodes = [element["@cov2ap.headerDecode"]]; } if (decodes.length > 0) { decodes.forEach((decode) => { switch (decode.toLowerCase()) { case "uri": value = decodeURI(value); break; case "uricomponent": value = decodeURIComponent(value); break; case "base64": value = decodeBase64(value); break; } }); } return value; } function serviceFromRequest(req) { const servicePathUrl = normalizeSlashes(req.params["0"] || req.url); // wildcard or non-wildcard const servicePath = targetPath ? `/${targetPath}${servicePathUrl}` : servicePathUrl; const service = { name: "", path: "", valid: true, absolute: false, }; Object.assign( service, determineMostSelectiveService( Object.keys(services) .map((path) => { if (servicePath.toLowerCase().startsWith(normalizeSlashes(path).toLowerCase())) { return { name: services[path], path: stripSlashes(path), }; } }) .filter((entry) => !!entry), ), ); if (!service.name) { Object.assign( service, determineMostSelectiveService( Object.keys(cds.services) .map((service) => { const path = serviceODataV4Path(cds.services[service]); if (path) { const absolute = !normalizeSlashes(path) .toLowerCase() .startsWith(normalizeSlashes(targetPath).toLowerCase()); if ( convertUrlAbsolutePath(absolute, servicePath) .toLowerCase() .startsWith(normalizeSlashes(path).toLowerCase()) ) { return { name: service, path: stripSlashes(path), absolute, }; } } }) .filter((entry) => !!entry), ), ); } if (!service.name) { Object.assign( service, determineMostSelectiveService( Object.keys(cds.services) .map((service) => { const path = serviceODataV4Path(cds.services[service]); if (path) { if (servicePathUrl.toLowerCase().startsWith(normalizeSlashes(path).toLowerCase())) { return { name: service, path: stripSlashes(path), absolute: true, }; } } }) .filter((entry) => !!entry), ), ); } if (!service.name) { Object.assign( service, determineMostSelectiveService( Object.keys(cds.services) .map((service) => { const path = serviceODataV4Path(cds.services[service]); if (path === "/") { return { name: service, path: "", }; } }) .filter((entry) => !!entry), ), ); } if (!service.name || !req.csn.definitions[service.name] || req.csn.definitions[service.name].kind !== "service") { logWarn(req, "Service", "Invalid service", { name: service.name, path: service.path, }); service.valid = false; } if (service.name && req.csn.definitions[service.name] && !isServedViaOData(req.csn.definitions[service.name])) { logWarn(req, "Service", "Invalid service protocol", { name: service.name, path: service.path, }); const error = new Error("Invalid service protocol. Only OData services supported"); error.statusCode = 400; throw error; } if (req.csn.definitions[service.name] && req.csn.definitions[service.name]["@cov2ap.ignore"]) { const error = new Error("Service is not exposed as OData V2 protocol"); error.statusCode = 400; throw error; } return { name: service.name, path: service.path, valid: service.valid, absolute: service.absolute, }; } function serviceODataV2Path(service) { if (Array.isArray(service.endpoints)) { const odataV2Endpoint = service.endpoints.find((endpoint) => ["odata-v2"].includes(endpoint.kind)); if (odataV2Endpoint) { return odataV2Endpoint.path; } } } function serviceODataV4Path(service) { if (Array.isArray(service.endpoints)) { const odataV4Endpoint = service.endpoints.find((endpoint) => ["odata", "odata-v4"].includes(endpoint.kind)); if (odataV4Endpoint) { return odataV4Endpoint.path; } } return service.path; } function determineMostSelectiveService(services) { services.sort((a, b) => { return b.path.length - a.path.length; }); if (services.length > 0) { return services[0]; } return null; } function isServedViaOData(service) { let protocols = service["@protocol"]; if (protocols) { protocols = !Array.isArray(protocols) ? [protocols] : protocols; return protocols.some((protocol) => { return (typeof protocol === "string" ? protocol : protocol.kind).startsWith("odata"); }); } const protocolDirect = Object.keys(cds.env.protocols || {}).find((protocol) => service["@" + protocol]); if (protocolDirect) { return protocolDirect.startsWith("odata"); } return true; } async function getMetadata(req, service) { let metadata; if (req.tenant) { if (mtxRemote && mtxEndpoint) { metadata = await getTenantMetadataRemote(req, service); } else if (cds.mtx && cds.env.requires && cds.env.requires.multitenancy) { metadata = await getTenantMetadataLocal(req, service); } else if (cds.env.requires && cds.env.requires["cds.xt.ModelProviderService"]) { metadata = await getTenantMetadataStreamlined(req, service); } } if (!metadata) { metadata = await getDefaultMetadata(req, service); } return metadata; } async function getTenantMetadataRemote(req, service) { const mtxBasePath = mtxEndpoint.startsWith("http://") || mtxEndpoint.startsWith("https://") ? mtxEndpoint : `${target}${mtxEndpoint}`; return await prepareMetadata( req.tenant, async (tenant) => { const response = await fetch(`${mtxBasePath}/metadata/csn/${tenant}`, { method: "GET", headers: propagateHeaders(req), }); if (!response.ok) { throw new Error(await response.text()); } return response.json(); }, async (tenant, service, locale) => { const response = await fetch( `${mtxBasePath}/metadata/edmx/${tenant}?name=${service}&language=${locale}&odataVersion=v2`, { method: "GET", headers: propagateHeaders(req), }, ); if (!response.ok) { throw new Error(await response.text()); } return response.text(); }, service, determineLocale(req), ); } async function getTenantMetadataLocal(req, service) { metadataCache[req.tenant] = metadataCache[req.tenant] || {}; const isExtended = await callCached(metadataCache[req.tenant], "isExtended", () => { return cds.mtx.isExtended(req.tenant); }); if (isExtended) { return await prepareMetadata( req.tenant, async (tenant) => { return await cds.mtx.getCsn(tenant); }, async (tenant, service, locale) => { return await cds.mtx.getEdmx(tenant, service, locale, "v2"); }, service, determineLocale(req), ); } } async function getTenantMetadataStreamlined(req, service) { metadataCache[req.tenant] = metadataCache[req.tenant] || {}; const { "cds.xt.ModelProviderService": mps } = cds.services; if (mps) { const isExtended = await callCached(metadataCache[req.tenant], "isExtended", () => { return mps.isExtended({ tenant: req.tenant, }); }); if (isExtended) { return await prepareMetadata( req.tenant, async (tenant) => { return await mps.getCsn({ tenant, toggles: ensureArray(req.features), for: "nodejs", }); }, async (tenant, service, locale) => { return await mps.getEdmx({ tenant, toggles: ensureArray(req.features), service, locale, flavor: "v2", for: "nodejs", }); }, service, determineLocale(req), ); } } } async function getDefaultMetadata(req, service) { return await prepareMetadata( DefaultTenant, async () => { if (typeof model === "object" && !Array.isArray(model)) { return model; } return await cds.load(model); }, async () => {}, service, determineLocale(req), ); } async function prepareMetadata(tenant, loadCsn, loadEdmx, service, locale) { metadataCache[tenant] = metadataCache[tenant] || {}; const csn = await callCached(metadataCache[tenant], "csn", () => { return prepareCSN(tenant, loadCsn); }); if (!service) { return { csn }; } metadataCache[tenant].edmx = metadataCache[tenant].edmx || {}; metadataCache[tenant].edmx[service] = metadataCache[tenant].edmx[service] || {}; const edmx = await callCached(metadataCache[tenant].edmx[service], locale, async () => { const edmx = await prepareEdmx(tenant, csn, loadEdmx, service, locale); if (["disk", "stream"].includes(cacheMetadata)) { const edmxFilename = fsPath.join(CACHE_DIR, `${tenant}$${service}$${locale}.edmx.xml`); await fs.promises.writeFile(edmxFilename, edmx); return edmxFilename; } return edmx; }); return { csn, edmx }; } async function prepareCSN(tenant, loadCsn) { let csnRaw; if (cds.server && cds.model && tenant === DefaultTenant) { csnRaw = cds.model; } else { csnRaw = await loadCsn(tenant); } let csn; if (cds.compile.for.nodejs) { csn = cds.compile.for.nodejs(csnRaw); } else { csn = csnRaw.meta && csnRaw.meta.transformation === "odata" ? csnRaw : cds.linked(cds.compile.for.odata(csnRaw)); } return csn; } async function prepareEdmx(tenant, csn, loadEdmx, service, locale) { let edmx; if (tenant !== DefaultTenant) { edmx = await loadEdmx(tenant, service, locale); } if (!edmx) { edmx = await edmxFromFile(tenant, service); if (!edmx) { edmx = await cds.compile.to.edmx(csn, { service, version: "v2", }); } edmx = cds.localize(csn, locale, edmx); } return edmx; } async function edmxFromFile(tenant, service) { const filePath = cds.root + `/srv/odata/v2/${service}.xml`; let exists; try { exists = !(await fs.promises.access(filePath, fs.constants.F_OK)); } catch (e) { logDebug({ tenant }, "Metadata", `No metadata file found for service ${service} at ${filePath}`); } if (exists) { return await fs.promises.readFile(filePath, "utf8"); } } async function callCached(cache, field, call) { if (call && !cache[field]) { cache[field] = call(); } try { return await cache[field]; } catch (err) { delete cache[field]; throw err; } } function localName(definition, req) { const localName = isServiceName(definition.name, req) ? odataName(definition.name, req) : definition.name; const nameSuffix = definition.kind === "entity" && definition.params && req.context.parameters && req.context.parameters.kind === "Set" ? "Set" : ""; return localName + nameSuffix; } function isServiceName(name, req) { return name.startsWith(`${req.service}.`); } function odataName(name, req) { return name.substring(`${req.service}.`.length).replace(/\./g, "_"); } function qualifiedODataName(name, req) { return `${req.service}.${odataName(name, req)}`; } function qualifiedName(name, req) { const serviceNamespacePrefix = `${req.service}.`; return (name.startsWith(serviceNamespacePrefix) ? "" : serviceNamespacePrefix) + name; } function qualifiedSubName(name, req) { if (name.includes("_")) { const parts = name.split("_"); const endPart = parts.pop(); name = `${parts.join("_")}.${endPart}`; } return qualifiedName(name, req); } function lookupDefinition(name, req) { if (["$metadata"].includes(name) || name.startsWith("cds.") || name.startsWith("Edm.")) { return; } const definitionName = qualifiedName(name, req); const definitionSubName = qualifiedSubName(name, req); const definition = req.csn.definitions[definitionSubName] || req.csn.definitions[definitionName] || req.csn.definitions[name]; if (definition) { return definition; } for (const name in req.csn.definitions) { if (!isServiceName(name, req)) { continue; } if (definitionName === qualifiedODataName(name, req)) { return req.csn.definitions[name]; } } } function lookupBoundDefinition(name, req) { for (const definitionName in req.csn.definitions) { const definition = req.csn.definitions[definitionName]; if (definition.actions) { for (const actionName in definition.actions) { if (name.endsWith(`_${actionName}`)) { const entityName = name.substring(0, name.length - `_${actionName}`.length); const entityDefinition = lookupDefinition(entityName, req); if (entityDefinition === definition) { const boundAction = definition.actions[actionName]; req.lookupContext.boundDefinition = definition; req.lookupContext.operation = boundAction; const returnDefinition = lookupReturnDefinition(boundAction.returns, req); if (returnDefinition) { req.lookupContext.returnDefinition = returnDefinition; } return boundAction; } } } } } } function lookupParametersDefinition(name, req) { const definitionTypeName = qualifiedName(name, req); let definitionKind; if (definitionTypeName.endsWith("Set")) { definitionKind = "Set"; } else if (definitionTypeName.endsWith("Parameters")) { definitionKind = "Parameters"; } if (definitionKind) { const definitionName = definitionTypeName.substring(0, definitionTypeName.length - definitionKind.length); const definition = req.csn.definitions[definitionName] || req.csn.definitions[name]; if (definition && definition.kind === "entity" && definition.params) { req.lookupContext.parameters = { kind: definitionKind, entity: localName(definition, req), type: localName({ name: definitionTypeName }, req), values: {}, keys: {}, count: false, }; return definition; } } } function enhanceParametersDefinition(context, req) { if (context && context.kind === "entity" && context.params) { req.lookupContext.parameters = req.lookupContext.parameters || { kind: "Parameters", entity: localName(context, req), type: localName(context, req), values: {}, keys: {}, count: false, }; } } function convertProxyError(err, req, res) { logError(req, "Proxy", err); if (!req && !res) { throw err; } if (res.writeHead && !res.headersSent) { if (/HPE_INVALID/.test(err.code)) { res.writeHead(502); } else { switch (err.code) { case "ECONNRESET": case "ENOTFOUND": case "ECONNREFUSED": case "ETIMEDOUT": res.writeHead(504); break; default: res.writeHead(500); } } } if (!res.writableEnded) { res.end("Unexpected error occurred while processing request"); } } /** * Convert Proxy Request (V2 -> V4) * @param proxyReq Proxy Request * @param req Request * @param res Response */ async function convertProxyRequest(proxyReq, req, res) { try { // Trace traceRequest(req, "Request", req.method, req.originalUrl, req.headers, req.body); const headers = propagateHeaders(req); let body = req.body; let contentType = req.header("content-type"); if (isMultipartMixed(contentType)) { // Multipart req.contentIdOrder = []; if (req.method === "HEAD") { body = ""; } else { body = processMultipartMixed( req, body, contentType, ({ method, url }) => { method = convertMethod(method); url = convertUrlAndSetContext(url, req, method); return { method, url }; }, ({ contentType, body, headers, url, contentId }) => { if (contentId) { req.contentId[`$${contentId}`] = req.context.url; } delete headers["odata-version"]; delete headers["Odata-Version"]; delete headers.dataserviceversion; delete headers.DataServiceVersion; delete headers.maxdataserviceversion; delete headers.MaxDataServiceVersion; if (isResponseFormatXML(req.context, headers)) { req.context.serviceResponseAsXML = true; } if (headers.accept && !headers.accept.includes("application/json")) { headers.accept = "application/json," + headers.accept; } if (isXML(contentType)) { req.context.serviceRequestAsXML = true; body = convertRequestBodyFromXML(body, req); contentType = "application/json"; headers["content-type"] = contentType; } if (headers["sap-messages"]) { req.context.messages = headers["sap-messages"]; } if (isApplicationJSON(contentType)) { if (ieee754Compatible) { contentType = enrichApplicationJSON(conten