@villedemontreal/http-request
Version:
HTTP utilities - send HTTP requests with proper headers, etc.
408 lines (372 loc) • 13 kB
text/typescript
import { IOrderBy, OrderByDirection, Timer, utils } from '@villedemontreal/general-utils';
import { Request } from 'express';
import * as _ from 'lodash';
import * as superagent from 'superagent';
import { configs } from './config/configs';
import { constants } from './config/constants';
import { createLogger } from './utils/logger';
const logger = createLogger('HttpUtils');
/**
* HTTP utilities
*/
export class HttpUtils {
private readonly REQ_PARAMS_LOWERCASED = '__queryParamsLowercased';
/**
* Remove first and last slash of the string unless the string is the part after protocol (http://)
*/
public removeSlashes(text: string) {
if (text) {
let start;
let end: number;
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
*/
public urlJoin(...args: string[]) {
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.
*
*/
public async send(request: superagent.SuperAgentRequest): Promise<superagent.Response> {
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 (utils.isBlank(request.get('X-Correlation-ID'))) {
const cid = configs.correlationId;
if (!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 as any)[responseTimeoutRequestVarName] !== undefined
? (request as any)[responseTimeoutRequestVarName]
: constants.request.timeoutsDefault.response,
deadline:
(request as any)[timeoutRequestVarName] !== undefined
? (request as any)[timeoutRequestVarName]
: 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 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.
*/
public getQueryParamAll(req: Request, key: string): string[] {
if (!req || !req.query || !key) {
return [];
}
// ==========================================
// URL parsing is case sensitive. We can
// directly return the params as an array here.
// ==========================================
if (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 as any)[this.REQ_PARAMS_LOWERCASED]) {
(req as any)[this.REQ_PARAMS_LOWERCASED] = [];
Object.keys(req.query).forEach((keyExisting: string) => {
const keyLower = keyExisting.toLowerCase();
if (keyLower in (req as any)[this.REQ_PARAMS_LOWERCASED]) {
(req as any)[this.REQ_PARAMS_LOWERCASED][keyLower].push(req.query[keyExisting]);
} else {
let val = req.query[keyExisting];
if (!_.isArray(val)) {
val = [val] as string[];
}
(req as any)[this.REQ_PARAMS_LOWERCASED][keyLower] = val;
}
});
}
const values = (req as any)[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.
*/
public getQueryParamOne(req: Request, key: string): string {
const values = this.getQueryParamAll(req, key);
if (!values || values.length === 0) {
return undefined;
}
return values[values.length - 1];
}
/**
* 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.
*/
public getQueryParamOneAsDate = (
req: Request,
key: string,
errorHandler?: (errMsg: string, value?: string) => any,
): Date => {
const dateStr = this.getQueryParamOne(req, key);
let date: Date;
if (!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.
*/
public getQueryParamOneAsNumber = (
req: Request,
key: string,
errorHandler?: (errMsg: string, value?: string) => any,
): number => {
const numberStr = this.getQueryParamOne(req, key);
let val: number;
if (!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.
*/
public getQueryParamOneAsBoolean = (
req: Request,
key: string,
errorHandler?: (errMsg: string, value?: string) => any,
): boolean => {
const boolStr = this.getQueryParamOne(req, key);
if (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);
};
private getOriginalQueryParamAsArray(req: Request, key: string) {
let val = req.query[key];
if (_.isUndefined(val)) {
return [];
}
if (!_.isArray(val)) {
val = [val] as string[];
}
return val as string[];
}
/**
* 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
*/
public getOrderBys = (req: Request): IOrderBy[] => {
const orderBys: IOrderBy[] = [];
const orderByStr = this.getQueryParamOne(req, 'orderBy');
if (utils.isBlank(orderByStr)) {
return orderBys;
}
const tokens: string[] = orderByStr.split(',');
for (let token of tokens) {
token = token.trim();
let key = token;
let direction: OrderByDirection = OrderByDirection.ASC;
if (token.startsWith('+')) {
key = token.substring(1);
} else if (token.startsWith('-')) {
key = token.substring(1);
direction = OrderByDirection.DESC;
}
const orderBy: IOrderBy = {
key,
direction,
};
orderBys.push(orderBy);
}
return orderBys;
};
}
export const httpUtils: HttpUtils = new HttpUtils();