soajs.controller 4.3.4 → 4.3.6

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.
@@ -315,7 +315,21 @@ module.exports = (configuration) => {
315
315
  let resContentType = "";
316
316
  const extraOptions = {};
317
317
 
318
- if (monitor && !monitor_service_blacklist && monitor.req_response) {
318
+ // Cache response capture
319
+ let cacheContext = req.soajs.cacheContext;
320
+ let cacheResponseChunks = [];
321
+ let cacheResponseStatusCode = 200;
322
+ let cacheResponseHeaders = {};
323
+
324
+ // Idempotency response capture
325
+ let idempotencyContext = req.soajs.idempotencyContext;
326
+ let idempotencyResponseChunks = [];
327
+ let idempotencyResponseStatusCode = 200;
328
+ let idempotencyResponseHeaders = {};
329
+
330
+ // Set up events for monitoring, caching, and/or idempotency
331
+ let needsEvents = (monitor && !monitor_service_blacklist && monitor.req_response) || cacheContext || idempotencyContext;
332
+ if (needsEvents) {
319
333
  const MAX_RESPONSE_SIZE = process.env.SOAJS_MAX_RESPONSE_SIZE || 10 * 1024 * 1024; // 10MB default
320
334
  let responseSize = 0;
321
335
  let responseExceeded = false;
@@ -330,35 +344,78 @@ module.exports = (configuration) => {
330
344
  isStream = resContentType.match(/stream/i);
331
345
  }
332
346
  }
333
- monitoObj.time.res_start = new Date().getTime();
347
+ if (monitor && !monitor_service_blacklist && monitor.req_response) {
348
+ monitoObj.time.res_start = new Date().getTime();
349
+ }
334
350
  },
335
351
  "data": (chunk) => {
336
- if (!isStream && allowedContentType) {
337
- responseSize += chunk.length;
338
- if (responseSize > MAX_RESPONSE_SIZE) {
339
- if (!responseExceeded) {
340
- responseExceeded = true;
341
- monitoObj.response = "{\"error\": \"Response body exceeds maximum size limit\"}";
342
- }
343
- } else if (!responseExceeded) {
344
- if (!monitoObj.response) {
345
- monitoObj.response = chunk;
346
- } else {
347
- monitoObj.response += chunk;
352
+ // Capture for caching
353
+ if (cacheContext) {
354
+ cacheResponseChunks.push(chunk);
355
+ }
356
+ // Capture for idempotency
357
+ if (idempotencyContext) {
358
+ idempotencyResponseChunks.push(chunk);
359
+ }
360
+ // Capture for monitoring
361
+ if (monitor && !monitor_service_blacklist && monitor.req_response) {
362
+ if (!isStream && allowedContentType) {
363
+ responseSize += chunk.length;
364
+ if (responseSize > MAX_RESPONSE_SIZE) {
365
+ if (!responseExceeded) {
366
+ responseExceeded = true;
367
+ monitoObj.response = "{\"error\": \"Response body exceeds maximum size limit\"}";
368
+ }
369
+ } else if (!responseExceeded) {
370
+ if (!monitoObj.response) {
371
+ monitoObj.response = chunk;
372
+ } else {
373
+ monitoObj.response += chunk;
374
+ }
348
375
  }
376
+ } else {
377
+ monitoObj.response = "{\"contentType\": \"is stream or not allowed\", \"value': \"" + resContentType + "\"}";
349
378
  }
350
- } else {
351
- monitoObj.response = "{\"contentType\": \"is stream or not allowed\", \"value': \"" + resContentType + "\"}";
352
379
  }
353
380
  },
354
381
  "fetchProxReq": (proxyReq) => {
355
382
  req.soajs.controller.redirectedRequest = proxyReq;
383
+ },
384
+ "statusCode": (statusCode, headers) => {
385
+ if (cacheContext) {
386
+ cacheResponseStatusCode = statusCode;
387
+ cacheResponseHeaders = headers || {};
388
+ }
389
+ if (idempotencyContext) {
390
+ idempotencyResponseStatusCode = statusCode;
391
+ idempotencyResponseHeaders = headers || {};
392
+ }
356
393
  }
357
394
  };
358
395
  }
359
396
 
360
397
  proxyRequestMonitor(req, res, requestOptions, extraOptions)
361
398
  .then(() => {
399
+ // Save to cache if applicable
400
+ if (cacheContext && cacheResponseStatusCode >= 200 && cacheResponseStatusCode < 300) {
401
+ let cacheBody = Buffer.concat(cacheResponseChunks).toString('utf8');
402
+ let cacheData = {
403
+ statusCode: cacheResponseStatusCode,
404
+ headers: cacheResponseHeaders,
405
+ body: cacheBody
406
+ };
407
+ cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl);
408
+ }
409
+ // Complete idempotency if applicable
410
+ if (idempotencyContext) {
411
+ let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
412
+ let idempotencyData = {
413
+ statusCode: idempotencyResponseStatusCode,
414
+ headers: idempotencyResponseHeaders,
415
+ body: idempotencyBody
416
+ };
417
+ idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
418
+ }
362
419
  if (monitor && !monitor_service_blacklist && monitor.req_response) {
363
420
  monitoObj.time.res_end = new Date().getTime();
364
421
  log_monitor(monitoObj);
@@ -370,6 +427,10 @@ module.exports = (configuration) => {
370
427
  req.soajs.controller.redirectedRequest.destroy();
371
428
  req.soajs.controller.redirectedRequest = null;
372
429
  }
430
+ // Unlock idempotency on error
431
+ if (idempotencyContext) {
432
+ idempotencyContext.model.unlock(idempotencyContext.key);
433
+ }
373
434
  if (!req.soajs.controller.monitorEndingReq) {
374
435
  req.soajs.controllerResponse(core.error.getError(135));
375
436
  }
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
12
+ const memory = require('./model/memory.js');
13
+ const mongo = require('./model/mongo.js');
14
+
15
+ let model = { memory, mongo };
16
+
17
+ const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+
19
+ function isValidUUIDv4(str) {
20
+ return UUID_V4_REGEX.test(str);
21
+ }
22
+
23
+ function matchesApi(method, path, apis) {
24
+ if (!apis || !Array.isArray(apis)) {
25
+ return false;
26
+ }
27
+ const requestSignature = `${method} ${path}`;
28
+ for (let api of apis) {
29
+ if (api === requestSignature) {
30
+ return true;
31
+ }
32
+ const [apiMethod, apiPath] = api.split(' ');
33
+ if (apiMethod !== method) {
34
+ continue;
35
+ }
36
+ const apiParts = apiPath.split('/');
37
+ const pathParts = path.split('/');
38
+ if (apiParts.length !== pathParts.length) {
39
+ continue;
40
+ }
41
+ let matches = true;
42
+ for (let i = 0; i < apiParts.length; i++) {
43
+ if (apiParts[i].startsWith(':')) {
44
+ continue;
45
+ }
46
+ if (apiParts[i] !== pathParts[i]) {
47
+ matches = false;
48
+ break;
49
+ }
50
+ }
51
+ if (matches) {
52
+ return true;
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ module.exports = function (configuration) {
59
+ model.memory.init(configuration.log);
60
+ model.mongo.init(configuration.log, configuration.gatewayDB);
61
+
62
+ return (req, res, next) => {
63
+ const idempotencyKey = req.headers['idempotency-key'];
64
+
65
+ if (!idempotencyKey) {
66
+ return next();
67
+ }
68
+
69
+ if (!isValidUUIDv4(idempotencyKey)) {
70
+ req.soajs.controllerResponse({
71
+ 'status': 400,
72
+ 'code': 180,
73
+ 'msg': 'Invalid Idempotency-Key format. Expected UUID v4.'
74
+ });
75
+ return;
76
+ }
77
+
78
+ if (!req.soajs || !req.soajs.tenant || !req.soajs.controller || !req.soajs.controller.serviceParams) {
79
+ return next();
80
+ }
81
+
82
+ const serviceName = req.soajs.controller.serviceParams.name;
83
+ // Strip service prefix from pathname to get API path (e.g., /console/registry -> /registry)
84
+ const serviceInfo = req.soajs.controller.serviceParams.serviceInfo;
85
+ const apiPath = "/" + serviceInfo.slice(2).join("/");
86
+ const method = req.method.toUpperCase();
87
+
88
+ if (method === 'GET') {
89
+ return next();
90
+ }
91
+
92
+ const idempotencyConfig = get(["soajs", "registry", "custom", "gateway", "value", "idempotency"], req);
93
+ if (!idempotencyConfig) {
94
+ return next();
95
+ }
96
+
97
+ const serviceConfig = idempotencyConfig.services && idempotencyConfig.services[serviceName];
98
+ if (!serviceConfig || !serviceConfig.enabled) {
99
+ return next();
100
+ }
101
+
102
+ if (serviceConfig.apis && !matchesApi(method, apiPath, serviceConfig.apis)) {
103
+ return next();
104
+ }
105
+
106
+ const modelType = idempotencyConfig.model || 'memory';
107
+ if (!model[modelType]) {
108
+ configuration.log.warn('Idempotency: Unknown model [' + modelType + ']. It can only be [memory || mongo]');
109
+ return next();
110
+ }
111
+
112
+ const ttl = serviceConfig.ttl || 60000;
113
+ const key = {
114
+ 'l1': req.soajs.tenant.id,
115
+ 'l2': idempotencyKey
116
+ };
117
+
118
+ const processIdempotency = async () => {
119
+ try {
120
+ const existing = await model[modelType].get(key);
121
+
122
+ if (existing) {
123
+ if (existing.status === 'in_flight') {
124
+ req.soajs.controllerResponse({
125
+ 'status': 409,
126
+ 'code': 181,
127
+ 'msg': 'Request with this Idempotency-Key is still being processed.'
128
+ });
129
+ return;
130
+ }
131
+
132
+ if (existing.status === 'completed' && existing.response) {
133
+ res.writeHead(existing.response.statusCode, existing.response.headers);
134
+ res.end(existing.response.body);
135
+ return;
136
+ }
137
+ }
138
+
139
+ const locked = await model[modelType].lock(key, ttl);
140
+ if (!locked) {
141
+ req.soajs.controllerResponse({
142
+ 'status': 409,
143
+ 'code': 181,
144
+ 'msg': 'Request with this Idempotency-Key is still being processed.'
145
+ });
146
+ return;
147
+ }
148
+
149
+ // Store idempotency context for gotoService to use
150
+ req.soajs.idempotencyContext = {
151
+ key: key,
152
+ ttl: ttl,
153
+ modelType: modelType,
154
+ model: model[modelType],
155
+ log: configuration.log
156
+ };
157
+
158
+ next();
159
+ } catch (err) {
160
+ configuration.log.error('Idempotency error:', err);
161
+ model[modelType].unlock(key).catch(() => {});
162
+ next();
163
+ }
164
+ };
165
+
166
+ processIdempotency();
167
+ };
168
+ };
169
+
170
+ // Export model for external use
171
+ module.exports.model = model;
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ let dataHolder = {};
12
+ let cleanupInterval = null;
13
+
14
+ let model = {
15
+ 'log': null,
16
+
17
+ 'init': (log) => {
18
+ model.log = log;
19
+ dataHolder = {};
20
+
21
+ if (cleanupInterval) {
22
+ clearInterval(cleanupInterval);
23
+ }
24
+ const CLEANUP_INTERVAL = process.env.SOAJS_IDEMPOTENCY_CLEANUP_INTERVAL || 60000;
25
+ cleanupInterval = setInterval(() => {
26
+ model.cleanupExpiredEntries();
27
+ }, CLEANUP_INTERVAL);
28
+ // Do not keep the event loop alive solely for the cleanup timer.
29
+ if (cleanupInterval.unref) {
30
+ cleanupInterval.unref();
31
+ }
32
+ },
33
+
34
+ 'cleanupExpiredEntries': () => {
35
+ const now = Date.now();
36
+ let removedCount = 0;
37
+
38
+ for (const l1Key in dataHolder) {
39
+ if (dataHolder.hasOwnProperty(l1Key)) {
40
+ const tenantData = dataHolder[l1Key];
41
+
42
+ for (const l2Key in tenantData) {
43
+ if (tenantData.hasOwnProperty(l2Key)) {
44
+ const entry = tenantData[l2Key];
45
+ if (entry.expiresAt && now > entry.expiresAt) {
46
+ delete tenantData[l2Key];
47
+ removedCount++;
48
+ }
49
+ }
50
+ }
51
+
52
+ if (Object.keys(tenantData).length === 0) {
53
+ delete dataHolder[l1Key];
54
+ }
55
+ }
56
+ }
57
+
58
+ if (removedCount > 0 && model.log) {
59
+ model.log.debug(`Idempotency cache cleanup: removed ${removedCount} expired entries`);
60
+ }
61
+ },
62
+
63
+ 'get': (key) => {
64
+ if (!dataHolder[key.l1]) {
65
+ return null;
66
+ }
67
+ const entry = dataHolder[key.l1][key.l2];
68
+ if (!entry) {
69
+ return null;
70
+ }
71
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
72
+ delete dataHolder[key.l1][key.l2];
73
+ return null;
74
+ }
75
+ return entry;
76
+ },
77
+
78
+ 'lock': (key, ttl) => {
79
+ if (!dataHolder[key.l1]) {
80
+ dataHolder[key.l1] = {};
81
+ }
82
+ const existing = dataHolder[key.l1][key.l2];
83
+ if (existing && existing.expiresAt && Date.now() <= existing.expiresAt) {
84
+ return false;
85
+ }
86
+ dataHolder[key.l1][key.l2] = {
87
+ 'status': 'in_flight',
88
+ 'lockedAt': Date.now(),
89
+ 'expiresAt': Date.now() + ttl
90
+ };
91
+ return true;
92
+ },
93
+
94
+ 'complete': (key, response, ttl) => {
95
+ if (!dataHolder[key.l1]) {
96
+ dataHolder[key.l1] = {};
97
+ }
98
+ dataHolder[key.l1][key.l2] = {
99
+ 'status': 'completed',
100
+ 'response': response,
101
+ 'completedAt': Date.now(),
102
+ 'expiresAt': Date.now() + ttl
103
+ };
104
+ },
105
+
106
+ 'unlock': (key) => {
107
+ if (dataHolder[key.l1] && dataHolder[key.l1][key.l2]) {
108
+ delete dataHolder[key.l1][key.l2];
109
+ }
110
+ },
111
+
112
+ 'shutdown': () => {
113
+ if (cleanupInterval) {
114
+ clearInterval(cleanupInterval);
115
+ cleanupInterval = null;
116
+ }
117
+ dataHolder = {};
118
+ if (model.log) {
119
+ model.log.info('Idempotency memory model shutdown complete');
120
+ }
121
+ }
122
+ };
123
+
124
+ if (typeof process !== 'undefined') {
125
+ const shutdownHandler = () => {
126
+ model.shutdown();
127
+ };
128
+ process.once('SIGTERM', shutdownHandler);
129
+ process.once('SIGINT', shutdownHandler);
130
+ }
131
+
132
+ module.exports = model;
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ const core = require('soajs.core.modules');
12
+ const Mongo = core.mongo;
13
+
14
+ const idempotency_store = 'idempotency_store';
15
+
16
+ let model = {
17
+ 'mongo': null,
18
+ 'log': null,
19
+
20
+ 'init': (log, dbConfiguration) => {
21
+ model.log = log;
22
+ if (!model.mongo) {
23
+ model.mongo = new Mongo(dbConfiguration);
24
+ model.mongo.createIndex(idempotency_store, { 'l1': 1, 'l2': 1 }, { unique: true }, (err, index) => {
25
+ log.debug("Idempotency index: " + index + " created with error: " + err);
26
+ });
27
+ model.mongo.createIndex(idempotency_store, { 'expiresAt': 1 }, { expireAfterSeconds: 0 }, (err, index) => {
28
+ log.debug("Idempotency TTL index: " + index + " created with error: " + err);
29
+ });
30
+ }
31
+ },
32
+
33
+ 'get': async (key) => {
34
+ let condition = {
35
+ 'l1': key.l1,
36
+ 'l2': key.l2
37
+ };
38
+ try {
39
+ const entry = await model.mongo.findOne(idempotency_store, condition);
40
+ if (!entry) {
41
+ return null;
42
+ }
43
+ if (entry.expiresAt && Date.now() > entry.expiresAt.getTime()) {
44
+ return null;
45
+ }
46
+ return entry;
47
+ } catch (e) {
48
+ model.log.error('Idempotency get:', e.message);
49
+ return null;
50
+ }
51
+ },
52
+
53
+ 'lock': async (key, ttl) => {
54
+ let condition = {
55
+ 'l1': key.l1,
56
+ 'l2': key.l2
57
+ };
58
+ try {
59
+ const existing = await model.mongo.findOne(idempotency_store, condition);
60
+ if (existing && existing.expiresAt && Date.now() <= existing.expiresAt.getTime()) {
61
+ return false;
62
+ }
63
+
64
+ let doc = {
65
+ 'l1': key.l1,
66
+ 'l2': key.l2,
67
+ 'status': 'in_flight',
68
+ 'lockedAt': new Date(),
69
+ 'expiresAt': new Date(Date.now() + ttl)
70
+ };
71
+ let options = { 'upsert': true };
72
+ let s = { '$set': doc };
73
+ await model.mongo.updateOne(idempotency_store, condition, s, options);
74
+ return true;
75
+ } catch (e) {
76
+ if (e.code === 11000) {
77
+ return false;
78
+ }
79
+ model.log.error('Idempotency lock:', e.message);
80
+ return false;
81
+ }
82
+ },
83
+
84
+ 'complete': async (key, response, ttl) => {
85
+ let condition = {
86
+ 'l1': key.l1,
87
+ 'l2': key.l2
88
+ };
89
+ let doc = {
90
+ 'status': 'completed',
91
+ 'response': response,
92
+ 'completedAt': new Date(),
93
+ 'expiresAt': new Date(Date.now() + ttl)
94
+ };
95
+ let s = { '$set': doc };
96
+ try {
97
+ await model.mongo.updateOne(idempotency_store, condition, s, {});
98
+ } catch (e) {
99
+ model.log.error('Idempotency complete:', e.message);
100
+ }
101
+ },
102
+
103
+ 'unlock': async (key) => {
104
+ let condition = {
105
+ 'l1': key.l1,
106
+ 'l2': key.l2
107
+ };
108
+ try {
109
+ await model.mongo.deleteOne(idempotency_store, condition);
110
+ } catch (e) {
111
+ model.log.error('Idempotency unlock:', e.message);
112
+ }
113
+ }
114
+ };
115
+
116
+ module.exports = model;
@@ -25,6 +25,10 @@ let model = {
25
25
  cleanupInterval = setInterval(() => {
26
26
  model.cleanupExpiredEntries();
27
27
  }, CLEANUP_INTERVAL);
28
+ // Do not keep the event loop alive solely for the cleanup timer.
29
+ if (cleanupInterval.unref) {
30
+ cleanupInterval.unref();
31
+ }
28
32
  },
29
33
 
30
34
  'cleanupExpiredEntries': () => {
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.4",
4
+ "version": "4.3.6",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -37,6 +37,8 @@ const keyACL_mw = require("../mw/keyACL/index");
37
37
  const gotoService_mw = require("../mw/gotoService/index");
38
38
  const mt_mw = require("../mw/mt/index");
39
39
  const traffic_mw = require("../mw/traffic/index");
40
+ const idempotency_mw = require("../mw/idempotency/index");
41
+ const cache_mw = require("../mw/cache/index");
40
42
  const lastSeen_mw = require("../mw/lastSeen/index");
41
43
  const ip2ban_mw = require("../mw/ip2ban/index");
42
44
 
@@ -175,11 +177,23 @@ Controller.prototype.init = function (callback) {
175
177
  }));
176
178
  log.info("SOAJS MT middleware initialization done.");
177
179
 
180
+ app.use(idempotency_mw({
181
+ "log": log,
182
+ "gatewayDB": registry.coreDB.gateway || registry.coreDB.provision
183
+ }));
184
+ log.info("Idempotency middleware initialization done.");
185
+
178
186
  app.use(traffic_mw({
179
187
  "log": log,
180
188
  "gatewayDB": registry.coreDB.gateway || registry.coreDB.provision
181
189
  }));
182
190
 
191
+ app.use(cache_mw({
192
+ "log": log,
193
+ "gatewayDB": registry.coreDB.gateway || registry.coreDB.provision
194
+ }));
195
+ log.info("Cache middleware initialization done.");
196
+
183
197
  app.use(lastSeen_mw());
184
198
 
185
199
  app.use((req, res, next) => {
@@ -34,6 +34,10 @@ describe("Starting Gateway Unit test", () => {
34
34
  require("./mw/response/index.js");
35
35
  require("./mw/soajs/index.js");
36
36
  require("./mw/traffic/index.js");
37
+ require("./mw/idempotency/index.js");
38
+ require("./mw/idempotency/memory.js");
39
+ require("./mw/cache/index.js");
40
+ require("./mw/cache/memory.js");
37
41
  require("./mw/lastSeen/index.js");
38
42
 
39
43
  require("./utilities/utils.js");