soajs.controller 4.3.5 → 4.3.7
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/docs/configuration-custom-registry.md +64 -54
- package/docs/middleware/cache.md +42 -4
- package/docs/middleware/idempotency.md +31 -0
- package/mw/cache/index.js +26 -1
- package/mw/cache/model/memory.js +4 -0
- package/mw/idempotency/index.js +19 -5
- package/mw/idempotency/model/memory.js +4 -0
- package/mw/traffic/model/memory.js +4 -0
- package/package.json +1 -1
- package/test/unit/mw/cache/index.js +122 -5
- package/test/unit/mw/idempotency/index.js +239 -0
|
@@ -18,11 +18,13 @@ registry.custom
|
|
|
18
18
|
│ │ └── throttling # Service-level rate limiting
|
|
19
19
|
│ ├── idempotency # Idempotency key configuration
|
|
20
20
|
│ │ ├── model # Storage backend (memory/mongo)
|
|
21
|
-
│ │ └──
|
|
21
|
+
│ │ └── services # Per-service idempotency config
|
|
22
|
+
│ │ └── [service] # Service-specific settings
|
|
22
23
|
│ ├── cache # GET response caching
|
|
23
24
|
│ │ ├── model # Storage backend (memory/mongo)
|
|
24
25
|
│ │ ├── defaultTTL # Default cache TTL
|
|
25
|
-
│ │ └──
|
|
26
|
+
│ │ └── services # Per-service cache config
|
|
27
|
+
│ │ └── [service] # Service-specific settings
|
|
26
28
|
│ ├── maintenanceMode # Gateway maintenance mode
|
|
27
29
|
│ ├── lastSeen # User activity tracking
|
|
28
30
|
│ └── gotoService
|
|
@@ -141,22 +143,24 @@ Prevents duplicate write operations by tracking requests via `Idempotency-Key` h
|
|
|
141
143
|
{
|
|
142
144
|
"idempotency": {
|
|
143
145
|
"model": "memory", // Storage: "memory" or "mongo"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
|
|
159
|
-
|
|
146
|
+
"services": { // Per-service configuration
|
|
147
|
+
"av": { // Service name
|
|
148
|
+
"enabled": true, // Enable for this service
|
|
149
|
+
"ttl": 60000, // Key expiration (ms)
|
|
150
|
+
"apis": [ // APIs to protect
|
|
151
|
+
"POST /calls",
|
|
152
|
+
"PUT /call/p2p/ended",
|
|
153
|
+
"PUT /call/p2p/rejected"
|
|
154
|
+
]
|
|
155
|
+
},
|
|
156
|
+
"payment": {
|
|
157
|
+
"enabled": true,
|
|
158
|
+
"ttl": 120000,
|
|
159
|
+
"apis": [
|
|
160
|
+
"POST /charge",
|
|
161
|
+
"POST /refund"
|
|
162
|
+
]
|
|
163
|
+
}
|
|
160
164
|
}
|
|
161
165
|
}
|
|
162
166
|
}
|
|
@@ -165,9 +169,9 @@ Prevents duplicate write operations by tracking requests via `Idempotency-Key` h
|
|
|
165
169
|
| Field | Type | Default | Description |
|
|
166
170
|
|-------|------|---------|-------------|
|
|
167
171
|
| `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
|
|
168
|
-
| `[
|
|
169
|
-
| `[
|
|
170
|
-
| `[
|
|
172
|
+
| `services.[name].enabled` | boolean | `false` | Enable idempotency for this service |
|
|
173
|
+
| `services.[name].ttl` | number | `60000` | Key expiration time in milliseconds |
|
|
174
|
+
| `services.[name].apis` | string[] | `[]` | API patterns to protect (e.g., `"POST /path"`) |
|
|
171
175
|
|
|
172
176
|
**Client Usage:**
|
|
173
177
|
```http
|
|
@@ -194,25 +198,27 @@ Caches GET API responses with per-API TTL configuration. Returns `X-Cache: HIT`
|
|
|
194
198
|
"cache": {
|
|
195
199
|
"model": "memory", // Storage: "memory" or "mongo"
|
|
196
200
|
"defaultTTL": 300000, // Default TTL: 5 minutes
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"
|
|
206
|
-
|
|
201
|
+
"services": { // Per-service configuration
|
|
202
|
+
"av": { // Service name
|
|
203
|
+
"enabled": true, // Enable caching for this service
|
|
204
|
+
"apis": {
|
|
205
|
+
"GET /calls": {
|
|
206
|
+
"enabled": true,
|
|
207
|
+
"ttl": 30000 // 30 seconds
|
|
208
|
+
},
|
|
209
|
+
"GET /call/:id": {
|
|
210
|
+
"enabled": true,
|
|
211
|
+
"ttl": 60000 // 60 seconds
|
|
212
|
+
}
|
|
207
213
|
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
},
|
|
215
|
+
"catalog": {
|
|
216
|
+
"enabled": true,
|
|
217
|
+
"apis": {
|
|
218
|
+
"GET /products": {
|
|
219
|
+
"enabled": true,
|
|
220
|
+
"ttl": 600000 // 10 minutes
|
|
221
|
+
}
|
|
216
222
|
}
|
|
217
223
|
}
|
|
218
224
|
}
|
|
@@ -224,10 +230,10 @@ Caches GET API responses with per-API TTL configuration. Returns `X-Cache: HIT`
|
|
|
224
230
|
|-------|------|---------|-------------|
|
|
225
231
|
| `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
|
|
226
232
|
| `defaultTTL` | number | `300000` | Default cache TTL in milliseconds |
|
|
227
|
-
| `[
|
|
228
|
-
| `[
|
|
229
|
-
| `[
|
|
230
|
-
| `[
|
|
233
|
+
| `services.[name].enabled` | boolean | `false` | Enable caching for this service |
|
|
234
|
+
| `services.[name].apis` | object | `{}` | API-specific cache configuration |
|
|
235
|
+
| `services.[name].apis[api].enabled` | boolean | `false` | Enable caching for this API |
|
|
236
|
+
| `services.[name].apis[api].ttl` | number | `defaultTTL` | Cache TTL for this specific API |
|
|
231
237
|
|
|
232
238
|
**Response Headers:**
|
|
233
239
|
- `X-Cache: HIT` - Response served from cache
|
|
@@ -480,20 +486,24 @@ Whitelists specific APIs that can be accessed when a user is PIN-locked (during
|
|
|
480
486
|
},
|
|
481
487
|
"idempotency": {
|
|
482
488
|
"model": "mongo",
|
|
483
|
-
"
|
|
484
|
-
"
|
|
485
|
-
|
|
486
|
-
|
|
489
|
+
"services": {
|
|
490
|
+
"payment": {
|
|
491
|
+
"enabled": true,
|
|
492
|
+
"ttl": 120000,
|
|
493
|
+
"apis": ["POST /charge", "POST /refund"]
|
|
494
|
+
}
|
|
487
495
|
}
|
|
488
496
|
},
|
|
489
497
|
"cache": {
|
|
490
498
|
"model": "mongo",
|
|
491
499
|
"defaultTTL": 300000,
|
|
492
|
-
"
|
|
493
|
-
"
|
|
494
|
-
|
|
495
|
-
"
|
|
496
|
-
|
|
500
|
+
"services": {
|
|
501
|
+
"catalog": {
|
|
502
|
+
"enabled": true,
|
|
503
|
+
"apis": {
|
|
504
|
+
"GET /products": { "enabled": true, "ttl": 600000 },
|
|
505
|
+
"GET /categories": { "enabled": true, "ttl": 3600000 }
|
|
506
|
+
}
|
|
497
507
|
}
|
|
498
508
|
}
|
|
499
509
|
},
|
|
@@ -555,7 +565,7 @@ Custom registry configurations are processed in this order:
|
|
|
555
565
|
| 4 | gotoService/preRedirect | `gotoService.renewReqMonitorOff` |
|
|
556
566
|
| 5 | gotoService/redirectToService | `gotoService.monitor` |
|
|
557
567
|
| 6 | mt (security checks) | `mt.whitelist`, `oauth.value.pinWrapper`, `oauth.value.pinWhitelist` |
|
|
558
|
-
| 7 | idempotency | `idempotency.model`, `idempotency.[
|
|
568
|
+
| 7 | idempotency | `idempotency.model`, `idempotency.services.[name]` |
|
|
559
569
|
| 8 | traffic | `traffic.model`, `traffic.throttling` |
|
|
560
|
-
| 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.[
|
|
570
|
+
| 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.services.[name]` |
|
|
561
571
|
| 10 | lastSeen | `lastSeen` |
|
package/docs/middleware/cache.md
CHANGED
|
@@ -71,6 +71,10 @@ The Cache middleware provides response caching for GET APIs with per-API TTL con
|
|
|
71
71
|
"GET /users": {
|
|
72
72
|
"enabled": true
|
|
73
73
|
// Uses defaultTTL (300000ms)
|
|
74
|
+
},
|
|
75
|
+
"GET /profile": {
|
|
76
|
+
"enabled": true,
|
|
77
|
+
"scope": "tenant_user" // Cache per tenant + logged-in user
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
80
|
},
|
|
@@ -100,6 +104,27 @@ The Cache middleware provides response caching for GET APIs with per-API TTL con
|
|
|
100
104
|
| `services.[name].apis` | object | `{}` | API-specific cache configuration |
|
|
101
105
|
| `services.[name].apis[api].enabled` | boolean | `false` | Enable caching for this API |
|
|
102
106
|
| `services.[name].apis[api].ttl` | number | `defaultTTL` | Cache TTL for this API |
|
|
107
|
+
| `services.[name].apis[api].scope` | string | inferred | Cache scope: `"tenant"` or `"tenant_user"`. Defaults to `"tenant"` for public APIs and `"tenant_user"` for private APIs. |
|
|
108
|
+
|
|
109
|
+
## Caching Scope (Public vs Private APIs)
|
|
110
|
+
|
|
111
|
+
The cache key dimension depends on whether the API is **public** (no `access_token` required) or **private** (requires an `access_token`):
|
|
112
|
+
|
|
113
|
+
- **Public API** → the response is the same for the whole tenant, so it is cached per **tenant** (`scope: "tenant"`). The cache key contains the tenant id only.
|
|
114
|
+
- **Private API** → the response can differ per logged-in user, so it is cached per **tenant + user** (`scope: "tenant_user"`). The cache key also includes the user id, preventing one user from receiving another user's cached response.
|
|
115
|
+
|
|
116
|
+
The gateway determines public vs private automatically from `req.soajs.controller.serviceParams.isAPIPublic` and resolves the default scope accordingly. You can override the scope per API with the `scope` field.
|
|
117
|
+
|
|
118
|
+
| Scope | Cache key dimension | Default for |
|
|
119
|
+
|-------|---------------------|-------------|
|
|
120
|
+
| `tenant` | tenant id | Public APIs |
|
|
121
|
+
| `tenant_user` | tenant id + user id | Private APIs |
|
|
122
|
+
|
|
123
|
+
### Safety Behavior
|
|
124
|
+
|
|
125
|
+
If an API resolves to `tenant_user` scope but **no logged-in user can be resolved** (no URAC user on the request), the response is **not cached**. This prevents a user-scoped entry from being stored without a user identity and served to the wrong user. A misconfiguration therefore causes a cache miss, never a data leak.
|
|
126
|
+
|
|
127
|
+
An unrecognized `scope` value is ignored (with a warning) and falls back to the inferred default.
|
|
103
128
|
|
|
104
129
|
## API Pattern Matching
|
|
105
130
|
|
|
@@ -116,16 +141,24 @@ APIs are matched using `GET /path` format with support for path parameters:
|
|
|
116
141
|
## Cache Key Structure
|
|
117
142
|
|
|
118
143
|
```javascript
|
|
144
|
+
// Public API (scope: "tenant")
|
|
119
145
|
{
|
|
120
146
|
"l1": "tenant_id",
|
|
121
147
|
"l2": "serviceName:GET:/path:queryHash"
|
|
122
148
|
}
|
|
149
|
+
|
|
150
|
+
// Private API (scope: "tenant_user")
|
|
151
|
+
{
|
|
152
|
+
"l1": "tenant_id",
|
|
153
|
+
"l2": "serviceName:GET:/path:queryHash:u:user_id"
|
|
154
|
+
}
|
|
123
155
|
```
|
|
124
156
|
|
|
125
157
|
- **tenant_id**: Isolates cache per tenant
|
|
126
158
|
- **serviceName**: The target service name
|
|
127
159
|
- **path**: The API path
|
|
128
160
|
- **queryHash**: MD5 hash of sorted query parameters
|
|
161
|
+
- **user_id**: The logged-in user id (only for `tenant_user` scope), isolating cache per user within the tenant
|
|
129
162
|
|
|
130
163
|
### Query Parameter Handling
|
|
131
164
|
|
|
@@ -199,7 +232,9 @@ Content-Type: application/json
|
|
|
199
232
|
## Usage Notes
|
|
200
233
|
|
|
201
234
|
- Only GET requests are cached
|
|
202
|
-
- Cache is tenant-isolated
|
|
235
|
+
- Cache is tenant-isolated; private APIs are additionally user-isolated (`tenant_user` scope)
|
|
236
|
+
- Public APIs default to `tenant` scope, private APIs default to `tenant_user` scope
|
|
237
|
+
- Private (`tenant_user`) requests without a resolved user are not cached (safety)
|
|
203
238
|
- Query parameters affect cache key
|
|
204
239
|
- Only 2xx responses are cached
|
|
205
240
|
- Headers like `X-Cache` are not stored in cache
|
|
@@ -226,12 +261,15 @@ Content-Type: application/json
|
|
|
226
261
|
}
|
|
227
262
|
```
|
|
228
263
|
|
|
229
|
-
### User-Specific Data
|
|
264
|
+
### User-Specific Data (Private API)
|
|
230
265
|
|
|
231
266
|
```javascript
|
|
232
|
-
//
|
|
267
|
+
// Private API: cache key includes tenant ID AND user ID,
|
|
268
|
+
// so each user within a tenant has a separate cache entry.
|
|
269
|
+
// "tenant_user" is the default scope for private APIs, shown here explicitly.
|
|
233
270
|
"GET /users/profile": {
|
|
234
271
|
"enabled": true,
|
|
235
|
-
"ttl": 60000
|
|
272
|
+
"ttl": 60000, // 1 minute
|
|
273
|
+
"scope": "tenant_user"
|
|
236
274
|
}
|
|
237
275
|
```
|
|
@@ -59,6 +59,7 @@ The Idempotency middleware prevents duplicate write operations by tracking reque
|
|
|
59
59
|
{
|
|
60
60
|
"idempotency": {
|
|
61
61
|
"model": "memory", // Storage: "memory" or "mongo"
|
|
62
|
+
"enforce": false, // Global default for enforcement (optional)
|
|
62
63
|
"services": { // Service configurations
|
|
63
64
|
"av": { // Service name
|
|
64
65
|
"enabled": true, // Enable for this service
|
|
@@ -71,6 +72,7 @@ The Idempotency middleware prevents duplicate write operations by tracking reque
|
|
|
71
72
|
},
|
|
72
73
|
"payment": {
|
|
73
74
|
"enabled": true,
|
|
75
|
+
"enforce": true, // Require Idempotency-Key on matched APIs
|
|
74
76
|
"ttl": 120000,
|
|
75
77
|
"apis": [
|
|
76
78
|
"POST /charge",
|
|
@@ -85,10 +87,18 @@ The Idempotency middleware prevents duplicate write operations by tracking reque
|
|
|
85
87
|
| Field | Type | Default | Description |
|
|
86
88
|
|-------|------|---------|-------------|
|
|
87
89
|
| `model` | string | `"memory"` | Storage backend: `"memory"` or `"mongo"` |
|
|
90
|
+
| `enforce` | boolean | `false` | Global default enforcement (overridden per service) |
|
|
88
91
|
| `services.[name].enabled` | boolean | `false` | Enable idempotency for this service |
|
|
92
|
+
| `services.[name].enforce` | boolean | *(global)* | Require `Idempotency-Key` on matched APIs; overrides the global `enforce`. If unset, falls back to the global default |
|
|
89
93
|
| `services.[name].ttl` | number | `60000` | Key expiration time in milliseconds |
|
|
90
94
|
| `services.[name].apis` | string[] | `[]` | API patterns to protect (e.g., `"POST /path"`) |
|
|
91
95
|
|
|
96
|
+
## Enforcement
|
|
97
|
+
|
|
98
|
+
By default, requests without an `Idempotency-Key` header pass through untouched (backwards compatible). When `enforce` is enabled, a matched, non-GET API **requires** the header — a missing key returns **428 Precondition Required** (code `182`).
|
|
99
|
+
|
|
100
|
+
Resolution order: `services.[name].enforce` takes precedence when set to a boolean; otherwise the top-level `enforce` applies. Enforcement never affects GET requests, unconfigured/disabled services, or APIs outside the configured `apis` list.
|
|
101
|
+
|
|
92
102
|
## API Pattern Matching
|
|
93
103
|
|
|
94
104
|
APIs are matched using `METHOD /path` format with support for path parameters:
|
|
@@ -181,6 +191,26 @@ Content-Type: application/json
|
|
|
181
191
|
}
|
|
182
192
|
```
|
|
183
193
|
|
|
194
|
+
### Enforced (Missing Key)
|
|
195
|
+
|
|
196
|
+
Returned when the matched API has enforcement enabled and no `Idempotency-Key` header is provided.
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
HTTP/1.1 428 Precondition Required
|
|
200
|
+
Content-Type: application/json
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
"result": false,
|
|
204
|
+
"errors": {
|
|
205
|
+
"codes": [182],
|
|
206
|
+
"details": [{
|
|
207
|
+
"code": 182,
|
|
208
|
+
"message": "Idempotency-Key header is required for this API."
|
|
209
|
+
}]
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
184
214
|
## Key Structure
|
|
185
215
|
|
|
186
216
|
```javascript
|
|
@@ -198,6 +228,7 @@ Keys are isolated per tenant to prevent cross-tenant collisions.
|
|
|
198
228
|
|------|-------------|-------------|
|
|
199
229
|
| 180 | 400 | Invalid Idempotency-Key format (not UUID v4) |
|
|
200
230
|
| 181 | 409 | Request with this key is still being processed |
|
|
231
|
+
| 182 | 428 | Idempotency-Key header required (enforcement enabled) |
|
|
201
232
|
|
|
202
233
|
## Usage Notes
|
|
203
234
|
|
package/mw/cache/index.js
CHANGED
|
@@ -106,11 +106,36 @@ module.exports = function (configuration) {
|
|
|
106
106
|
return next();
|
|
107
107
|
}
|
|
108
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
|
+
|
|
109
133
|
const ttl = apiConfig.ttl || cacheConfig.defaultTTL || 300000;
|
|
110
134
|
const queryHash = hashQuery(req.query);
|
|
135
|
+
const userPart = (scope === 'tenant_user') ? `:u:${userId}` : '';
|
|
111
136
|
const key = {
|
|
112
137
|
'l1': req.soajs.tenant.id,
|
|
113
|
-
'l2': `${serviceName}:GET:${apiPath}:${queryHash}`
|
|
138
|
+
'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
|
|
114
139
|
};
|
|
115
140
|
|
|
116
141
|
const processCache = async () => {
|
package/mw/cache/model/memory.js
CHANGED
|
@@ -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/mw/idempotency/index.js
CHANGED
|
@@ -62,11 +62,9 @@ module.exports = function (configuration) {
|
|
|
62
62
|
return (req, res, next) => {
|
|
63
63
|
const idempotencyKey = req.headers['idempotency-key'];
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!isValidUUIDv4(idempotencyKey)) {
|
|
65
|
+
// Validate format only when a key was actually provided. The decision on a
|
|
66
|
+
// missing key is deferred until we know whether the target API enforces it.
|
|
67
|
+
if (idempotencyKey && !isValidUUIDv4(idempotencyKey)) {
|
|
70
68
|
req.soajs.controllerResponse({
|
|
71
69
|
'status': 400,
|
|
72
70
|
'code': 180,
|
|
@@ -103,6 +101,22 @@ module.exports = function (configuration) {
|
|
|
103
101
|
return next();
|
|
104
102
|
}
|
|
105
103
|
|
|
104
|
+
// Request targets an idempotency-enabled and matched API. Resolve enforcement:
|
|
105
|
+
// service-level 'enforce' overrides the global default.
|
|
106
|
+
const enforce = (typeof serviceConfig.enforce === 'boolean') ? serviceConfig.enforce : (idempotencyConfig.enforce === true);
|
|
107
|
+
|
|
108
|
+
if (!idempotencyKey) {
|
|
109
|
+
if (enforce) {
|
|
110
|
+
req.soajs.controllerResponse({
|
|
111
|
+
'status': 428,
|
|
112
|
+
'code': 182,
|
|
113
|
+
'msg': 'Idempotency-Key header is required for this API.'
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
return next();
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
const modelType = idempotencyConfig.model || 'memory';
|
|
107
121
|
if (!model[modelType]) {
|
|
108
122
|
configuration.log.warn('Idempotency: Unknown model [' + modelType + ']. It can only be [memory || mongo]');
|
|
@@ -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': () => {
|
|
@@ -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
|
@@ -221,7 +221,7 @@ describe("Unit test for: mw - cache", () => {
|
|
|
221
221
|
query: {},
|
|
222
222
|
soajs: {
|
|
223
223
|
tenant: { id: 'tenant-cache-miss' },
|
|
224
|
-
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
224
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
|
|
225
225
|
registry: {
|
|
226
226
|
custom: {
|
|
227
227
|
gateway: {
|
|
@@ -264,7 +264,7 @@ describe("Unit test for: mw - cache", () => {
|
|
|
264
264
|
query: {},
|
|
265
265
|
soajs: {
|
|
266
266
|
tenant: { id: 'tenant-param-cache' },
|
|
267
|
-
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'users', '123'] } },
|
|
267
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'users', '123'], isAPIPublic: true } },
|
|
268
268
|
registry: {
|
|
269
269
|
custom: {
|
|
270
270
|
gateway: {
|
|
@@ -306,7 +306,7 @@ describe("Unit test for: mw - cache", () => {
|
|
|
306
306
|
query: {},
|
|
307
307
|
soajs: {
|
|
308
308
|
tenant: { id: 'tenant-default-ttl' },
|
|
309
|
-
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
309
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
|
|
310
310
|
registry: {
|
|
311
311
|
custom: {
|
|
312
312
|
gateway: {
|
|
@@ -345,7 +345,7 @@ describe("Unit test for: mw - cache", () => {
|
|
|
345
345
|
query: { page: '1', limit: '10' },
|
|
346
346
|
soajs: {
|
|
347
347
|
tenant: { id: 'tenant-query' },
|
|
348
|
-
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
348
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
|
|
349
349
|
registry: {
|
|
350
350
|
custom: {
|
|
351
351
|
gateway: {
|
|
@@ -381,7 +381,7 @@ describe("Unit test for: mw - cache", () => {
|
|
|
381
381
|
query: {},
|
|
382
382
|
soajs: {
|
|
383
383
|
tenant: { id: 'tenant-empty-query' },
|
|
384
|
-
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
384
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'], isAPIPublic: true } },
|
|
385
385
|
registry: {
|
|
386
386
|
custom: {
|
|
387
387
|
gateway: {
|
|
@@ -458,4 +458,121 @@ describe("Unit test for: mw - cache", () => {
|
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
460
|
|
|
461
|
+
describe("scope handling (public vs private)", () => {
|
|
462
|
+
let noopRes = {
|
|
463
|
+
setHeader: function() {},
|
|
464
|
+
writeHead: function() { return this; },
|
|
465
|
+
write: function() { return true; },
|
|
466
|
+
end: function() {}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
function buildReq(serviceParamsExtra, uracDriver, apiConfig) {
|
|
470
|
+
return {
|
|
471
|
+
method: 'GET',
|
|
472
|
+
query: {},
|
|
473
|
+
soajs: {
|
|
474
|
+
tenant: { id: 'tenant-scope' },
|
|
475
|
+
uracDriver: uracDriver,
|
|
476
|
+
controller: {
|
|
477
|
+
serviceParams: Object.assign({ name: 'test', serviceInfo: ['', 'test', 'test'] }, serviceParamsExtra)
|
|
478
|
+
},
|
|
479
|
+
registry: {
|
|
480
|
+
custom: {
|
|
481
|
+
gateway: {
|
|
482
|
+
value: {
|
|
483
|
+
cache: {
|
|
484
|
+
model: 'memory',
|
|
485
|
+
services: {
|
|
486
|
+
test: { enabled: true, apis: { 'GET /test': Object.assign({ enabled: true, ttl: 30000 }, apiConfig) } }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
it("public API: should cache scoped to tenant only (no user in key)", (done) => {
|
|
498
|
+
let req = buildReq({ isAPIPublic: true }, { id: 'user-1' }, {});
|
|
499
|
+
let functionMw = mw(mockConfig);
|
|
500
|
+
functionMw(req, noopRes, (error) => {
|
|
501
|
+
assert.ifError(error);
|
|
502
|
+
assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
|
|
503
|
+
assert.strictEqual(req.soajs.cacheContext.key.l1, 'tenant-scope');
|
|
504
|
+
assert.strictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:'), -1, 'public key must not contain user part');
|
|
505
|
+
done();
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("private API: should default to tenant_user scope (user in key)", (done) => {
|
|
510
|
+
let req = buildReq({ isAPIPublic: false }, { id: 'user-1' }, {});
|
|
511
|
+
let functionMw = mw(mockConfig);
|
|
512
|
+
functionMw(req, noopRes, (error) => {
|
|
513
|
+
assert.ifError(error);
|
|
514
|
+
assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
|
|
515
|
+
assert.notStrictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:user-1'), -1, 'private key must contain user part');
|
|
516
|
+
done();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("private API: different users get different cache keys", (done) => {
|
|
521
|
+
let functionMw = mw(mockConfig);
|
|
522
|
+
let reqA = buildReq({ isAPIPublic: false }, { id: 'user-A' }, {});
|
|
523
|
+
let reqB = buildReq({ isAPIPublic: false }, { id: 'user-B' }, {});
|
|
524
|
+
functionMw(reqA, noopRes, (errA) => {
|
|
525
|
+
assert.ifError(errA);
|
|
526
|
+
functionMw(reqB, noopRes, (errB) => {
|
|
527
|
+
assert.ifError(errB);
|
|
528
|
+
assert.notStrictEqual(reqA.soajs.cacheContext.key.l2, reqB.soajs.cacheContext.key.l2, 'different users must not share a key');
|
|
529
|
+
done();
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("private API without resolved user: should skip caching (no cacheContext)", (done) => {
|
|
535
|
+
let req = buildReq({ isAPIPublic: false }, null, {});
|
|
536
|
+
let functionMw = mw(mockConfig);
|
|
537
|
+
functionMw(req, noopRes, (error) => {
|
|
538
|
+
assert.ifError(error);
|
|
539
|
+
assert.strictEqual(req.soajs.cacheContext, undefined, 'must not set cacheContext when user is unresolved');
|
|
540
|
+
done();
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("scope override: private API forced to tenant scope", (done) => {
|
|
545
|
+
let req = buildReq({ isAPIPublic: false }, { id: 'user-1' }, { scope: 'tenant' });
|
|
546
|
+
let functionMw = mw(mockConfig);
|
|
547
|
+
functionMw(req, noopRes, (error) => {
|
|
548
|
+
assert.ifError(error);
|
|
549
|
+
assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
|
|
550
|
+
assert.strictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:'), -1, 'overridden tenant scope must not contain user part');
|
|
551
|
+
done();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("scope override: public API forced to tenant_user scope", (done) => {
|
|
556
|
+
let req = buildReq({ isAPIPublic: true }, { id: 'user-1' }, { scope: 'tenant_user' });
|
|
557
|
+
let functionMw = mw(mockConfig);
|
|
558
|
+
functionMw(req, noopRes, (error) => {
|
|
559
|
+
assert.ifError(error);
|
|
560
|
+
assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
|
|
561
|
+
assert.notStrictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:user-1'), -1, 'overridden tenant_user scope must contain user part');
|
|
562
|
+
done();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("invalid scope: should fall back to default (private -> tenant_user)", (done) => {
|
|
567
|
+
let req = buildReq({ isAPIPublic: false }, { id: 'user-1' }, { scope: 'bogus' });
|
|
568
|
+
let functionMw = mw(mockConfig);
|
|
569
|
+
functionMw(req, noopRes, (error) => {
|
|
570
|
+
assert.ifError(error);
|
|
571
|
+
assert.ok(req.soajs.cacheContext, 'cacheContext should be set on MISS');
|
|
572
|
+
assert.notStrictEqual(req.soajs.cacheContext.key.l2.indexOf(':u:user-1'), -1, 'invalid scope must fall back to tenant_user for private API');
|
|
573
|
+
done();
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
461
578
|
});
|
|
@@ -30,6 +30,7 @@ describe("Unit test for: mw - idempotency", () => {
|
|
|
30
30
|
it("should call next() when no Idempotency-Key header", (done) => {
|
|
31
31
|
let req = {
|
|
32
32
|
headers: {},
|
|
33
|
+
method: 'POST',
|
|
33
34
|
soajs: {
|
|
34
35
|
tenant: { id: '123' },
|
|
35
36
|
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
@@ -313,6 +314,244 @@ describe("Unit test for: mw - idempotency", () => {
|
|
|
313
314
|
});
|
|
314
315
|
});
|
|
315
316
|
|
|
317
|
+
describe("enforcement", () => {
|
|
318
|
+
it("should return 428 when enforced and no key on matched API", (done) => {
|
|
319
|
+
let responseData = null;
|
|
320
|
+
let req = {
|
|
321
|
+
headers: {},
|
|
322
|
+
method: 'POST',
|
|
323
|
+
soajs: {
|
|
324
|
+
tenant: { id: '123' },
|
|
325
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
326
|
+
registry: {
|
|
327
|
+
custom: {
|
|
328
|
+
gateway: {
|
|
329
|
+
value: {
|
|
330
|
+
idempotency: {
|
|
331
|
+
services: {
|
|
332
|
+
test: { enabled: true, enforce: true, ttl: 60000, apis: ['POST /test'] }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
controllerResponse: (data) => {
|
|
340
|
+
responseData = data;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
let res = {};
|
|
345
|
+
let functionMw = mw(mockConfig);
|
|
346
|
+
functionMw(req, res, () => {
|
|
347
|
+
assert.fail('Should not call next');
|
|
348
|
+
});
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
assert.notStrictEqual(responseData, null);
|
|
351
|
+
assert.strictEqual(responseData.status, 428);
|
|
352
|
+
assert.strictEqual(responseData.code, 182);
|
|
353
|
+
done();
|
|
354
|
+
}, 10);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("should call next() when enforced and key is present", (done) => {
|
|
358
|
+
let req = {
|
|
359
|
+
headers: { 'idempotency-key': '550e8400-e29b-41d4-a716-446655440010' },
|
|
360
|
+
method: 'POST',
|
|
361
|
+
soajs: {
|
|
362
|
+
tenant: { id: 'tenant-enforce' },
|
|
363
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
364
|
+
registry: {
|
|
365
|
+
custom: {
|
|
366
|
+
gateway: {
|
|
367
|
+
value: {
|
|
368
|
+
idempotency: {
|
|
369
|
+
model: 'memory',
|
|
370
|
+
services: {
|
|
371
|
+
test: { enabled: true, enforce: true, ttl: 60000, apis: ['POST /test'] }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
let res = {
|
|
381
|
+
writeHead: function() { return this; },
|
|
382
|
+
write: function() { return true; },
|
|
383
|
+
end: function() {},
|
|
384
|
+
on: function() {}
|
|
385
|
+
};
|
|
386
|
+
let functionMw = mw(mockConfig);
|
|
387
|
+
functionMw(req, res, (error) => {
|
|
388
|
+
assert.ifError(error);
|
|
389
|
+
done();
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("should call next() when not enforced and no key (backwards compatible)", (done) => {
|
|
394
|
+
let req = {
|
|
395
|
+
headers: {},
|
|
396
|
+
method: 'POST',
|
|
397
|
+
soajs: {
|
|
398
|
+
tenant: { id: '123' },
|
|
399
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
400
|
+
registry: {
|
|
401
|
+
custom: {
|
|
402
|
+
gateway: {
|
|
403
|
+
value: {
|
|
404
|
+
idempotency: {
|
|
405
|
+
services: {
|
|
406
|
+
test: { enabled: true, ttl: 60000, apis: ['POST /test'] }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
let res = {};
|
|
416
|
+
let functionMw = mw(mockConfig);
|
|
417
|
+
functionMw(req, res, (error) => {
|
|
418
|
+
assert.ifError(error);
|
|
419
|
+
done();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("should return 428 when global enforce is set and service has no override", (done) => {
|
|
424
|
+
let responseData = null;
|
|
425
|
+
let req = {
|
|
426
|
+
headers: {},
|
|
427
|
+
method: 'POST',
|
|
428
|
+
soajs: {
|
|
429
|
+
tenant: { id: '123' },
|
|
430
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
431
|
+
registry: {
|
|
432
|
+
custom: {
|
|
433
|
+
gateway: {
|
|
434
|
+
value: {
|
|
435
|
+
idempotency: {
|
|
436
|
+
enforce: true,
|
|
437
|
+
services: {
|
|
438
|
+
test: { enabled: true, ttl: 60000, apis: ['POST /test'] }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
controllerResponse: (data) => {
|
|
446
|
+
responseData = data;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
let res = {};
|
|
451
|
+
let functionMw = mw(mockConfig);
|
|
452
|
+
functionMw(req, res, () => {
|
|
453
|
+
assert.fail('Should not call next');
|
|
454
|
+
});
|
|
455
|
+
setTimeout(() => {
|
|
456
|
+
assert.notStrictEqual(responseData, null);
|
|
457
|
+
assert.strictEqual(responseData.status, 428);
|
|
458
|
+
assert.strictEqual(responseData.code, 182);
|
|
459
|
+
done();
|
|
460
|
+
}, 10);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("should let service enforce=false override global enforce=true", (done) => {
|
|
464
|
+
let req = {
|
|
465
|
+
headers: {},
|
|
466
|
+
method: 'POST',
|
|
467
|
+
soajs: {
|
|
468
|
+
tenant: { id: '123' },
|
|
469
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
470
|
+
registry: {
|
|
471
|
+
custom: {
|
|
472
|
+
gateway: {
|
|
473
|
+
value: {
|
|
474
|
+
idempotency: {
|
|
475
|
+
enforce: true,
|
|
476
|
+
services: {
|
|
477
|
+
test: { enabled: true, enforce: false, ttl: 60000, apis: ['POST /test'] }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
let res = {};
|
|
487
|
+
let functionMw = mw(mockConfig);
|
|
488
|
+
functionMw(req, res, (error) => {
|
|
489
|
+
assert.ifError(error);
|
|
490
|
+
done();
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("should not enforce on GET requests", (done) => {
|
|
495
|
+
let req = {
|
|
496
|
+
headers: {},
|
|
497
|
+
method: 'GET',
|
|
498
|
+
soajs: {
|
|
499
|
+
tenant: { id: '123' },
|
|
500
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
501
|
+
registry: {
|
|
502
|
+
custom: {
|
|
503
|
+
gateway: {
|
|
504
|
+
value: {
|
|
505
|
+
idempotency: {
|
|
506
|
+
services: {
|
|
507
|
+
test: { enabled: true, enforce: true, ttl: 60000, apis: ['POST /test'] }
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
let res = {};
|
|
517
|
+
let functionMw = mw(mockConfig);
|
|
518
|
+
functionMw(req, res, (error) => {
|
|
519
|
+
assert.ifError(error);
|
|
520
|
+
done();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("should not enforce when API is not in the configured list", (done) => {
|
|
525
|
+
let req = {
|
|
526
|
+
headers: {},
|
|
527
|
+
method: 'POST',
|
|
528
|
+
soajs: {
|
|
529
|
+
tenant: { id: '123' },
|
|
530
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'other'] } },
|
|
531
|
+
registry: {
|
|
532
|
+
custom: {
|
|
533
|
+
gateway: {
|
|
534
|
+
value: {
|
|
535
|
+
idempotency: {
|
|
536
|
+
services: {
|
|
537
|
+
test: { enabled: true, enforce: true, ttl: 60000, apis: ['POST /test'] }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
let res = {};
|
|
547
|
+
let functionMw = mw(mockConfig);
|
|
548
|
+
functionMw(req, res, (error) => {
|
|
549
|
+
assert.ifError(error);
|
|
550
|
+
done();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
316
555
|
describe("UUID validation", () => {
|
|
317
556
|
it("should accept valid UUID v4", (done) => {
|
|
318
557
|
let req = {
|