@sap_oss/odata-library
Version:
OData client for testing Netweawer OData services.
772 lines (711 loc) • 21.7 kB
JavaScript
"use strict";
const _ = require("lodash");
const url = require("./url");
const BatchManager = require("./batch/Manager");
const log = require("./log");
const xml2js = require("xml2js");
const tough = require("tough-cookie");
const authentication = require("./authentication");
const https = require("https");
const AUTH_HEADERS = Symbol("AUTH_HEADERS");
const CSRF_TOKEN = Symbol("CSRF_TOKEN");
let requestCounter = 0;
/**
* Service endpoint agent.
* Handles all GET/POST/PUT/DELETE ... methods to the service endpoint.
*
* @class Agent
*/
class Agent {
/**
* Creates an instance of <code>Agent</code>.
*
* @param {Object} settings define service endpoint
*
* @memberof Agent
*/
constructor(settings) {
Object.defineProperty(this, "logger", {
value: this.initializeLogger(settings),
writable: false,
});
Object.defineProperty(this, "settings", {
value: settings,
writable: false,
});
Object.defineProperty(this, "prefix", {
value: settings.url.replace(/\/$/, ""),
writable: false,
});
Object.defineProperty(this, "batchManager", {
value: new BatchManager(),
writable: false,
});
Object.defineProperty(this, "cookieJar", {
value: new tough.CookieJar(),
writable: false,
});
Object.defineProperty(this, "defaultFetchOptions", {
value: this.initializeDefaultFetchOptions(settings),
writable: false,
});
}
/**
* Initialize object merged with user definined options
* for fetch and passed as options to the node-fetch
*
* @param {Object} settings define service endpoint
*
* @returns {Object} initialized options
*/
initializeDefaultFetchOptions(settings) {
const AGENT_OPTIONS = ["cert", "key", "pfx", "ca", "passphrase"];
const defaultAgentOptions = _.pick(_.get(settings, "auth"), AGENT_OPTIONS);
const defaultFetchOptions = {};
if (_.keys(defaultAgentOptions).length > 0) {
defaultFetchOptions.agent = new https.Agent(defaultAgentOptions);
}
return defaultFetchOptions;
}
/**
* Initialize logger instance
*
* @param {Object} settings define service endpoint
*
* @returns {Object} object which implements trace, debug, info, warn, error methods.
*
* @memberof Agent
*/
initializeLogger(settings) {
let logger = {
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
let methods = _.keys(logger);
if (_.has(settings, "logger")) {
_.each(methods, (methodName) => {
if (!_.isFunction(_.get(settings, `logger.${methodName}`))) {
throw new Error(
`Cannot initialize logger: ${methodName} is not a function`
);
}
});
logger = settings.logger;
}
return logger;
}
/**
* Convert parameters map to query string
*
* @param {Object} parameters - contains information which is user to request metadata
*
* @returns {String} search part of the metadata URL
*
* @memberof Agent
*/
metadataSearch(parameters) {
return _.chain(parameters)
.keys()
.filter(
(key) =>
_.isArray(parameters[key]) ||
_.isString(parameters[key]) ||
_.isNumber(parameters[key])
)
.map(
(key) =>
`${key}=${
_.isArray(parameters[key])
? parameters[key].join(",")
: parameters[key]
}`
)
.join("&")
.value();
}
/**
* Send requests to service metadata
*
* @returns {Promise} which done when all metadata requests ar loaded and metadata is merged.
*
* @memberof Agent
*/
metadata() {
let metadataUrls = _.concat(
//Core metadata data url
[
`${this.prefix}/$metadata?${this.metadataSearch(
this.settings.parameters
)}`,
],
//Add metadata url if exists
this.settings.annotationsUrl
? url.appendSearch(
this.settings.annotationsUrl,
this.metadataSearch(this.settings.parameters)
)
: []
);
return authentication.authenticate(this, metadataUrls[0]).then(() => {
return Promise.all(
_.map(metadataUrls, (requestUrl) =>
this.createMetadataRequest(requestUrl)
)
).then((responses) => {
this.logger.info("All metadata succesfully fetched.");
return responses;
});
});
}
/**
* Creates metadata requests from url
*
* @param {string} metadataUrl URL of the metadata
*
* @returns {Promise} which done where metadata request is loaded
*
* @memberof Agent
*/
createMetadataRequest(metadataUrl) {
return new Promise((resolve, reject) => {
this.fetch(metadataUrl)
.then((res) => {
this.logger.info(
`Metadata successfully fetched from '${metadataUrl}'.`
);
return res.text();
})
.then((resText) => {
xml2js.parseString(resText, function (err, output) {
if (err) {
reject(err);
} else {
resolve(output);
}
});
})
.catch((err) => {
reject(err);
});
});
}
/**
* Wrapper around GET function. All parameters are passed to fetch method
*
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the GET request
*
* @returns {Promise} promise which is done when GET request is finished
*
* @memberof Agent
*/
get(...args) {
return this.sendRequest("GET", ...args);
}
/**
* Wrapper around POST function. All parameters are passed to fetch method
*
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the post request
* @param {Object} payload data which is converted to the JSON string and passed as body of POST request
*
* @returns {Promise} promise which is done when POST request is finished
*
* @memberof Agent
*/
post(...args) {
return this.sendRequest("POST", ...args);
}
/**
* Wrapper around PUT function. All parameters are passed to fetch method
*
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the PUT request
* @param {Object} payload data which is converted to the JSON string and passed as body of PUT request
*
* @returns {Promise} promise which done when PUT request is finished
*
* @memberof Agent
*/
put(...args) {
return this.sendRequest("PUT", ...args);
}
/**
* Wrapper around fetch API http requests. All parameters are passed to fetch
*
* @private
*
* @param {String} httpMethod name of the HTTP method
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the post request
* @param {Object} payload data which is converted to the JSON string and passed as body of POST request
*
* @returns {Promise} which done when request of HTTP method definned by parameters has finished
*
* @memberof Agent
*/
sendRequest(httpMethod, inputUrl, headers, payload) {
let fetchTokenPromise = Promise.resolve();
let options = {
method: httpMethod.toUpperCase(),
headers: {},
};
let requestUrl = `${url.normalize(inputUrl, this.prefix)}`;
if (payload) {
options.body = payload;
}
if (_.isObject(headers)) {
options.headers = headers;
}
if (options.method !== "GET") {
fetchTokenPromise = this.fetchToken().then((token) => {
options.headers["x-csrf-token"] = token;
});
}
return fetchTokenPromise.then(() => {
return this.fetch(requestUrl, options);
});
}
/**
* Send batch request defined by the batch object passed as parameter
*
* @private
*
* @param {Object} [batch] represents batch request, if batch is not
* passed use default batch from batch/Manager
* @param {Boolean} raw if the parameter is false response contains
* just array of parsed OData responses. If the parameter is true
* response contains HTTP.Response with property batchResponse.
* The batchResponses contains list of particular respones from
* the requests send over bulk batch request.
*
* @returns {Promise} which done when batch request is resolved
*
* @memberof Agent
*/
batch(batch, raw = false) {
let batchNormalized = batch || this.batchManager.defaultBatch;
let payload;
return this.fetchToken()
.then((csrfToken) => {
payload = batchNormalized.payload(csrfToken);
return this.sendRequest(
"POST",
"/$batch",
_.assign(
{
"Content-Type": `multipart/mixed;boundary=${batchNormalized.boundary()}`,
Accept: "multipart/mixed",
},
csrfToken
? {
"x-csrf-token": csrfToken,
}
: {}
),
payload,
true
);
})
.then((batchResponse) => {
return batchNormalized
.process(batchResponse)
.then((requestsResponses) => {
const normalizedResponse = this.normalizeBatchResponse(
batchResponse,
requestsResponses,
raw
);
this.batchManager.remove(batchNormalized);
return normalizedResponse;
});
});
}
/**
* Wrapper around MERGE function. All parameters are passed to fetch. MERGE request
* is supported by OData protocol 2.0 and older.
*
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the MERGE request
* @param {Object} payload data which is converted to the JSON string and passed as body of MERGE request
*
*
* @returns {Promise} promise which done when MERGE request has finished
*
* @memberof Agent
*/
merge(...args) {
return this.sendRequest("MERGE", ...args);
}
/**
* Create PATCH request. Patch updates the entity. It is supported by OData protocol
* version 3.0 and newer.
*
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the GET request
* @param {Object} payload data which is converted to the JSON string and passed as body of MERGE request in batch
*
* @returns {Promise} promise which done when PATCH request has finished
*
* @public
* @memberof Agent
*/
patch(...args) {
return this.sendRequest("PATCH", ...args);
}
/**
* Wrapper around DELETE function. All parameters are passed to fetch method
*
* @param {String} inputUrl relative path in the service
* @param {Object} headers object which contains headers used for the delete request
*
* @returns {Promise} which is done where delete request has done
*
* @memberof Agent
*/
delete(inputUrl, headers) {
return this.sendRequest("DELETE", inputUrl, headers);
}
/**
* Send request to fetch CSRF token from backend.
*
* @param {String} inputUrl relative path in the service
*
* @returns {Promise} which done where token has loaded
*
* @memberof Agent
*/
fetchToken() {
let promise;
if (this[CSRF_TOKEN]) {
promise = Promise.resolve(this[CSRF_TOKEN]);
} else {
promise = new Promise((resolve, reject) => {
this.logger.info("Fetch X-CSRF-Token");
this.fetch(`${url.normalize("/", this.prefix)}`, {
headers: { "X-CSRF-Token": "fetch" },
})
.then((res) => {
this[CSRF_TOKEN] = res.headers.get("x-csrf-token");
this.logger.info("CSRF token successfully downloaded.");
resolve(this[CSRF_TOKEN]);
})
.catch(reject);
});
}
return promise;
}
/**
* Send batch request defined by the batch object passed as parameter
*
* @private
*
* @param {HTTP.Response} batchResponse response to batch request
* @param {Object[]} requestsResponses list of responses parsed from batch response
* @param {Boolean} raw if the parameter is false returns values contains
* just array of parsed OData responses. If the parameter is true
* returns batchResponse with list particular responses from batch.
*
* @returns {Promise} promise with array or responses parsed from batch
* response or batch response object
*
* @memberof Agent
*/
normalizeBatchResponse(batchResponse, requestsResponses, raw) {
let normalizedBatchResponse;
if (raw) {
normalizedBatchResponse = _.assign(batchResponse, {
batchResponses: requestsResponses,
});
} else {
normalizedBatchResponse = _.chain([])
.concat(...requestsResponses)
.map((response) =>
response instanceof Error
? response
: response.plain(this._listResultPath, this._instanceResultPath)
)
.value();
}
return Promise.resolve(normalizedBatchResponse);
}
/**
* Determine path to result content
*
* @param {Boolean} isList true if result is array
* @param {Object} result object with response from backend
*
* @return {String} path with dot notation to content of response
*/
getResultPath(isList, result) {
return isList && _.has(result, this._listResultPath)
? this._listResultPath
: this._instanceResultPath;
}
/**
* Initialize version dependent properties
*
* @param {String} version identification of currect service version
*/
setServiceVersion(version) {
Object.defineProperty(this, "serviceVersion", {
value: version,
writable: false,
});
if (!["1.0", "4.0"].includes(version)) {
throw new Error(`OData Service version '${version}' is not supported.`);
}
let isV4 = version === "4.0";
Object.defineProperty(this, "_listResultPath", {
value: isV4 ? "value" : "d.results",
writable: false,
});
Object.defineProperty(this, "_instanceResultPath", {
value: isV4 ? "" : "d",
writable: false,
});
}
/**
* Envelope (fetch API) to support authentication
*
* @public
*
* @param {String} requestUrl endpoint for HTTP request
* @param {Object} [opts] options passed to enveloped fetch (with auth parameters)
*
* @returns {Promise} promise which is resolved when HTTP request is done
*/
fetch(requestUrl, opts = {}) {
let normalizedOpts;
let isRequestedManualRedirect;
let follow;
let response;
let counter;
if (!_.isObject(opts)) {
throw new Error("Invalid options passed for HTTP request.");
}
normalizedOpts = _.assign({}, this.defaultFetchOptions, opts);
counter = requestCounter++;
isRequestedManualRedirect = normalizedOpts.redirect === "manual";
follow = normalizedOpts.follow;
return this.readCookies(requestUrl)
.then((cookies) => {
this.appendHeaders({ Cookie: cookies }, normalizedOpts);
this.appendHeaders(this[AUTH_HEADERS], normalizedOpts);
log.logRequest(
this.logger,
counter,
requestUrl,
_.pickBy(normalizedOpts, (value, key) => key !== "agent")
);
return fetch(
requestUrl,
_.assign({ redirect: "manual" }, normalizedOpts)
);
})
.then((res) => {
response = res;
return response;
})
.then(this.saveCookies.bind(this))
.then(() => {
return this.isResponseRedirect(
response,
follow,
isRequestedManualRedirect
)
? this.redirect(counter, requestUrl, normalizedOpts, response)
: this.processResponse(counter, requestUrl, normalizedOpts, response);
});
}
/**
* Append counter to the response as identification for content log
* after its processing
*
* @private
*
* @param {Number} counter sequence number for currrent request
* @param {String} requestUrl endpoint for HTTP request
* @param {Object} opts options passed to enveloped fetch (with auth parameters)
* @param {HTTP.Response} response from fetch
*
* @returns {Promise} promise which is resolved when HTTP response is procesed
*/
processResponse(counter, requestUrl, opts, response) {
let promise;
log.logResponse(this.logger, counter, requestUrl, opts);
response.requestCounter = counter;
if (response.status >= 400) {
promise = response.text().then((errorText) => {
let err = new Error(errorText || response.statusText);
err.name = response.statusText;
err.status = response.status;
return Promise.reject(err);
});
} else {
promise = Promise.resolve(response);
}
return promise;
}
/**
* Redirect response
*
* @private
*
* @param {Number} counter sequence number for currrent request
* @param {String} requestUrl endpoint for HTTP request
* @param {Object} opts options passed to enveloped fetch (with auth parameters)
* @param {HTTP.Response} response from fetch
*
* @returns {Promise} promise which is resolved when redirect is processed
*/
redirect(counter, requestUrl, opts, response) {
let statusOpts = Object.assign(
{},
opts.follow !== undefined ? { follow: opts.follow - 1 } : {}
);
log.logResponse(this.logger, counter, requestUrl, opts, response);
if (response.status !== 307) {
statusOpts.method = "GET";
statusOpts.body = null;
}
return this.fetch(
this.nextRequestUrl(response.headers.get("location"), response),
Object.assign({}, statusOpts),
true
);
}
/**
* Create URL for next hop from current response and current
* action in form (form sometimes contains all URL and sometimes
* just path.
*
* @private
*
* @param {String} requestedUrl path or fullurl from action attribute
* of SAML/Login form
* @param {Object} response object with last response which contains
* requested URL
*
* @returns {String} full url
*/
nextRequestUrl(requestedUrl, response) {
let requestUrl;
try {
requestUrl = new URL(requestedUrl);
} catch (err) {
requestUrl = new URL(requestedUrl, _.get(response, "url"));
}
return requestUrl.href;
}
/**
* Read cookies from local cookie storag
*
* @private
*
* @param {String} requestUrl endpoint for HTTP request
*
* @returns {Promise} promise which is resolved when cookies has read
*/
readCookies(requestUrl) {
return new Promise((resolve, reject) => {
this.cookieJar.getCookieString(
typeof requestUrl === "string" ? requestUrl : requestUrl.url,
(err, cookies) => {
if (err) {
reject(err);
} else {
resolve(cookies);
}
}
);
});
}
/**
* Finds out if response is redirect
*
* @private
*
* @param {HTTP.Response} response from fetch
* @param {Number} follow is the follow header from request options
* @param {Boolean} isRequestedManualRedirect determine manual redirect management
*
* @returns {Boolean} true if response is redirect to other HTTP url
*/
isResponseRedirect(response, follow, isRequestedManualRedirect) {
return (
[303, 302, 307].some(
(redirectStatus) => response.status === redirectStatus
) &&
isRequestedManualRedirect !== true &&
follow !== 0
);
}
/**
* Safely add additional headers to the request headers
*
* @private
*
* @param {Object} headers additional headers
* @param {Object} opts object which is passed to the fetch and which is updated
*/
appendHeaders(headers, opts) {
let normalizedHeaders;
if (_.isObject(opts) && _.isObject(headers)) {
normalizedHeaders = _.pickBy(headers, (value) => !_.isNil(value));
if (
_.isObject(opts.headers) &&
typeof opts.headers.append === "function"
) {
_.each(normalizedHeaders, (value, key) => {
opts.headers.append(key, value);
});
} else {
opts.headers = Object.assign({}, opts.headers, normalizedHeaders);
}
}
}
/**
* Save cookie to local cookie storage
*
* @private
*
* @param {HTTP.Response} response from fetch
*
* @return {Promise} promise which is resolved when cookies are saved
*/
saveCookies(response) {
let cookies = response.headers.getSetCookie();
//Store all present cookies
return Promise.all(
cookies
.map((cookieString) => tough.Cookie.parse(cookieString))
.map(
(cookie) =>
new Promise((resolve, reject) => {
this.cookieJar.setCookie(
cookie,
response.url,
(err, savedCookie) => {
if (err) {
reject(err);
} else {
resolve(savedCookie);
}
}
);
})
)
);
}
setAuthorizationHeaders(authorizationHeaders) {
if (!_.isObject(authorizationHeaders)) {
throw new Error("Invalid authorization headers");
}
this[AUTH_HEADERS] = authorizationHeaders;
}
}
module.exports = Agent;