@cap-js-community/odata-v2-adapter
Version:
OData V2 adapter for CDS
1,391 lines (1,302 loc) • 179 kB
JavaScript
"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