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.
@@ -0,0 +1,209 @@
1
+ # Idempotency Middleware
2
+
3
+ **File:** `mw/idempotency/index.js`
4
+
5
+ **Order:** 17 (after MT middleware)
6
+
7
+ The Idempotency middleware prevents duplicate write operations by tracking requests via `Idempotency-Key` headers.
8
+
9
+ ## Purpose
10
+
11
+ - Prevents duplicate side effects from retried requests
12
+ - Stores and replays responses for completed requests
13
+ - Returns 409 Conflict for in-flight duplicate requests
14
+ - Supports memory and MongoDB storage backends
15
+
16
+ ## Flow
17
+
18
+ ```
19
+ Incoming Request
20
+ │
21
+ ▼
22
+ ┌─────────────────────────┐
23
+ │ Check Idempotency-Key │ Header present?
24
+ └────────┬────────────────┘
25
+ │
26
+ ├─── Missing ──▶ next() (backwards compatible)
27
+ │
28
+ ▼
29
+ ┌─────────────────────────┐
30
+ │ Validate UUID v4 │ Format check
31
+ └────────┬────────────────┘
32
+ │
33
+ ├─── Invalid ──▶ 400 Bad Request (code: 180)
34
+ │
35
+ ▼
36
+ ┌─────────────────────────┐
37
+ │ Check Configuration │ Service enabled? API matches?
38
+ └────────┬────────────────┘
39
+ │
40
+ ├─── Not configured ──▶ next()
41
+ │
42
+ ▼
43
+ ┌─────────────────────────┐
44
+ │ Lookup Existing Key │ Check store
45
+ └────────┬────────────────┘
46
+ │
47
+ ├─── Found (completed) ──▶ Return cached response
48
+ │
49
+ ├─── Found (in_flight) ──▶ 409 Conflict (code: 181)
50
+ │
51
+ └─── Not found ──▶ Lock key → Execute → Store response
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ **Path:** `registry.custom.gateway.value.idempotency`
57
+
58
+ ```javascript
59
+ {
60
+ "idempotency": {
61
+ "model": "memory", // Storage: "memory" or "mongo"
62
+ "services": { // Service configurations
63
+ "av": { // Service name
64
+ "enabled": true, // Enable for this service
65
+ "ttl": 60000, // Time-to-live in ms (default: 60s)
66
+ "apis": [ // APIs to protect
67
+ "POST /calls",
68
+ "PUT /call/p2p/ended",
69
+ "PUT /call/p2p/rejected"
70
+ ]
71
+ },
72
+ "payment": {
73
+ "enabled": true,
74
+ "ttl": 120000,
75
+ "apis": [
76
+ "POST /charge",
77
+ "POST /refund"
78
+ ]
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ | Field | Type | Default | Description |
86
+ |-------|------|---------|-------------|
87
+ | `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
88
+ | `services.[name].enabled` | boolean | `false` | Enable idempotency for this service |
89
+ | `services.[name].ttl` | number | `60000` | Key expiration time in milliseconds |
90
+ | `services.[name].apis` | string[] | `[]` | API patterns to protect (e.g., `"POST /path"`) |
91
+
92
+ ## API Pattern Matching
93
+
94
+ APIs are matched using `METHOD /path` format with support for path parameters:
95
+
96
+ ```javascript
97
+ "apis": [
98
+ "POST /av/calls", // Exact match
99
+ "PUT /av/call/:id/ended", // :id matches any segment
100
+ "DELETE /users/:userId/posts/:postId" // Multiple params
101
+ ]
102
+ ```
103
+
104
+ ## Storage Backends
105
+
106
+ ### Memory (Default)
107
+
108
+ - Fast, in-process storage
109
+ - Lost on restart
110
+ - Good for single-instance deployments
111
+ - Automatic cleanup of expired entries
112
+
113
+ ### MongoDB
114
+
115
+ - Persistent storage
116
+ - Shared across instances
117
+ - Required for multi-instance deployments
118
+ - Uses TTL index for automatic expiration
119
+
120
+ ## Client Usage
121
+
122
+ Clients must include the `Idempotency-Key` header with a UUID v4:
123
+
124
+ ```http
125
+ POST /av/calls HTTP/1.1
126
+ Host: api.example.com
127
+ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
128
+ Content-Type: application/json
129
+
130
+ {"caller": "user1", "callee": "user2"}
131
+ ```
132
+
133
+ ## Response Scenarios
134
+
135
+ ### New Request (First Time)
136
+
137
+ ```
138
+ Request processed normally, response cached
139
+ ```
140
+
141
+ ### Replay (Completed)
142
+
143
+ ```
144
+ HTTP/1.1 200 OK
145
+ (Cached response body returned, no side effects)
146
+ ```
147
+
148
+ ### Replay (In-Flight)
149
+
150
+ ```
151
+ HTTP/1.1 409 Conflict
152
+ Content-Type: application/json
153
+
154
+ {
155
+ "result": false,
156
+ "errors": {
157
+ "codes": [181],
158
+ "details": [{
159
+ "code": 181,
160
+ "message": "Request with this Idempotency-Key is still being processed."
161
+ }]
162
+ }
163
+ }
164
+ ```
165
+
166
+ ### Invalid Key Format
167
+
168
+ ```
169
+ HTTP/1.1 400 Bad Request
170
+ Content-Type: application/json
171
+
172
+ {
173
+ "result": false,
174
+ "errors": {
175
+ "codes": [180],
176
+ "details": [{
177
+ "code": 180,
178
+ "message": "Invalid Idempotency-Key format. Expected UUID v4."
179
+ }]
180
+ }
181
+ }
182
+ ```
183
+
184
+ ## Key Structure
185
+
186
+ ```javascript
187
+ {
188
+ "l1": "tenant_id", // Tenant isolation
189
+ "l2": "idempotency_key" // Client-provided UUID
190
+ }
191
+ ```
192
+
193
+ Keys are isolated per tenant to prevent cross-tenant collisions.
194
+
195
+ ## Error Codes
196
+
197
+ | Code | HTTP Status | Description |
198
+ |------|-------------|-------------|
199
+ | 180 | 400 | Invalid Idempotency-Key format (not UUID v4) |
200
+ | 181 | 409 | Request with this key is still being processed |
201
+
202
+ ## Usage Notes
203
+
204
+ - Only applies to non-GET requests (POST, PUT, DELETE, PATCH)
205
+ - GET requests always pass through (idempotent by nature)
206
+ - Keys expire after TTL (default 60 seconds)
207
+ - Clients should generate a new UUID for each unique operation
208
+ - Same UUID = same operation (for retries)
209
+ - MongoDB backend required for horizontal scaling
@@ -47,13 +47,25 @@ The complete request lifecycle through the SOAJS Controller Gateway.
47
47
  │ │
48
48
  ▼ │
49
49
  ┌──────────────┐ │
50
- │ 7. Traffic │ Rate limiting with configurable │
50
+ │ 7.Idempotency│ Check Idempotency-Key header │
51
+ │ Check │ Return cached response or lock key │
52
+ └──────┬───────┘ │
53
+ │ │
54
+ ▼ │
55
+ ┌──────────────┐ │
56
+ │ 8. Traffic │ Rate limiting with configurable │
51
57
  │ Control │ strategies and storage backends │
58
+ └──────┬───────┘ │
59
+ │ │
60
+ ▼ │
61
+ ┌──────────────┐ │
62
+ │ 9. Response │ Check cache for GET requests │
63
+ │ Caching │ Return cached response or proceed │
52
64
  └──────┬───────┘ │
53
65
  │ │
54
66
  ▼ │
55
67
  ┌──────────────────────────────────────────────────────────────────┬──────┘
56
- │ 8. PROXY TO SERVICE │
68
+ │ 10. PROXY TO SERVICE │
57
69
  │ │
58
70
  │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
59
71
  │ │ Resolve Host │───▶│ Build Request │───▶│ HTTP Proxy │ │
@@ -120,14 +132,33 @@ The complete request lifecycle through the SOAJS Controller Gateway.
120
132
  - ACL enforcement
121
133
  - OAuth token validation
122
134
 
123
- ### 7. Traffic Control
135
+ ### 7. Idempotency Check
136
+
137
+ **Middleware:** `mw/idempotency/index.js`
138
+
139
+ - Checks for `Idempotency-Key` header (UUID v4)
140
+ - Returns cached response for completed requests
141
+ - Returns 409 Conflict for in-flight requests
142
+ - Locks key and proceeds for new requests
143
+ - Stores response after completion
144
+
145
+ ### 8. Traffic Control
124
146
 
125
147
  **Middleware:** `mw/traffic/index.js`
126
148
 
127
149
  - Rate limiting per strategy
128
150
  - Response headers (X-RateLimit-*)
129
151
 
130
- ### 8. Proxy to Service
152
+ ### 9. Response Caching
153
+
154
+ **Middleware:** `mw/cache/index.js`
155
+
156
+ - Caches GET API responses
157
+ - Returns cached response on cache hit
158
+ - Adds X-Cache and X-Cache-Age headers
159
+ - Stores response after backend call
160
+
161
+ ### 10. Proxy to Service
131
162
 
132
163
  **Middleware:** `mw/gotoService/*`
133
164
 
package/lib/request.js CHANGED
@@ -409,6 +409,11 @@ function proxyRequestMonitor(req, res, options, extraOptions = {}) {
409
409
  }
410
410
  let resContentType = proxyRes.headers['content-type'];
411
411
 
412
+ // Emit statusCode event for caching
413
+ if (extraOptions && extraOptions.events && extraOptions.events.statusCode) {
414
+ extraOptions.events.statusCode(proxyRes.statusCode, proxyRes.headers);
415
+ }
416
+
412
417
  if (extraOptions && extraOptions.events && extraOptions.events.response) {
413
418
  extraOptions.events.response(resContentType);
414
419
  }
@@ -0,0 +1,175 @@
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 crypto = require('crypto');
12
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
13
+ const memory = require('./model/memory.js');
14
+ const mongo = require('./model/mongo.js');
15
+
16
+ let model = { memory, mongo };
17
+
18
+ function hashQuery(query) {
19
+ if (!query || Object.keys(query).length === 0) {
20
+ return '';
21
+ }
22
+ const sortedQuery = Object.keys(query).sort().reduce((obj, key) => {
23
+ obj[key] = query[key];
24
+ return obj;
25
+ }, {});
26
+ return crypto.createHash('md5').update(JSON.stringify(sortedQuery)).digest('hex');
27
+ }
28
+
29
+ function matchesApi(method, path, apis) {
30
+ if (!apis || typeof apis !== 'object') {
31
+ return null;
32
+ }
33
+ const requestSignature = `${method} ${path}`;
34
+
35
+ if (apis[requestSignature]) {
36
+ return apis[requestSignature];
37
+ }
38
+
39
+ for (const api in apis) {
40
+ if (apis.hasOwnProperty(api)) {
41
+ const [apiMethod, apiPath] = api.split(' ');
42
+ if (apiMethod !== method) {
43
+ continue;
44
+ }
45
+ const apiParts = apiPath.split('/');
46
+ const pathParts = path.split('/');
47
+ if (apiParts.length !== pathParts.length) {
48
+ continue;
49
+ }
50
+ let matches = true;
51
+ for (let i = 0; i < apiParts.length; i++) {
52
+ if (apiParts[i].startsWith(':')) {
53
+ continue;
54
+ }
55
+ if (apiParts[i] !== pathParts[i]) {
56
+ matches = false;
57
+ break;
58
+ }
59
+ }
60
+ if (matches) {
61
+ return apis[api];
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // Export model for use by gotoService
69
+ module.exports = function (configuration) {
70
+ model.memory.init(configuration.log);
71
+ model.mongo.init(configuration.log, configuration.gatewayDB);
72
+
73
+ return (req, res, next) => {
74
+ if (req.method.toUpperCase() !== 'GET') {
75
+ return next();
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., /marketplace/items -> /items)
84
+ const serviceInfo = req.soajs.controller.serviceParams.serviceInfo;
85
+ const apiPath = "/" + serviceInfo.slice(2).join("/");
86
+ const method = req.method.toUpperCase();
87
+
88
+ const cacheConfig = get(["soajs", "registry", "custom", "gateway", "value", "cache"], req);
89
+ if (!cacheConfig) {
90
+ return next();
91
+ }
92
+
93
+ const serviceConfig = cacheConfig.services && cacheConfig.services[serviceName];
94
+ if (!serviceConfig || !serviceConfig.enabled) {
95
+ return next();
96
+ }
97
+
98
+ const apiConfig = matchesApi(method, apiPath, serviceConfig.apis);
99
+ if (!apiConfig || !apiConfig.enabled) {
100
+ return next();
101
+ }
102
+
103
+ const modelType = cacheConfig.model || 'memory';
104
+ if (!model[modelType]) {
105
+ configuration.log.warn('Cache: Unknown model [' + modelType + ']. It can only be [memory || mongo]');
106
+ return next();
107
+ }
108
+
109
+ // Determine caching scope.
110
+ // Public APIs (no access_token required) return the same response for the whole
111
+ // tenant, so they are cached per tenant ("tenant"). Private APIs may return a
112
+ // different response per logged-in user, so they must be cached per tenant + user
113
+ // ("tenant_user") to avoid serving one user's response to another.
114
+ // Scope can be overridden per API in the custom registry via apiConfig.scope.
115
+ // Default: public -> "tenant", private -> "tenant_user".
116
+ const isAPIPublic = req.soajs.controller.serviceParams.isAPIPublic;
117
+ const userId = req.soajs.uracDriver && req.soajs.uracDriver.id;
118
+
119
+ let scope = apiConfig.scope;
120
+ if (scope !== 'tenant' && scope !== 'tenant_user') {
121
+ if (scope) {
122
+ configuration.log.warn('Cache: Unknown scope [' + scope + '] for API [' + method + ' ' + apiPath + ']. It can only be [tenant || tenant_user]. Falling back to default.');
123
+ }
124
+ scope = isAPIPublic ? 'tenant' : 'tenant_user';
125
+ }
126
+
127
+ // Safety: a user-scoped entry without a resolved user would leak across users.
128
+ // Skip caching rather than risk serving the wrong user's response.
129
+ if (scope === 'tenant_user' && !userId) {
130
+ return next();
131
+ }
132
+
133
+ const ttl = apiConfig.ttl || cacheConfig.defaultTTL || 300000;
134
+ const queryHash = hashQuery(req.query);
135
+ const userPart = (scope === 'tenant_user') ? `:u:${userId}` : '';
136
+ const key = {
137
+ 'l1': req.soajs.tenant.id,
138
+ 'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
139
+ };
140
+
141
+ const processCache = async () => {
142
+ try {
143
+ const cached = await model[modelType].get(key);
144
+
145
+ if (cached && cached.response) {
146
+ res.setHeader('X-Cache', 'HIT');
147
+ res.setHeader('X-Cache-Age', Math.floor((Date.now() - cached.cachedAt) / 1000));
148
+ res.writeHead(cached.response.statusCode, cached.response.headers);
149
+ res.end(cached.response.body);
150
+ return;
151
+ }
152
+
153
+ // Store cache context for gotoService to use
154
+ req.soajs.cacheContext = {
155
+ key: key,
156
+ ttl: ttl,
157
+ modelType: modelType,
158
+ model: model[modelType],
159
+ log: configuration.log
160
+ };
161
+
162
+ res.setHeader('X-Cache', 'MISS');
163
+ next();
164
+ } catch (err) {
165
+ configuration.log.error('Cache error:', err);
166
+ next();
167
+ }
168
+ };
169
+
170
+ processCache();
171
+ };
172
+ };
173
+
174
+ // Export model for external use
175
+ module.exports.model = model;
@@ -0,0 +1,121 @@
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_CACHE_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(`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
+ 'set': (key, response, ttl) => {
79
+ if (!dataHolder[key.l1]) {
80
+ dataHolder[key.l1] = {};
81
+ }
82
+ dataHolder[key.l1][key.l2] = {
83
+ 'response': response,
84
+ 'cachedAt': Date.now(),
85
+ 'expiresAt': Date.now() + ttl
86
+ };
87
+ },
88
+
89
+ 'invalidate': (key) => {
90
+ if (dataHolder[key.l1] && dataHolder[key.l1][key.l2]) {
91
+ delete dataHolder[key.l1][key.l2];
92
+ }
93
+ },
94
+
95
+ 'invalidateTenant': (tenantId) => {
96
+ if (dataHolder[tenantId]) {
97
+ delete dataHolder[tenantId];
98
+ }
99
+ },
100
+
101
+ 'shutdown': () => {
102
+ if (cleanupInterval) {
103
+ clearInterval(cleanupInterval);
104
+ cleanupInterval = null;
105
+ }
106
+ dataHolder = {};
107
+ if (model.log) {
108
+ model.log.info('Cache memory model shutdown complete');
109
+ }
110
+ }
111
+ };
112
+
113
+ if (typeof process !== 'undefined') {
114
+ const shutdownHandler = () => {
115
+ model.shutdown();
116
+ };
117
+ process.once('SIGTERM', shutdownHandler);
118
+ process.once('SIGINT', shutdownHandler);
119
+ }
120
+
121
+ module.exports = model;
@@ -0,0 +1,98 @@
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 cache_store = 'cache_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(cache_store, { 'l1': 1, 'l2': 1 }, { unique: true }, (err, index) => {
25
+ log.debug("Cache index: " + index + " created with error: " + err);
26
+ });
27
+ model.mongo.createIndex(cache_store, { 'expiresAt': 1 }, { expireAfterSeconds: 0 }, (err, index) => {
28
+ log.debug("Cache 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(cache_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('Cache get:', e.message);
49
+ return null;
50
+ }
51
+ },
52
+
53
+ 'set': async (key, response, ttl) => {
54
+ let condition = {
55
+ 'l1': key.l1,
56
+ 'l2': key.l2
57
+ };
58
+ let doc = {
59
+ 'l1': key.l1,
60
+ 'l2': key.l2,
61
+ 'response': response,
62
+ 'cachedAt': new Date(),
63
+ 'expiresAt': new Date(Date.now() + ttl)
64
+ };
65
+ let options = { 'upsert': true };
66
+ let s = { '$set': doc };
67
+ try {
68
+ await model.mongo.updateOne(cache_store, condition, s, options);
69
+ } catch (e) {
70
+ model.log.error('Cache set:', e.message);
71
+ }
72
+ },
73
+
74
+ 'invalidate': async (key) => {
75
+ let condition = {
76
+ 'l1': key.l1,
77
+ 'l2': key.l2
78
+ };
79
+ try {
80
+ await model.mongo.deleteOne(cache_store, condition);
81
+ } catch (e) {
82
+ model.log.error('Cache invalidate:', e.message);
83
+ }
84
+ },
85
+
86
+ 'invalidateTenant': async (tenantId) => {
87
+ let condition = {
88
+ 'l1': tenantId
89
+ };
90
+ try {
91
+ await model.mongo.deleteMany(cache_store, condition);
92
+ } catch (e) {
93
+ model.log.error('Cache invalidateTenant:', e.message);
94
+ }
95
+ }
96
+ };
97
+
98
+ module.exports = model;