@villedemontreal/http-request
Version:
HTTP utilities - send HTTP requests with proper headers, etc.
363 lines • 14.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.httpUtils = exports.HttpUtils = void 0;
const general_utils_1 = require("@villedemontreal/general-utils");
const _ = require("lodash");
const configs_1 = require("./config/configs");
const constants_1 = require("./config/constants");
const logger_1 = require("./utils/logger");
const logger = (0, logger_1.createLogger)('HttpUtils');
/**
* HTTP utilities
*/
class HttpUtils {
constructor() {
this.REQ_PARAMS_LOWERCASED = '__queryParamsLowercased';
/**
* Get the last value of a querystring parameter *as a Date*.
* The parameter must be parsable using `new Date(xxx)`.
* It is recommended to always use ISO-8601 to represent dates
* (ex: "2020-04-21T17:13:33.107Z").
*
* If the parameter is found but can't be parsed to a Date,
* by default an `Error` is thrown. But if `errorHandler`
* is specified, it is called instead. This allows you
* to catch the error and throw a custom error, for
* example by using `throw createInvalidParameterError(xxx)`
* in an API.
*
* Manages the fact that we may use insensitive routing.
*
* @returns the last parameter with that key as a Date
* or `undefined` if not found.
* @throws An Error if the parameter is found but can't be parsed
* to a Date and no `errorHandler` is specified.
*/
this.getQueryParamOneAsDate = (req, key, errorHandler) => {
const dateStr = this.getQueryParamOne(req, key);
let date;
if (!general_utils_1.utils.isBlank(dateStr)) {
date = new Date(dateStr);
if (isNaN(date.getTime())) {
const errorMsg = `Not a valid parsable date: "${dateStr}"`;
if (errorHandler) {
return errorHandler(errorMsg, dateStr);
}
throw new Error(errorMsg);
}
}
return date;
};
/**
* Get the last value of a querystring parameter *as a Number*.
* The parameter must be parsable using `Number(xxx)`.
*
* If the parameter is found but can't be parsed to a Number,
* by default an `Error` is thrown. But if `errorHandler`
* is specified, it is called instead. This allows you
* to catch the error and throw a custom error, for
* example by using `throw createInvalidParameterError(xxx)`
* in an API.
*
* Manages the fact that we may use insensitive routing.
*
* @returns the last parameter with that key as a Number
* or `undefined` if not found.
* @throws An Error if the parameter is found but can't be parsed
* to a Number and no `errorHandler` is specified.
*/
this.getQueryParamOneAsNumber = (req, key, errorHandler) => {
const numberStr = this.getQueryParamOne(req, key);
let val;
if (!general_utils_1.utils.isBlank(numberStr)) {
val = Number(numberStr);
if (isNaN(val)) {
const errorMsg = `Not a valid number: "${numberStr}"`;
if (errorHandler) {
return errorHandler(errorMsg, numberStr);
}
throw new Error(errorMsg);
}
}
return val;
};
/**
* Get the last value of a querystring parameter *as a boolean*.
* The value must be "true" or "false" (case insensitive) to
* be considered as a valid boolean. For example, the value '1'
* is invalid.
*
* @returns the last parameter with that key as a boolean
* or `undefined` if not found.
* @throws An Error if the parameter is found but can't be parsed
* to a valid boolean and no `errorHandler` is specified.
*/
this.getQueryParamOneAsBoolean = (req, key, errorHandler) => {
const boolStr = this.getQueryParamOne(req, key);
if (general_utils_1.utils.isBlank(boolStr)) {
return undefined;
}
if (boolStr.toLowerCase() === 'true') {
return true;
}
if (boolStr.toLowerCase() === 'false') {
return false;
}
const errorMsg = `Not a valid boolean value: "${boolStr}"`;
if (errorHandler) {
return errorHandler(errorMsg, boolStr);
}
throw new Error(errorMsg);
};
/**
* Gets the "IOrderBy[]" from the querystring parameters
* of a search request.
*
* @see https://confluence.montreal.ca/pages/viewpage.action?spaceKey=AES&title=REST+API#RESTAPI-Tridelarequ%C3%AAte
*/
this.getOrderBys = (req) => {
const orderBys = [];
const orderByStr = this.getQueryParamOne(req, 'orderBy');
if (general_utils_1.utils.isBlank(orderByStr)) {
return orderBys;
}
const tokens = orderByStr.split(',');
for (let token of tokens) {
token = token.trim();
let key = token;
let direction = general_utils_1.OrderByDirection.ASC;
if (token.startsWith('+')) {
key = token.substring(1);
}
else if (token.startsWith('-')) {
key = token.substring(1);
direction = general_utils_1.OrderByDirection.DESC;
}
const orderBy = {
key,
direction,
};
orderBys.push(orderBy);
}
return orderBys;
};
}
/**
* Remove first and last slash of the string unless the string is the part after protocol (http://)
*/
removeSlashes(text) {
if (text) {
let start;
let end;
start = 0;
while (start < text.length && text[start] === '/') {
start++;
}
end = text.length - 1;
while (end > start && text[end] === '/') {
end--;
}
let result = text.substring(start, end + 1);
// handle exception of the protocol that's followed with 2 slashes after the semi-colon.
if (result && result[result.length - 1] === ':') {
result += '/';
}
return result;
}
return text;
}
/**
* Join few parts of an url to a final string
*/
urlJoin(...args) {
return _.map(args, this.removeSlashes)
.filter((x) => !!x)
.join('/');
}
/**
* Sends a HTTP request built with Superagent.
*
* Will add the proper Correlation Id and will write
* useful logs.
*
* IMPORTANT : this method does NOT throw an Error on a
* 4XX-5XX status response! It will return it the same way
* it returns a 200 response and it is up to the calling code
* to validate the actual response's status. For example
* by using :
*
* if(response.ok) {...}
*
* and/or by checking the status :
*
* if(response.status === 404) {...}
*
* An error will be thrown only when a network problem occures or
* if the target server can't be reached.
*
* This is different from SuperAgent's default behavior that DOES
* throw an error on 4XX-5XX status responses.
*
*/
async send(request) {
if (_.isNil(request)) {
throw new Error(`The request object can't be empty`);
}
if ('status' in request) {
throw new Error(`The request object must be of type SuperAgentRequest. Make sure this object has NOT already been awaited ` +
`prior to being passed here!`);
}
if (!request.url || request.url.indexOf('://') < 0) {
throw new Error(`The URL in your request MUST have a protocol and a hostname. Received: ${request.url}`);
}
if (general_utils_1.utils.isBlank(request.get('X-Correlation-ID'))) {
const cid = configs_1.configs.correlationId;
if (!general_utils_1.utils.isBlank(cid)) {
request.set('X-Correlation-ID', cid);
}
}
// ==========================================
// Adds timeouts, if they are not already set.
// ==========================================
const responseTimeoutRequestVarName = '_responseTimeout';
const timeoutRequestVarName = '_timeout';
request.timeout({
response: request[responseTimeoutRequestVarName] !== undefined
? request[responseTimeoutRequestVarName]
: constants_1.constants.request.timeoutsDefault.response,
deadline: request[timeoutRequestVarName] !== undefined
? request[timeoutRequestVarName]
: constants_1.constants.request.timeoutsDefault.deadline,
});
logger.debug({
sendingCorrelationIdHeader: request.get('X-Correlation-ID') || null,
url: request.url,
method: request.method,
msg: `Http Client - Start request to ${request.method} ${request.url}`,
});
let result;
const timer = new general_utils_1.Timer();
try {
result = await request;
}
catch (err) {
// ==========================================
// SuperAgent throws a error on 4XX/5XX status responses...
// But we prefere to return those responses as regular
// ones and leave it to the caling code to validate
// the status! That way, we can differenciate between
// a 4XX/5XX result and a *real* error, for example if
// the request can't be sent because of a network
// error....
// ==========================================
if (err.status && err.response) {
result = err.response;
}
else {
// ==========================================
// Real error!
// ==========================================
logger.debug({
error: err,
url: request.url,
method: request.method,
timeTaken: timer.toString(),
msg: `Http Client - End request ERROR request to ${request.method} ${request.url}`,
});
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw {
msg: `An error occured while making the HTTP request to ${request.method} ${request.url}`,
originalError: err,
};
}
}
logger.debug({
url: request.url,
method: request.method,
statusCode: result.status,
timeTaken: timer.toString(),
msg: `Http Client - End request to ${request.method} ${request.url}`,
});
return result;
}
/**
* Gets all the values of a querystring parameter.
* Manages the fact that we may use insensitive routing.
*
* A querystring parameter may indeed contains multiple values. For
* example : "path?name=aaa&name=bbb" will result in an
* *array* when getting the "name" parameter : ['aaa', 'bbb'].
*
* @returns all the values of the parameters as an array (even if
* only one value is found) or an empty array if none are found.
*/
getQueryParamAll(req, key) {
if (!req || !req.query || !key) {
return [];
}
// ==========================================
// URL parsing is case sensitive. We can
// directly return the params as an array here.
// ==========================================
if (configs_1.configs.isUrlCaseSensitive) {
return this.getOriginalQueryParamAsArray(req, key);
}
// ==========================================
// The URL parsing is case *insensitive* here.
// We need more work to make sure we merge
// params in a case insensitive manner.
// ==========================================
if (!req[this.REQ_PARAMS_LOWERCASED]) {
req[this.REQ_PARAMS_LOWERCASED] = [];
Object.keys(req.query).forEach((keyExisting) => {
const keyLower = keyExisting.toLowerCase();
if (keyLower in req[this.REQ_PARAMS_LOWERCASED]) {
req[this.REQ_PARAMS_LOWERCASED][keyLower].push(req.query[keyExisting]);
}
else {
let val = req.query[keyExisting];
if (!_.isArray(val)) {
val = [val];
}
req[this.REQ_PARAMS_LOWERCASED][keyLower] = val;
}
});
}
const values = req[this.REQ_PARAMS_LOWERCASED][key.toLowerCase()];
return values || [];
}
/**
* Get the last value of a querystring parameter.
* Manages the fact that we may use insensitive routing.
*
* A querystring parameter may indeed contains multiple values. For
* example : "path?name=aaa&name=bbb" will result in an
* *array* when getting the "name" parameter : ['aaa', 'bbb'].
*
* In many situation, we only want to deal withy a single value.
* This function return the last value of a query param.
*
* @returns the last parameter with that key or `undefined` if
* not found.
*/
getQueryParamOne(req, key) {
const values = this.getQueryParamAll(req, key);
if (!values || values.length === 0) {
return undefined;
}
return values[values.length - 1];
}
getOriginalQueryParamAsArray(req, key) {
let val = req.query[key];
if (_.isUndefined(val)) {
return [];
}
if (!_.isArray(val)) {
val = [val];
}
return val;
}
}
exports.HttpUtils = HttpUtils;
exports.httpUtils = new HttpUtils();
//# sourceMappingURL=httpUtils.js.map