soajs.controller 4.3.12 → 4.3.14

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.
@@ -292,11 +292,24 @@ Tracks user activity by notifying a service (typically URAC) of user access.
292
292
  "serviceName": "urac", // Service to notify
293
293
  "serviceVersion": "3", // Service version
294
294
  "api": "/user/last/seen", // API endpoint
295
- "network": "internal" // Optional network identifier
295
+ "network": "internal", // Optional network identifier
296
+ "include": { // Optional whitelist, omit for all requests
297
+ "connectspaces": true
298
+ },
299
+ "targets": [ // Optional extra APIs, same event
300
+ {
301
+ "serviceName": "authenticator",
302
+ "serviceVersion": "1",
303
+ "api": "/my/device/network",
304
+ "method": "put"
305
+ }
306
+ ]
296
307
  }
297
308
  }
298
309
  ```
299
310
 
311
+ See `docs/middleware/lastSeen.md` for the `include` matching rules and the target fields.
312
+
300
313
  | Field | Type | Default | Description |
301
314
  |-------|------|---------|-------------|
302
315
  | `active` | boolean | `false` | Enable/disable activity tracking |
@@ -69,7 +69,18 @@ Located in `registry.custom.gateway.value.lastSeen`:
69
69
  "serviceName": "urac", // Target service
70
70
  "serviceVersion": "3", // Service version
71
71
  "api": "/user/last/seen", // API endpoint
72
- "network": "internal" // Optional network identifier
72
+ "network": "internal", // Optional network identifier
73
+ "include": { // Optional whitelist, omit to trigger on everything
74
+ "connectspaces": true
75
+ },
76
+ "targets": [ // Optional extra APIs notified on the same event
77
+ {
78
+ "serviceName": "authenticator",
79
+ "serviceVersion": "1",
80
+ "api": "/my/device/network",
81
+ "method": "put"
82
+ }
83
+ ]
73
84
  }
74
85
  }
75
86
  ```
@@ -81,6 +92,40 @@ Located in `registry.custom.gateway.value.lastSeen`:
81
92
  | `serviceVersion` | string | `"3"` | Service version |
82
93
  | `api` | string | `"/user/last/seen"` | API endpoint |
83
94
  | `network` | string | - | Network identifier in request body |
95
+ | `include` | object | - | Whitelist of services and APIs, omit to trigger on every request |
96
+ | `targets` | array | - | Extra APIs notified on the same event |
97
+
98
+ ### include
99
+
100
+ Decides whether a request counts as activity. Omitting it triggers on every request.
101
+
102
+ | Form | Meaning |
103
+ |------|---------|
104
+ | service missing from `include` | never triggers |
105
+ | `"service": true` | every API, every method |
106
+ | `"service": {"apis": {"/path": true}}` | that API, every method |
107
+ | `"service": {"apis": {"/path": ["get","put"]}}` | that API, listed methods only |
108
+ | `"apis": {"*": ...}` | any API, checked before the exact paths |
109
+
110
+ API keys accept the service relative path (`/active`) or the full path
111
+ (`/connectspaces/active`), and support path params (`/user/:id`).
112
+
113
+ ### targets
114
+
115
+ Extra APIs notified whenever the main one is. They are not filtered on their own, the
116
+ `include` above has already decided that the request counts as activity, so a target is
117
+ notified on exactly the same set of requests.
118
+
119
+ | Field | Type | Default | Description |
120
+ |-------|------|---------|-------------|
121
+ | `serviceName` | string | required | Service to notify, must be in the registry |
122
+ | `api` | string | required | API endpoint |
123
+ | `serviceVersion` | string | - | Service version, resolved by awareness when absent |
124
+ | `method` | string | `"post"` | HTTP method |
125
+
126
+ Each target receives the same body and the same `soajsinjectobj` header as the main
127
+ notification. A target that is down, unknown to the registry, or missing `serviceName`
128
+ or `api` is skipped without affecting the others.
84
129
 
85
130
  ## Request to URAC
86
131
 
@@ -48,7 +48,7 @@ module.exports = () => {
48
48
  if (obj.service_v) {
49
49
  let san_v = coreLibs.version.sanitize(obj.service_v);
50
50
  if (!aclObj[san_v]) {
51
- obj.req.soajs.log.error("Problem accessing service [" + obj.req.soajs.controller.serviceParams.service_n + "] & version [" + obj.req.soajs.controller.serviceParams.service_v + "]");
51
+ //NOTE: the error context already reports the service and the version
52
52
  return cb(154, null);
53
53
  }
54
54
  aclObj = aclObj[san_v];
@@ -143,6 +143,56 @@ function isMethodMatch(methodConfig, method) {
143
143
  return false;
144
144
  }
145
145
 
146
+ /**
147
+ * Fire and forget the notification at one target.
148
+ *
149
+ * NOTE: the request is not awaited, a target that is down or slow must not affect
150
+ * the original request, which has already been handed to the next middleware.
151
+ *
152
+ * @param req
153
+ * @param lastSeen the custom registry block, carries the shared body
154
+ * @param item {name, version, api, method}
155
+ */
156
+ function notifyTarget(req, lastSeen, item) {
157
+ //NOTE: getHost resolves against the registry, an unknown service would throw on
158
+ // the port lookup below and take the remaining targets down with it
159
+ if (!req.soajs.registry || !req.soajs.registry.services ||
160
+ !req.soajs.registry.services[item.name] || !req.soajs.registry.services[item.name].port) {
161
+ req.soajs.log.debug("lastSeen failed, service not in registry [" + item.name + "@" + item.version + " " + item.api + "]");
162
+ return;
163
+ }
164
+ req.soajs.awareness.getHost(item.name, item.version, (host) => {
165
+ if (!host) {
166
+ req.soajs.log.debug("lastSeen failed with no host for [" + item.name + "@" + item.version + " " + item.api + "]");
167
+ return;
168
+ }
169
+ item.host = host;
170
+ item.port = req.soajs.registry.services[item.name].port;
171
+
172
+ let options = {
173
+ "uri": "http://" + item.host + ":" + item.port + item.api,
174
+ "json": true,
175
+ "method": item.method || "POST"
176
+ };
177
+ if (lastSeen.network) {
178
+ options.body = { "network": lastSeen.network };
179
+ }
180
+ if (req.headers && req.headers.soajsinjectobj) {
181
+ options.headers = { "soajsinjectobj": req.headers.soajsinjectobj };
182
+ }
183
+ httpRequest(options)
184
+ .then(() => { })
185
+ .catch(({ error, body }) => {
186
+ if (error) {
187
+ req.soajs.log.debug("lastSeen failed for [" + item.name + "@" + item.version + " " + item.api + "]");
188
+ req.soajs.log.debug("lastSeen " + error.message);
189
+ } else {
190
+ req.soajs.log.debug("lastSeen ", body);
191
+ }
192
+ });
193
+ });
194
+ }
195
+
146
196
  /**
147
197
  *
148
198
  * @param configuration
@@ -180,39 +230,29 @@ module.exports = () => {
180
230
  if (uracObj) {
181
231
  userId = uracObj._id;
182
232
  }
183
- if (userId) {
184
- req.soajs.awareness.getHost(item.name, item.version, (host) => {
185
- if (host) {
186
- item.host = host;
187
- item.port = req.soajs.registry.services[item.name].port;
188
-
189
- let options = {
190
- "uri": "http://" + item.host + ":" + item.port + item.api,
191
- "json": true,
192
- "method": "POST"
193
- };
194
- if (lastSeen.network) {
195
- options.body = { "network": lastSeen.network };
196
- }
197
- if (req.headers && req.headers.soajsinjectobj) {
198
- options.headers = { "soajsinjectobj": req.headers.soajsinjectobj };
199
- }
200
- httpRequest(options)
201
- .then(() => { })
202
- .catch(({ error, body }) => {
203
- if (error) {
204
- req.soajs.log.debug("lastSeen failed for [" + item.name + "@" + item.version + " " + item.api + "]");
205
- req.soajs.log.debug("lastSeen " + error.message);
206
- } else {
207
- req.soajs.log.debug("lastSeen ", body);
208
- }
209
- });
210
- } else {
211
- req.soajs.log.debug("lastSeen failed with no host for [" + item.name + "@" + item.version + " " + item.api + "]");
233
+ if (!userId) {
234
+ req.soajs.log.debug("lastSeen failed with no userId for [" + item.name + "@" + item.version + " " + item.api + "]");
235
+ return;
236
+ }
237
+
238
+ notifyTarget(req, lastSeen, item);
239
+
240
+ //NOTE: additional targets are notified on the same event, they are not
241
+ // filtered on their own, the include above already decided that this
242
+ // request counts as activity
243
+ if (Array.isArray(lastSeen.targets)) {
244
+ lastSeen.targets.forEach((target) => {
245
+ if (!target || !target.serviceName || !target.api) {
246
+ req.soajs.log.debug("lastSeen target skipped, serviceName and api are required");
247
+ return;
212
248
  }
249
+ notifyTarget(req, lastSeen, {
250
+ "name": target.serviceName,
251
+ "version": target.serviceVersion || null,
252
+ "api": target.api,
253
+ "method": target.method
254
+ });
213
255
  });
214
- } else {
215
- req.soajs.log.debug("lastSeen failed with no userId for [" + item.name + "@" + item.version + " " + item.api + "]");
216
256
  }
217
257
  }
218
258
  }
package/mw/mt/index.js CHANGED
@@ -48,7 +48,7 @@ module.exports = (configuration) => {
48
48
  serviceInfo = req.soajs.controller.serviceParams.registry.versions[req.soajs.controller.serviceParams.version];
49
49
  }
50
50
  if (!serviceInfo) {
51
- req.soajs.log.error("Problem accessing service [" + req.soajs.controller.serviceParams.name + "], API [" + req.soajs.controller.serviceParams.path + "] & version [" + req.soajs.controller.serviceParams.version + "]");
51
+ //NOTE: the error context already reports the service, the api and the version
52
52
  return next(133);
53
53
  }
54
54
  let oauth = true;
@@ -160,12 +160,16 @@ module.exports = (configuration) => {
160
160
  //if this is controller route: /key/permission/get, ignore async waterfall response
161
161
  if (url_keyACL) {
162
162
  if (!req.soajs.uracDriver) {
163
- if (err && err.message) {
164
- req.soajs.log.error(err.message);
165
- }
166
163
  if (err && err.name === "OAuth2Error") {
164
+ //NOTE: the code and the message travel with the error, the
165
+ // error context reports both with full attribution
167
166
  return next(err);
168
167
  }
168
+ //NOTE: the cause is replaced by 158 below, so it is only
169
+ // recoverable from here
170
+ if (err && err.message) {
171
+ req.soajs.log.error(err.message);
172
+ }
169
173
  //doesn't work if you are not logged in
170
174
  return next(158);
171
175
  } else {
@@ -180,7 +184,8 @@ module.exports = (configuration) => {
180
184
  }
181
185
 
182
186
  if (err) {
183
- req.soajs.log.error("Problem accessing service [" + req.soajs.controller.serviceParams.name + "], API [" + req.soajs.controller.serviceParams.path + "] & version [" + req.soajs.controller.serviceParams.version + "]");
187
+ //NOTE: the error context already reports the service, the api and
188
+ // the version alongside the code and the message
184
189
  return next(err);
185
190
  } else {
186
191
  let serviceName = data.req.soajs.controller.serviceParams.name;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.12",
4
+ "version": "4.3.14",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -1009,4 +1009,172 @@ describe("Unit test for: mw - lastSeen", () => {
1009
1009
 
1010
1010
  });
1011
1011
 
1012
+ describe("extra targets tests", () => {
1013
+
1014
+ let buildReq = (lastSeen) => {
1015
+ return {
1016
+ method: "GET",
1017
+ getClientIP: () => {
1018
+ return "127.0.0.1";
1019
+ },
1020
+ soajs: {
1021
+ log: {
1022
+ debug: () => {
1023
+ },
1024
+ error: () => {
1025
+ }
1026
+ },
1027
+ uracDriver: {
1028
+ getProfile: () => {
1029
+ return { _id: "1111111111" };
1030
+ }
1031
+ },
1032
+ awareness: {
1033
+ getHost: (name, version, cb) => {
1034
+ return cb(name + ".fake");
1035
+ }
1036
+ },
1037
+ controller: {
1038
+ serviceParams: {
1039
+ name: "connectspaces",
1040
+ path: "/active",
1041
+ parsedUrl: { pathname: "/connectspaces/active" }
1042
+ }
1043
+ },
1044
+ registry: {
1045
+ services: {
1046
+ urac: { port: 4001 },
1047
+ authenticator: { port: 4002 }
1048
+ },
1049
+ custom: {
1050
+ gateway: {
1051
+ value: { lastSeen: lastSeen }
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ };
1057
+ };
1058
+
1059
+ it("notifies the extra target alongside urac, honouring its method", (done) => {
1060
+ let req = buildReq({
1061
+ active: true,
1062
+ network: "YAYA",
1063
+ targets: [{
1064
+ serviceName: "authenticator",
1065
+ serviceVersion: "1",
1066
+ api: "/my/device/network",
1067
+ method: "put"
1068
+ }]
1069
+ });
1070
+ const uracScope = nock('http://urac.fake:4001')
1071
+ .post('/user/last/seen', "{\"network\":\"YAYA\"}")
1072
+ .reply(200, { result: true });
1073
+ const authScope = nock('http://authenticator.fake:4002')
1074
+ .put('/my/device/network', "{\"network\":\"YAYA\"}")
1075
+ .reply(200, { result: true });
1076
+
1077
+ let functionMw = mw({});
1078
+ functionMw(req, {}, (error) => {
1079
+ assert.ifError(error);
1080
+ });
1081
+ setTimeout(() => {
1082
+ assert.strictEqual(uracScope.isDone(), true, "urac was not notified");
1083
+ assert.strictEqual(authScope.isDone(), true, "the extra target was not notified");
1084
+ done();
1085
+ }, 100);
1086
+ });
1087
+
1088
+ it("defaults the extra target to post when no method is set", (done) => {
1089
+ let req = buildReq({
1090
+ active: true,
1091
+ targets: [{
1092
+ serviceName: "authenticator",
1093
+ serviceVersion: "1",
1094
+ api: "/my/device/network"
1095
+ }]
1096
+ });
1097
+ nock('http://urac.fake:4001').post('/user/last/seen').reply(200, { result: true });
1098
+ const authScope = nock('http://authenticator.fake:4002')
1099
+ .post('/my/device/network')
1100
+ .reply(200, { result: true });
1101
+
1102
+ let functionMw = mw({});
1103
+ functionMw(req, {}, () => {
1104
+ });
1105
+ setTimeout(() => {
1106
+ assert.strictEqual(authScope.isDone(), true, "the extra target was not posted to");
1107
+ done();
1108
+ }, 100);
1109
+ });
1110
+
1111
+ it("still notifies urac when a target names a service that is not in the registry", (done) => {
1112
+ let req = buildReq({
1113
+ active: true,
1114
+ targets: [{
1115
+ serviceName: "doesnotexist",
1116
+ serviceVersion: "1",
1117
+ api: "/nope"
1118
+ }]
1119
+ });
1120
+ const uracScope = nock('http://urac.fake:4001')
1121
+ .post('/user/last/seen')
1122
+ .reply(200, { result: true });
1123
+
1124
+ let functionMw = mw({});
1125
+ assert.doesNotThrow(() => {
1126
+ functionMw(req, {}, () => {
1127
+ });
1128
+ });
1129
+ setTimeout(() => {
1130
+ assert.strictEqual(uracScope.isDone(), true, "urac was not notified");
1131
+ done();
1132
+ }, 100);
1133
+ });
1134
+
1135
+ it("skips a malformed target and still notifies urac", (done) => {
1136
+ let req = buildReq({
1137
+ active: true,
1138
+ targets: [{ serviceVersion: "1" }, null]
1139
+ });
1140
+ const uracScope = nock('http://urac.fake:4001')
1141
+ .post('/user/last/seen')
1142
+ .reply(200, { result: true });
1143
+
1144
+ let functionMw = mw({});
1145
+ functionMw(req, {}, () => {
1146
+ });
1147
+ setTimeout(() => {
1148
+ assert.strictEqual(uracScope.isDone(), true, "urac was not notified");
1149
+ done();
1150
+ }, 100);
1151
+ });
1152
+
1153
+ it("does not notify any target when the include filter excludes the request", (done) => {
1154
+ let req = buildReq({
1155
+ active: true,
1156
+ include: { "someotherservice": true },
1157
+ targets: [{
1158
+ serviceName: "authenticator",
1159
+ serviceVersion: "1",
1160
+ api: "/my/device/network",
1161
+ method: "put"
1162
+ }]
1163
+ });
1164
+ const uracScope = nock('http://urac.fake:4001').post('/user/last/seen').reply(200, {});
1165
+ const authScope = nock('http://authenticator.fake:4002').put('/my/device/network').reply(200, {});
1166
+
1167
+ let functionMw = mw({});
1168
+ functionMw(req, {}, () => {
1169
+ });
1170
+ setTimeout(() => {
1171
+ assert.strictEqual(uracScope.isDone(), false, "urac should have been skipped");
1172
+ assert.strictEqual(authScope.isDone(), false, "the extra target should have been skipped");
1173
+ nock.cleanAll();
1174
+ done();
1175
+ }, 100);
1176
+ });
1177
+
1178
+ });
1179
+
1012
1180
  });
@@ -146,6 +146,19 @@ describe("Testing utilities", () => {
146
146
  done();
147
147
  });
148
148
 
149
+ it("errorContext - falls back to service_n and service_v before gotoService resolves them", (done) => {
150
+ // key and keyACL run before gotoService, so serviceParams carries only the
151
+ // raw service_n / service_v at that point
152
+ let r = makeReq(true);
153
+ r.soajs.controller.serviceParams = { "service_n": "urac", "service_v": "3" };
154
+ utils.controllerErrorHandler(154, r, res, null);
155
+
156
+ let ctx = JSON.parse(logged.error[0]);
157
+ assert.strictEqual(ctx.service, "urac");
158
+ assert.strictEqual(ctx.version, "3");
159
+ done();
160
+ });
161
+
149
162
  it("logErrors - string error", (done) => {
150
163
  utils.logErrors("error", req, res, (error) => {
151
164
  done();
@@ -51,8 +51,10 @@ function errorContext(err, req) {
51
51
  "code": code,
52
52
  "msg": msg,
53
53
  "method": req.method,
54
+ //NOTE: name and version are resolved by gotoService, the service_n and service_v
55
+ // fallbacks cover the middleware that runs before it, ex: key and keyACL
54
56
  "service": serviceParams.name || serviceParams.service_n || null,
55
- "version": serviceParams.version || null,
57
+ "version": serviceParams.version || serviceParams.service_v || null,
56
58
  "api": (serviceParams.parsedUrl && serviceParams.parsedUrl.pathname) || null,
57
59
  "url": redactUrl(req.url),
58
60
  "ip": req.getClientIP ? req.getClientIP() : null,