@sera4/essentia 1.1.8 → 1.1.13

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.
package/cache/index.js ADDED
@@ -0,0 +1,188 @@
1
+ "use strict"
2
+
3
+ import { promisify } from "util";
4
+
5
+ const isEmpty = obj => [Object, Array].includes((obj || {}).constructor) && !Object.entries((obj || {})).length;
6
+
7
+ class CacheConfigError extends Error {
8
+ constructor(m) {
9
+ super(m);
10
+ }
11
+ }
12
+
13
+ /* Redis cache helper
14
+ * Example use: Place the following code in your project to setup redis use
15
+ *
16
+ * import { cache } from "@sera4/essentia";
17
+ * const cacheHelper = cache({ password: .., database: .., host: .., port: .. }, "service:"));
18
+ *
19
+ */
20
+
21
+ const convertRedisKeyValueToJson = (v) => {
22
+ return (v && v.length > 0 && v[0] !== null) ? JSON.parse(v) : null;
23
+ }
24
+
25
+ const convertRedisHashToJson = (v) => {
26
+ if (v) {
27
+ var result = {};
28
+ for(var k in v) {
29
+ if (v.hasOwnProperty(k)) {
30
+ result[k] = JSON.parse(v[k]);
31
+ }
32
+ }
33
+ return result;
34
+ } else {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function convertJsonToRedisValue(j) {
40
+ var result = {};
41
+ for (var key in j) {
42
+ if (j.hasOwnProperty(key)) {
43
+ result[key] = JSON.stringify(j[key]);
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+
49
+ class Cache {
50
+ constructor(config, servicePrefix) {
51
+ if ((config == undefined) || isEmpty(config)) {
52
+ throw new CacheConfigError("Invalid cache configuration");
53
+ }
54
+
55
+ if (servicePrefix == null) {
56
+ throw new CacheConfigError("Invalid service prefix");
57
+ }
58
+
59
+ if (config.fake) {
60
+ // used in testing
61
+ this.redisClient = require("fakeredis").createClient("fake backend")
62
+ } else {
63
+ this.redisClient = require("redis").createClient(config);
64
+ }
65
+
66
+ this.redisClient.on("error", (err) => {
67
+ logger.error("Cache/Redis error: ", err);
68
+ });
69
+
70
+ this.servicePrefix = servicePrefix;
71
+ }
72
+
73
+ ping() {
74
+ return this.redisClient.ping();
75
+ }
76
+
77
+ properKey(key, path) {
78
+ let p = this.servicePrefix + key
79
+ if (path)
80
+ p += path
81
+ return p
82
+ }
83
+
84
+ writeHash(path, key, value) {
85
+ return this.redisClient.hsetAsync(properKey(key), key, value);
86
+ }
87
+
88
+ writeHashBlocking(path, key, value) {
89
+ return this.redisClient.hset(properKey(key), key, value);
90
+ }
91
+
92
+ // write multiple hash keys and stringify every value
93
+ writeMultiHash(path, j) {
94
+ return this.redisClient.hmsetAsync(properKey(key), convertJsonToRedisValue(j));
95
+ }
96
+
97
+ readHashKey(obj, key, autoConvert=true) {
98
+ var k = autoConvert;
99
+
100
+ return new Promise((resolve, reject) => {
101
+ this.redisClient.hget(properKey(key, obj), key, (e, r) => {
102
+ if (e) {
103
+ logger.error("Redis hmget error:", e);
104
+ reject(e);
105
+ } else {
106
+ resolve(k ? convertRedisKeyValueToJson(r) : r);
107
+ }
108
+ });
109
+ });
110
+ }
111
+
112
+ readHash(path) {
113
+ return new Promise((resolve, reject) => {
114
+ this.redisClient.hgetall(properKey(key, path), (e, r) => {
115
+ if (e) {
116
+ console.log("Redis hget error:", e);
117
+ reject(e);
118
+ } else {
119
+ resolve(convertRedisHashToJson(r));
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ deleteHashKey(hash, key) {
126
+ return new Promise((resolve, reject) => {
127
+ this.redisClient.hdel(properKey(key, hash), key, (err) => {
128
+ if (err) {
129
+ logger.error("Redis hdel error:", err);
130
+ reject(err);
131
+ } else {
132
+ resolve();
133
+ }
134
+ });
135
+ });
136
+ }
137
+
138
+ get(key) {
139
+ return this.redisClient.getAsync(properKey(key));
140
+ }
141
+
142
+ getAsync(key) {
143
+ return promisify(this.redisClient.get(key)).bind(this.redisClient)
144
+ }
145
+
146
+
147
+ /* a single key write with optional expiry */
148
+ // returns bluebird promise
149
+ writeKey(key, value, expiry) {
150
+ key = this.servicePrefix + key;
151
+ if (expiry) {
152
+ return this.redisClient.setAsync(key, value, "EX", expiry);
153
+ } else {
154
+ return this.redisClient.setAsync(key, value);
155
+ }
156
+ }
157
+
158
+ deleteKey(key) {
159
+ return redisClient.delAsync(properKey(key));
160
+ }
161
+
162
+ expire(path, expiry) {
163
+ return this.redisClient.expire(path, expiry);
164
+ }
165
+
166
+ exists(path) {
167
+ return this.redisClient.exists(path);
168
+ }
169
+
170
+ deleteAllKeys() {
171
+ return new Promise((resolve, reject) => {
172
+ if(process.env.NODE_ENV === "production") {
173
+ throw new Error("Clearing system cache is not permitted in production");
174
+ }
175
+ this.redisClient.flushdb((err, res) => {
176
+ if (err) {
177
+ reject(err);
178
+ } else {
179
+ resolve(res);
180
+ }
181
+ });
182
+ });
183
+ }
184
+ }
185
+
186
+ export default function(config, servicePrefix) {
187
+ return new Cache(config, servicePrefix)
188
+ }
@@ -0,0 +1,40 @@
1
+ import logger from "../logger/s4-logger.js";
2
+ let enabled = false;
3
+
4
+ const helpers = {
5
+ debug(...args) {
6
+ if (enabled) {
7
+ logger.debug(":: HealthCheck ::", args);
8
+ }
9
+ },
10
+
11
+ log(...args) {
12
+ if (enabled) {
13
+ logger.debug(":: HealthCheck ::", args);
14
+ }
15
+ },
16
+
17
+ warn(...args) {
18
+ if (enabled) {
19
+ logger.warn(":: HealthCheck ::", args);
20
+ }
21
+ },
22
+
23
+ info(...args) {
24
+ if (enabled) {
25
+ logger.info(":: HealthCheck ::", args);
26
+ }
27
+ },
28
+
29
+ error(...args) {
30
+ if (enabled) {
31
+ logger.error(":: HealthCheck ::", args);
32
+ }
33
+ },
34
+
35
+ setEnabled(e) {
36
+ enabled = e;
37
+ }
38
+ }
39
+
40
+ export default helpers;
package/health/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict"
2
2
 
3
3
  import pathHelper from "path";
4
+ import logger from "./health-logger.js";
4
5
 
5
6
  class HealthCheckError extends Error {
6
7
  constructor(m) {
@@ -15,12 +16,16 @@ class HealthCheckError extends Error {
15
16
  * const router = require('express').Router();
16
17
  *
17
18
  * ....
19
+ *
20
+ * const checkRedis = async () => { await cache.ping(); }
21
+ * hc.setupCheck({ name: "redis", checkResult: false, critical: true, healthFunction: checkRedis });
18
22
  * app.use(hc.defaultPath(serverConfigs.api_prefix), hc.routes(router));
19
23
  *
20
24
  */
21
-
22
25
  class HealthCheck {
23
- constructor(appendData = {}) {
26
+ constructor(appendData = {}, enableLogging = true) {
27
+ logger.setEnabled(enableLogging);
28
+
24
29
  this.defaultResponse = {
25
30
  ...
26
31
  appendData,
@@ -29,11 +34,13 @@ class HealthCheck {
29
34
  };
30
35
 
31
36
  this.defaultPathRoute = `health_check\(.*\)?`
37
+ this.suppressLogs = false;
32
38
  this.lastCallId = 0; // default start point/integer
33
39
  this.healthChecks = [];
34
40
  }
35
41
 
36
- /* call this with an async function, must support wait capability
42
+ /* Deprecated and replaced by setupCheck
43
+ * call this with an async function, must support wait capability
37
44
  *
38
45
  * const dbCheck = async () => {
39
46
  * return { name: "db", "result": "success", critical: false };
@@ -60,15 +67,40 @@ class HealthCheck {
60
67
  return router
61
68
  }
62
69
 
63
- /* performs health checks and returns 200 if true and 500 if not */
70
+ // v2 version of health check, replaced addCheck
71
+ // example:
72
+ // const checkRedisFcn = async () => { await cache.ping().then(() => true) }
73
+ // setupCheck({ name: "redis", checkResult: false, critical: false, healthFunction: checkRedisFcn });
74
+ setupCheck(config) {
75
+ this.healthChecks.push(config);
76
+ }
77
+
78
+ /* performs health checks and returns 200 if true and 500 if not */
64
79
  async checkHealth(req, res) {
65
80
  const checkFormat = pathHelper.extname(req.baseUrl);
66
81
 
67
- try {
68
- const result = await Promise.all(this.healthChecks.map(async healthCallback => {
69
- return await healthCallback();
70
- }));
82
+ const result = await Promise.all(this.healthChecks.map(async hc => {
83
+ if (typeof(hc) === "function") {
84
+ try {
85
+ return await hc();
86
+ return { "result": "success", critical: false };
87
+ } catch(e) {
88
+ return { name: "unknown", result: 'fail', status: e.status }
89
+ }
90
+ } else {
91
+ let res = { name: hc.name, critical: hc.critical }
92
+ try {
93
+ await hc.healthFunction();
94
+ return { ...res, "result": "success" };
95
+ } catch(e) {
96
+ const appendStatus = e.status ? ` (${e.status})` : ""
97
+ logger.warn(`${hc.name} service failed${appendStatus}` )
98
+ return { ...res, "result": "fail", status: e.status }
99
+ }
100
+ }
101
+ }));
71
102
 
103
+ try {
72
104
  let callId = ++this.lastCallId;
73
105
 
74
106
  // start the counter over
@@ -78,10 +110,18 @@ class HealthCheck {
78
110
 
79
111
  let status = this.defaultResponse.status;
80
112
 
113
+ let nonCriticalFail = false;
114
+ let criticalFail = false;
115
+
81
116
  result.some(e => {
82
117
  if (e.result.match(/fail/)) {
83
- status = 500;
84
- return true; // break
118
+ if (e.critical == false) {
119
+ nonCriticalFail = true;
120
+ } else {
121
+ criticalFail = true;
122
+ status = 500;
123
+ return true; // break
124
+ }
85
125
  }
86
126
  })
87
127
 
@@ -89,33 +129,36 @@ class HealthCheck {
89
129
  res.setHeader('check', callId)
90
130
 
91
131
  if (checkFormat === ".json") {
92
- const payload = { "check": callId }
132
+ const payload = { "check": callId, healthy: "true", message: "success" }
93
133
 
94
- if (status === 200) {
134
+ if (criticalFail) {
95
135
  res.status(status).json({
96
136
  ... payload,
97
- "healthy":true,
98
- "message":"success"
137
+ "healthy": false,
138
+ "message": "fail",
139
+ "services": result
99
140
  });
100
- } else {
141
+ } else if (nonCriticalFail) {
101
142
  res.status(status).json({
102
143
  ... payload,
103
- "healthy": false,
104
144
  "message": "fail",
105
- "services": [result]
145
+ "services": result
106
146
  });
147
+ } else if (status === 200) {
148
+ res.status(status).json(payload);
107
149
  }
108
150
  } else {
109
151
  let status_message;
110
- if (status === 200) {
111
- status_message = "success"
112
- } else {
152
+ if (criticalFail) {
113
153
  status_message = "fail"
114
- }
154
+ } else if ((status === 200) || nonCriticalFail) {
155
+ status_message = "success"
156
+ }
115
157
 
116
158
  res.status(status).send(status_message);
117
159
  }
118
160
  } catch(error) {
161
+ console.debug("error: ", error)
119
162
  res.status(500).send();
120
163
  }
121
164
  }
@@ -4,6 +4,30 @@ import jsonStringify from "fast-safe-stringify";
4
4
  const colorizer = winston.format.colorize();
5
5
  import "winston-logstash";
6
6
 
7
+ // IETF RFC5424:
8
+ // Severity of all levels is assumed to be numerically ascending from most important to least important.
9
+ const levels = {
10
+ error: 0,
11
+ warn: 1,
12
+ info: 2,
13
+ debug: 3,
14
+ trace: 4,
15
+ };
16
+
17
+ // These colors are not used, however if colors are not defined for custom levels
18
+ // then winston will throw an exception.
19
+ winston.addColors({
20
+ trace: 'white'
21
+ });
22
+
23
+ const defaultLevel = process.env.NODE_ENV === 'production' ? 'info' : 'debug';
24
+
25
+ // These colors are not used, however if colors are not defined for custom levels
26
+ // then winston will throw an exception.
27
+ winston.addColors({
28
+ trace: 'white'
29
+ });
30
+
7
31
  const devFormat = {
8
32
  transform(info) {
9
33
  const { message } = info;
@@ -16,11 +40,13 @@ const devFormat = {
16
40
  };
17
41
 
18
42
  const defaultTransport = new winston.transports.Console({
19
- format: devFormat
43
+ format: devFormat,
44
+ level: 'trace'
20
45
  });
21
46
 
22
47
  const logger = winston.createLogger({
23
- level: 'debug',
48
+ levels: levels,
49
+ level: defaultLevel,
24
50
  defaultMeta: { },
25
51
  transports: [defaultTransport]
26
52
  });
@@ -31,15 +57,13 @@ class S4Logger {
31
57
  return;
32
58
  }
33
59
 
34
- if (options.level && ["debug", "info", "error", "warn"].indexOf(options.level.toLowerCase())) {
35
- logger.level = options.level;
36
- }
60
+ this.setLevel(options.level);
37
61
 
38
62
  logger.defaultMeta.service = options.service;
39
63
  switch(options.format.toLowerCase()) {
40
64
  case "logstash":
41
- defaultTransport.format = winston.format.logstash();
42
- break;
65
+ defaultTransport.format = winston.format.logstash();
66
+ break;
43
67
  case "json":
44
68
  defaultTransport.format = winston.format.json();
45
69
  break;
@@ -50,7 +74,7 @@ class S4Logger {
50
74
  }
51
75
 
52
76
  setLevel(level) {
53
- if (level && ["debug", "info", "error", "warn"].indexOf(level.toLowerCase())) {
77
+ if (level && ["trace", "debug", "info", "error", "warn"].indexOf(level.toLowerCase())) {
54
78
  logger.level = level;
55
79
  }
56
80
  }
@@ -67,6 +91,10 @@ class S4Logger {
67
91
  });
68
92
  }
69
93
 
94
+ trace(...args) {
95
+ logger.trace(util.format(...args));
96
+ }
97
+
70
98
  debug(...args) {
71
99
  logger.debug(util.format(...args));
72
100
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@sera4/essentia",
3
- "version": "1.1.8",
3
+ "version": "1.1.13",
4
4
  "description": "A library of utilities for Teleporte Web Services",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "test": "NODE_ENV=test ./node_modules/.bin/mocha --forbid-only --reporter mocha-junit-reporter --reporter-options mochaFile=./test-results/result.xml --exit",
9
- "test:dev": "NODE_ENV=test ./node_modules/.bin/mocha --watch --forbid-only",
9
+ "test:dev": "NODE_ENV=test ./node_modules/.bin/mocha --forbid-only",
10
10
  "test:only": "NODE_ENV=test ./node_modules/.bin/mocha --watch",
11
11
  "test:pretty": "NODE_ENV=test ./node_modules/.bin/mocha --reporter mocha-simple-html-reporter --reporter-options output=./test-results/result.html --exit",
12
12
  "last-commit": "node last-commit.js"
@@ -28,13 +28,13 @@
28
28
  "sinon": "^5.1.1"
29
29
  },
30
30
  "dependencies": {
31
- "amqplib": "^0.7.1",
31
+ "amqplib": "^0.8.0",
32
32
  "async": "^2.6.3",
33
33
  "axios": "^0.21.1",
34
34
  "fast-safe-stringify": "^2.0.7",
35
35
  "git-rev": "^0.2.1",
36
36
  "lodash": "^4.17.21",
37
- "url-parse": "^1.5.1",
37
+ "url-parse": "^1.5.3",
38
38
  "uuid": "^3.4.0",
39
39
  "winston": "^3.3.3",
40
40
  "winston-logstash": "^0.4.0"
package/package.tar.gz CHANGED
Binary file
package/queue/index.js CHANGED
@@ -29,6 +29,7 @@ class S4Queue {
29
29
  */
30
30
  async testConnection() {
31
31
  if (!this.isConnected()) {
32
+ this.connectionTestChannel = null;
32
33
  throw("queue service not connected")
33
34
  }
34
35
 
@@ -72,12 +73,16 @@ class S4Queue {
72
73
  amqp.connect(options.connectionUrl, socketOptions, (err, conn) => {
73
74
 
74
75
  if (err) {
75
- return reject(err);
76
+ logger.error("Error while connecting", err)
77
+ logger.error("Attempting again in", this.retry, "seconds");
78
+ return setTimeout(() => this.openConnection(options), this.retry * 1000);
76
79
  }
77
80
 
78
81
  this.connection = conn;
79
82
  if (!this.connection) {
80
83
  return reject(new Error("Failed to obtain connection"));
84
+ } else {
85
+ this.updatePubsAndSubsConnectionRef(conn);
81
86
  }
82
87
 
83
88
  this.connection.on("error", (err) => {
@@ -91,8 +96,8 @@ class S4Queue {
91
96
  logger.error("connection closed");
92
97
  this.connection = null;
93
98
  if (err) {
94
- logger.debug("connection closed due to error:", err);
95
- logger.debug("Reconnecting in", this.retry, "seconds");
99
+ logger.error("connection closed due to error:", err);
100
+ logger.error("Reconnecting in", this.retry, "seconds");
96
101
  setTimeout(() => this.openConnection(options), this.retry * 1000);
97
102
  } else {
98
103
  // If there was no error, it is us who closed
@@ -171,6 +176,22 @@ class S4Queue {
171
176
  this.pubs[name] = p;
172
177
  }
173
178
 
179
+ /**
180
+ * Registers a new fanout publisher on the given exchange.
181
+ * If exchange doesn't exist, one will be created.
182
+ * @param {String} exchange the name of the exchange on AMQP service.
183
+ */
184
+ async registerFanoutPublisher(exchange) {
185
+ const name = exchange;
186
+ if (Object.keys(this.pubs).includes(name)) {
187
+ throw new Error(`Publisher ${name} already registered`);
188
+ }
189
+
190
+ const p = new Publisher(exchange, 'fanout', true, name);
191
+ await p.init(this.connection);
192
+ this.pubs[name] = p;
193
+ }
194
+
174
195
  /**
175
196
  * Registers a new direct subscriber on a given exchange and queue.
176
197
  * If exchange and queue don't exists, they'll be created
@@ -273,6 +294,26 @@ class S4Queue {
273
294
  return await p.publish({key: topic, content});
274
295
  }
275
296
 
297
+ /**
298
+ * Binds an exchange to a queue
299
+ * @param {String} exchange the name of the exchange.
300
+ * @param {String} queue the name of the queue.
301
+ * @param {pattern} (optional) pattern for routing key
302
+ */
303
+ async bindQueueToExchange(exchange, queue, pattern) {
304
+ const p = this.pubs[exchange];
305
+
306
+ if (!p) {
307
+ throw(new Error(`No PUB for ${exchange} found`));
308
+ }
309
+
310
+ if (!queue) {
311
+ throw(new Error(`Binding a queue requires a queue name`));
312
+ }
313
+
314
+ return await p.bindQueue(queue, pattern)
315
+ }
316
+
276
317
  /**
277
318
  * Adds a listener for a specific key.
278
319
  * If a subscriber that was regitered was found
@@ -289,6 +330,29 @@ class S4Queue {
289
330
  }
290
331
  });
291
332
  }
333
+
334
+ /**
335
+ * Updates the connection reference to all the previously
336
+ * registered publishers and subscribers.
337
+ * After we successfully establish a connection and register pubs/subs,
338
+ * it's possible for the connection to break. This object will try
339
+ * to re-establish a new connection every 3 seconds (or whatever configuration is set)
340
+ * and if a new connection is established, we need to pass that connection to
341
+ * all our pubs/subs, otherwise they'll be operating with old/stale references
342
+ * @param {Object} connRef a new connection reference to the AMQP service
343
+ */
344
+ updatePubsAndSubsConnectionRef(connRef) {
345
+ Object.keys(this.pubs).forEach(k => {
346
+ logger.debug(`Updating publisher ${k} with a new connection object`);
347
+ const p = this.pubs[k];
348
+ p.init(connRef);
349
+ });
350
+ Object.keys(this.subs).forEach(k => {
351
+ logger.debug(`Updating subscriber ${k} with a new connection object`);
352
+ const s = this.subs[k];
353
+ s.init(connRef);
354
+ });
355
+ }
292
356
  }
293
357
 
294
358
  const queue = new S4Queue();
@@ -5,9 +5,7 @@ import uuidv4 from "uuid/v4.js";
5
5
  * A publisher for AMQP service
6
6
  */
7
7
  export class Publisher {
8
-
9
8
  /**
10
- *
11
9
  * @param {String} exchange AMQP exchange name.
12
10
  * @param {String} type direct|fanout| etc (supported types by RabbitMQ).
13
11
  * @param {boolean} durable true if the messages should persist service restarts.
@@ -81,4 +79,8 @@ export class Publisher {
81
79
 
82
80
  return parcel.tracking_id;
83
81
  }
82
+
83
+ async bindQueue(queue, pattern, args) {
84
+ await this.channel.bindQueue(queue, this.exchange, pattern, args)
85
+ }
84
86
  }
@@ -7,9 +7,8 @@ const { omitBy, isNil } = lodash;
7
7
  /* A class which safely wraps an axios connection in order to pass the current user through to
8
8
  * the next service. Useful when one of our microservices requires auth as either an admin (sera4tal)
9
9
  * or as the end user (example: DELETE /auth/sessions/:id
10
- *
11
- * similar to proxy however this isn't a proxy, it's a full out new call that we can manage
12
10
  *
11
+ * Similar to proxy however this isn't a proxy, it's a full out new call that we can manage
13
12
  * each method requires a res.locals from a pre-authd session
14
13
  */
15
14
  class SafeProxy {