@villedemontreal/http-request 7.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,405 @@
1
+ import { IOrderBy, OrderByDirection, Timer, utils } from '@villedemontreal/general-utils';
2
+ import { Request } from 'express';
3
+ import httpHeaderFieldsTyped from 'http-header-fields-typed';
4
+ import * as _ from 'lodash';
5
+ import * as superagent from 'superagent';
6
+ import { configs } from './config/configs';
7
+ import { constants } from './config/constants';
8
+ import { createLogger } from './utils/logger';
9
+
10
+ const logger = createLogger('HttpUtils');
11
+
12
+ /**
13
+ * HTTP utilities
14
+ */
15
+ export class HttpUtils {
16
+ private readonly REQ_PARAMS_LOWERCASED = '__queryParamsLowercased';
17
+
18
+ /**
19
+ * Remove first and last slash of the string unless the string is the part after protocol (http://)
20
+ */
21
+ public removeSlashes(text: string) {
22
+ if (text) {
23
+ let start;
24
+ let end: number;
25
+ start = 0;
26
+ while (start < text.length && text[start] === '/') {
27
+ start++;
28
+ }
29
+ end = text.length - 1;
30
+ while (end > start && text[end] === '/') {
31
+ end--;
32
+ }
33
+
34
+ let result = text.substring(start, end + 1);
35
+ // handle exception of the protocol that's followed with 2 slashes after the semi-colon.
36
+ if (result && result[result.length - 1] === ':') {
37
+ result += '/';
38
+ }
39
+ return result;
40
+ }
41
+ return text;
42
+ }
43
+
44
+ /**
45
+ * Join few parts of an url to a final string
46
+ */
47
+ public urlJoin(...args: string[]) {
48
+ return _.map(args, this.removeSlashes)
49
+ .filter(x => !!x)
50
+ .join('/');
51
+ }
52
+
53
+ /**
54
+ * Sends a HTTP request built with Superagent.
55
+ *
56
+ * Will add the proper Correlation Id and will write
57
+ * useful logs.
58
+ *
59
+ * IMPORTANT : this method does NOT throw an Error on a
60
+ * 4XX-5XX status response! It will return it the same way
61
+ * it returns a 200 response and it is up to the calling code
62
+ * to validate the actual response's status. For example
63
+ * by using :
64
+ *
65
+ * if(response.ok) {...}
66
+ *
67
+ * and/or by checking the status :
68
+ *
69
+ * if(response.status === 404) {...}
70
+ *
71
+ * An error will be thrown only when a network problem occures or
72
+ * if the target server can't be reached.
73
+ *
74
+ * This is different from SuperAgent's default behavior that DOES
75
+ * throw an error on 4XX-5XX status responses.
76
+ *
77
+ */
78
+ public async send(request: superagent.SuperAgentRequest): Promise<superagent.Response> {
79
+ if (_.isNil(request)) {
80
+ throw new Error(`The request object can't be empty`);
81
+ }
82
+
83
+ if ('status' in request) {
84
+ throw new Error(
85
+ `The request object must be of type SuperAgentRequest. Make sure this object has NOT already been awaited ` +
86
+ `prior to being passed here!`
87
+ );
88
+ }
89
+
90
+ if (!request.url || request.url.indexOf('://') < 0) {
91
+ throw new Error(`The URL in your request MUST have a protocol and a hostname. Received: ${request.url}`);
92
+ }
93
+
94
+ if (utils.isBlank(request.get(httpHeaderFieldsTyped.X_CORRELATION_ID))) {
95
+ const cid = configs.correlationId;
96
+ if (!utils.isBlank(cid)) {
97
+ request.set(httpHeaderFieldsTyped.X_CORRELATION_ID, cid);
98
+ }
99
+ }
100
+
101
+ // ==========================================
102
+ // Adds timeouts, if they are not already set.
103
+ // ==========================================
104
+ const responseTimeoutRequestVarName = '_responseTimeout';
105
+ const timeoutRequestVarName = '_timeout';
106
+ request.timeout({
107
+ response:
108
+ request[responseTimeoutRequestVarName] !== undefined
109
+ ? request[responseTimeoutRequestVarName]
110
+ : constants.request.timeoutsDefault.response,
111
+ deadline:
112
+ request[timeoutRequestVarName] !== undefined
113
+ ? request[timeoutRequestVarName]
114
+ : constants.request.timeoutsDefault.deadline
115
+ });
116
+
117
+ logger.debug({
118
+ sendingCorrelationIdHeader: request.get(httpHeaderFieldsTyped.X_CORRELATION_ID) || null,
119
+ url: request.url,
120
+ method: request.method,
121
+ msg: `Http Client - Start request to ${request.method} ${request.url}`
122
+ });
123
+
124
+ let result;
125
+ const timer = new Timer();
126
+ try {
127
+ result = await request;
128
+ } catch (err) {
129
+ // ==========================================
130
+ // SuperAgent throws a error on 4XX/5XX status responses...
131
+ // But we prefere to return those responses as regular
132
+ // ones and leave it to the caling code to validate
133
+ // the status! That way, we can differenciate between
134
+ // a 4XX/5XX result and a *real* error, for example if
135
+ // the request can't be sent because of a network
136
+ // error....
137
+ // ==========================================
138
+ if (err.status && err.response) {
139
+ result = err.response;
140
+ } else {
141
+ // ==========================================
142
+ // Real error!
143
+ // ==========================================
144
+ logger.debug({
145
+ error: err,
146
+ url: request.url,
147
+ method: request.method,
148
+ timeTaken: timer.toString(),
149
+ msg: `Http Client - End request ERROR request to ${request.method} ${request.url}`
150
+ });
151
+
152
+ throw {
153
+ msg: `An error occured while making the HTTP request to ${request.method} ${request.url}`,
154
+ originalError: err
155
+ };
156
+ }
157
+ }
158
+
159
+ logger.debug({
160
+ url: request.url,
161
+ method: request.method,
162
+ statusCode: result.status,
163
+ timeTaken: timer.toString(),
164
+ msg: `Http Client - End request to ${request.method} ${request.url}`
165
+ });
166
+
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * Gets all the values of a querystring parameter.
172
+ * Manages the fact that we may use insensitive routing.
173
+ *
174
+ * A querystring parameter may indeed contains multiple values. For
175
+ * example : "path?name=aaa&name=bbb" will result in an
176
+ * *array* when getting the "name" parameter : ['aaa', 'bbb'].
177
+ *
178
+ * @returns all the values of the parameters as an array (even if
179
+ * only one value is found) or an empty array if none are found.
180
+ */
181
+ public getQueryParamAll(req: Request, key: string): string[] {
182
+ if (!req || !req.query || !key) {
183
+ return [];
184
+ }
185
+
186
+ // ==========================================
187
+ // URL parsing is case sensitive. We can
188
+ // directly return the params as an array here.
189
+ // ==========================================
190
+ if (configs.isUrlCaseSensitive) {
191
+ return this.getOriginalQueryParamAsArray(req, key);
192
+ }
193
+
194
+ // ==========================================
195
+ // The URL parsing is case *insensitive* here.
196
+ // We need more work to make sure we merge
197
+ // params in a case insensitive manner.
198
+ // ==========================================
199
+ if (!req[this.REQ_PARAMS_LOWERCASED]) {
200
+ req[this.REQ_PARAMS_LOWERCASED] = [];
201
+ Object.keys(req.query).forEach((keyExisting: string) => {
202
+ const keyLower = keyExisting.toLowerCase();
203
+
204
+ if (keyLower in req[this.REQ_PARAMS_LOWERCASED]) {
205
+ req[this.REQ_PARAMS_LOWERCASED][keyLower].push(req.query[keyExisting]);
206
+ } else {
207
+ let val = req.query[keyExisting];
208
+ if (!_.isArray(val)) {
209
+ val = [val] as string[];
210
+ }
211
+ req[this.REQ_PARAMS_LOWERCASED][keyLower] = val;
212
+ }
213
+ });
214
+ }
215
+
216
+ const values = req[this.REQ_PARAMS_LOWERCASED][key.toLowerCase()];
217
+ return values || [];
218
+ }
219
+
220
+ /**
221
+ * Get the last value of a querystring parameter.
222
+ * Manages the fact that we may use insensitive routing.
223
+ *
224
+ * A querystring parameter may indeed contains multiple values. For
225
+ * example : "path?name=aaa&name=bbb" will result in an
226
+ * *array* when getting the "name" parameter : ['aaa', 'bbb'].
227
+ *
228
+ * In many situation, we only want to deal withy a single value.
229
+ * This function return the last value of a query param.
230
+ *
231
+ * @returns the last parameter with that key or `undefined` if
232
+ * not found.
233
+ */
234
+ public getQueryParamOne(req: Request, key: string): string {
235
+ const values = this.getQueryParamAll(req, key);
236
+ if (!values || values.length === 0) {
237
+ return undefined;
238
+ }
239
+
240
+ return values[values.length - 1];
241
+ }
242
+
243
+ /**
244
+ * Get the last value of a querystring parameter *as a Date*.
245
+ * The parameter must be parsable using `new Date(xxx)`.
246
+ * It is recommended to always use ISO-8601 to represent dates
247
+ * (ex: "2020-04-21T17:13:33.107Z").
248
+ *
249
+ * If the parameter is found but can't be parsed to a Date,
250
+ * by default an `Error` is thrown. But if `errorHandler`
251
+ * is specified, it is called instead. This allows you
252
+ * to catch the error and throw a custom error, for
253
+ * example by using `throw createInvalidParameterError(xxx)`
254
+ * in an API.
255
+ *
256
+ * Manages the fact that we may use insensitive routing.
257
+ *
258
+ * @returns the last parameter with that key as a Date
259
+ * or `undefined` if not found.
260
+ * @throws An Error if the parameter is found but can't be parsed
261
+ * to a Date and no `errorHandler` is specified.
262
+ */
263
+ public getQueryParamOneAsDate = (
264
+ req: Request,
265
+ key: string,
266
+ errorHandler?: (errMsg: string, value?: string) => any
267
+ ): Date => {
268
+ const dateStr = this.getQueryParamOne(req, key);
269
+ let date: Date;
270
+ if (!utils.isBlank(dateStr)) {
271
+ date = new Date(dateStr);
272
+ if (isNaN(date.getTime())) {
273
+ const errorMsg = `Not a valid parsable date: "${dateStr}"`;
274
+ if (errorHandler) {
275
+ return errorHandler(errorMsg, dateStr);
276
+ }
277
+ throw new Error(errorMsg);
278
+ }
279
+ }
280
+ return date;
281
+ };
282
+
283
+ /**
284
+ * Get the last value of a querystring parameter *as a Number*.
285
+ * The parameter must be parsable using `Number(xxx)`.
286
+ *
287
+ * If the parameter is found but can't be parsed to a Number,
288
+ * by default an `Error` is thrown. But if `errorHandler`
289
+ * is specified, it is called instead. This allows you
290
+ * to catch the error and throw a custom error, for
291
+ * example by using `throw createInvalidParameterError(xxx)`
292
+ * in an API.
293
+ *
294
+ * Manages the fact that we may use insensitive routing.
295
+ *
296
+ * @returns the last parameter with that key as a Number
297
+ * or `undefined` if not found.
298
+ * @throws An Error if the parameter is found but can't be parsed
299
+ * to a Number and no `errorHandler` is specified.
300
+ */
301
+ public getQueryParamOneAsNumber = (
302
+ req: Request,
303
+ key: string,
304
+ errorHandler?: (errMsg: string, value?: string) => any
305
+ ): number => {
306
+ const numberStr = this.getQueryParamOne(req, key);
307
+ let val: number;
308
+ if (!utils.isBlank(numberStr)) {
309
+ val = Number(numberStr);
310
+ if (isNaN(val)) {
311
+ const errorMsg = `Not a valid number: "${numberStr}"`;
312
+ if (errorHandler) {
313
+ return errorHandler(errorMsg, numberStr);
314
+ }
315
+ throw new Error(errorMsg);
316
+ }
317
+ }
318
+ return val;
319
+ };
320
+
321
+ /**
322
+ * Get the last value of a querystring parameter *as a boolean*.
323
+ * The value must be "true" or "false" (case insensitive) to
324
+ * be considered as a valid boolean. For example, the value '1'
325
+ * is invalid.
326
+ *
327
+ * @returns the last parameter with that key as a boolean
328
+ * or `undefined` if not found.
329
+ * @throws An Error if the parameter is found but can't be parsed
330
+ * to a valid boolean and no `errorHandler` is specified.
331
+ */
332
+ public getQueryParamOneAsBoolean = (
333
+ req: Request,
334
+ key: string,
335
+ errorHandler?: (errMsg: string, value?: string) => any
336
+ ): boolean => {
337
+ const boolStr = this.getQueryParamOne(req, key);
338
+ if (utils.isBlank(boolStr)) {
339
+ return undefined;
340
+ }
341
+
342
+ if (boolStr.toLowerCase() === 'true') {
343
+ return true;
344
+ }
345
+
346
+ if (boolStr.toLowerCase() === 'false') {
347
+ return false;
348
+ }
349
+
350
+ const errorMsg = `Not a valid boolean value: "${boolStr}"`;
351
+ if (errorHandler) {
352
+ return errorHandler(errorMsg, boolStr);
353
+ }
354
+ throw new Error(errorMsg);
355
+ };
356
+
357
+ private getOriginalQueryParamAsArray(req: Request, key: string) {
358
+ let val = req.query[key];
359
+ if (_.isUndefined(val)) {
360
+ return [];
361
+ }
362
+ if (!_.isArray(val)) {
363
+ val = [val] as string[];
364
+ }
365
+ return val as string[];
366
+ }
367
+
368
+ /**
369
+ * Gets the "IOrderBy[]" from the querystring parameters
370
+ * of a search request.
371
+ *
372
+ * @see https://confluence.montreal.ca/pages/viewpage.action?spaceKey=AES&title=REST+API#RESTAPI-Tridelarequ%C3%AAte
373
+ */
374
+ public getOrderBys = (req: Request): IOrderBy[] => {
375
+ const orderBys: IOrderBy[] = [];
376
+
377
+ const orderByStr = this.getQueryParamOne(req, 'orderBy');
378
+ if (utils.isBlank(orderByStr)) {
379
+ return orderBys;
380
+ }
381
+
382
+ const tokens: string[] = orderByStr.split(',');
383
+ for (let token of tokens) {
384
+ token = token.trim();
385
+
386
+ let key = token;
387
+ let direction: OrderByDirection = OrderByDirection.ASC;
388
+ if (token.startsWith('+')) {
389
+ key = token.substring(1);
390
+ } else if (token.startsWith('-')) {
391
+ key = token.substring(1);
392
+ direction = OrderByDirection.DESC;
393
+ }
394
+
395
+ const orderBy: IOrderBy = {
396
+ key,
397
+ direction
398
+ };
399
+ orderBys.push(orderBy);
400
+ }
401
+
402
+ return orderBys;
403
+ };
404
+ }
405
+ export let httpUtils: HttpUtils = new HttpUtils();
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './httpUtils';
2
+
3
+ // ==========================================
4
+ // We do not export the configs instance itself,
5
+ // only the "init()" method, so we can define
6
+ // required parameters.
7
+ // ==========================================
8
+ export * from './config/init';
@@ -0,0 +1,53 @@
1
+ import { ILogger, initLogger, LazyLogger, Logger, LoggerConfigs, LogLevel } from '@villedemontreal/logger';
2
+ import { configs } from '../config/configs';
3
+
4
+ let testingLoggerLibInitialised = false;
5
+
6
+ /**
7
+ * Creates a Logger.
8
+ */
9
+ export function createLogger(name: string): ILogger {
10
+ // ==========================================
11
+ // We use a LazyLogger so the real Logger
12
+ // is only created when the first
13
+ // log is actually performed... At that point,
14
+ // our "configs.loggerCreator" configuration
15
+ // must have been set by the code using our library!
16
+ //
17
+ // This pattern allows calling code to import
18
+ // modules from us in which a logger is
19
+ // created in the global scope :
20
+ //
21
+ // let logger = createLogger('someName');
22
+ //
23
+ // Without a Lazy Logger, the library configurations
24
+ // would at that moment *not* have been set yet
25
+ // (by the calling code) and an Error would be thrown
26
+ // because the "configs.loggerCreator" is required.
27
+ // ==========================================
28
+ return new LazyLogger(name, (nameArg: string) => {
29
+ return configs.loggerCreator(nameArg);
30
+ });
31
+ }
32
+
33
+ function initTestingLoggerConfigs() {
34
+ const loggerConfig: LoggerConfigs = new LoggerConfigs(() => 'test-cid');
35
+ loggerConfig.setLogLevel(LogLevel.DEBUG);
36
+ initLogger(loggerConfig);
37
+ }
38
+
39
+ /**
40
+ * A Logger that uses a dummy cid provider.
41
+ *
42
+ * Only use this when running the tests!
43
+ */
44
+ export function getTestingLoggerCreator(): (name: string) => ILogger {
45
+ return (name: string): ILogger => {
46
+ if (!testingLoggerLibInitialised) {
47
+ initTestingLoggerConfigs();
48
+ testingLoggerLibInitialised = true;
49
+ }
50
+
51
+ return new Logger(name);
52
+ };
53
+ }
@@ -0,0 +1,14 @@
1
+ import { init } from '../config/init';
2
+ import { getTestingLoggerCreator } from '../utils/logger';
3
+
4
+ /**
5
+ * Call this when your need to set
6
+ * *Testing* configurations to the current
7
+ * library, without the need for a calling code
8
+ * to do so.
9
+ *
10
+ * A test Correlation Id will be used!
11
+ */
12
+ export function setTestingConfigurations(caseSensitive = false): void {
13
+ init(getTestingLoggerCreator(), () => 'test-cid', caseSensitive);
14
+ }