soajs.controller 4.3.19 → 4.3.20
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/maintenance-routes.md +31 -0
- package/docs/middleware/cache.md +74 -1
- package/mw/cache/index.js +24 -5
- package/mw/cache/invalidate.js +71 -0
- package/mw/cache/model/mongo.js +95 -1
- package/mw/gotoService/redirectToService.js +1 -1
- package/package.json +1 -1
- package/server/maintenance.js +41 -0
- package/test/data/provision/customRegistry/gateway.js +15 -0
- package/test/data/provision/hosts/spaces.js +12 -0
- package/test/data/provision/marketplace/spaces.js +36 -0
- package/test/data/provision/oauth/token/second_owner_token.js +72 -0
- package/test/data/provision/products/dsbrd.js +24 -0
- package/test/data/provision/urac/users/second_owner.js +61 -0
- package/test/integration/UC7-cache-invalidate/index.js +209 -0
- package/test/integration/index.js +4 -0
- package/test/unit/index.js +2 -0
- package/test/unit/mw/cache/index.js +122 -0
- package/test/unit/mw/cache/invalidate.js +99 -0
- package/test/unit/mw/cache/mongo.js +104 -0
|
@@ -18,6 +18,7 @@ The maintenance server runs on a separate port from the main gateway to allow in
|
|
|
18
18
|
| `/loadProvision` | GET | Reload provision/tenant data |
|
|
19
19
|
| `/reloadRegistry` | GET | Reload environment registry |
|
|
20
20
|
| `/awarenessStat` | GET | Get service awareness statistics |
|
|
21
|
+
| `/cache/invalidate` | POST | Remove cached GET responses a service knows are out of date |
|
|
21
22
|
|
|
22
23
|
---
|
|
23
24
|
|
|
@@ -226,6 +227,36 @@ curl http://localhost:5000/awarenessStat
|
|
|
226
227
|
|
|
227
228
|
---
|
|
228
229
|
|
|
230
|
+
## 5. Cache Invalidation (`/cache/invalidate`)
|
|
231
|
+
|
|
232
|
+
**Endpoint:** `POST /cache/invalidate`
|
|
233
|
+
|
|
234
|
+
**Purpose:** Lets a service remove cached GET responses as soon as it knows they changed, instead of
|
|
235
|
+
waiting for their TTL. Needs the mongo cache model.
|
|
236
|
+
|
|
237
|
+
### Request
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
curl -X POST http://localhost:5000/cache/invalidate \
|
|
241
|
+
-H "Content-Type: application/json" \
|
|
242
|
+
-d '{"tenant": "5c0e74ba9acc3c5a84a51259", "service": "spaces", "api": "GET /spaces", "user": "5c8d0c505653de3985aa2001"}'
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Response
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{
|
|
249
|
+
"result": true,
|
|
250
|
+
"ts": 1792430000000,
|
|
251
|
+
"service": { "service": "soajs.controller", "version": "4.3.20", "type": "rest", "route": "/cache/invalidate" },
|
|
252
|
+
"data": { "removed": 3 }
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
What each field matches, and the error cases, are in [Cache, Invalidation](middleware/cache.md#invalidation).
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
229
260
|
## Security Considerations
|
|
230
261
|
|
|
231
262
|
### Network Access
|
package/docs/middleware/cache.md
CHANGED
|
@@ -157,7 +157,7 @@ APIs are matched using `GET /path` format with support for path parameters:
|
|
|
157
157
|
- **tenant_id**: Isolates cache per tenant
|
|
158
158
|
- **serviceName**: The target service name
|
|
159
159
|
- **path**: The API path
|
|
160
|
-
- **queryHash**: MD5 hash of sorted query parameters
|
|
160
|
+
- **queryHash**: MD5 hash of sorted query parameters, without `access_token`
|
|
161
161
|
- **user_id**: The logged-in user id (only for `tenant_user` scope), isolating cache per user within the tenant
|
|
162
162
|
|
|
163
163
|
### Query Parameter Handling
|
|
@@ -168,8 +168,14 @@ Query parameters are included in the cache key via MD5 hash:
|
|
|
168
168
|
GET /users?page=1&limit=10 → key includes hash of {"limit":"10","page":"1"}
|
|
169
169
|
GET /users?limit=10&page=1 → same hash (sorted)
|
|
170
170
|
GET /users → different key (no query hash)
|
|
171
|
+
GET /users?access_token=abc → same key as GET /users
|
|
171
172
|
```
|
|
172
173
|
|
|
174
|
+
`access_token` is left out: it identifies the caller, not what is asked for. With it in the key, every
|
|
175
|
+
token got its own entry, so a `tenant` scoped API was never shared between users, and a user's entries
|
|
176
|
+
stopped matching after each login or token refresh. The user of a `tenant_user` entry is already in the
|
|
177
|
+
key.
|
|
178
|
+
|
|
173
179
|
## Storage Backends
|
|
174
180
|
|
|
175
181
|
### Memory (Default)
|
|
@@ -229,6 +235,73 @@ Content-Type: application/json
|
|
|
229
235
|
- Error responses (4xx, 5xx)
|
|
230
236
|
- APIs not configured for caching
|
|
231
237
|
|
|
238
|
+
## Invalidation
|
|
239
|
+
|
|
240
|
+
A cached response normally lives until its TTL. A service that knows something changed can remove the
|
|
241
|
+
affected entries right away by calling the gateway's maintenance port:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
POST /cache/invalidate
|
|
245
|
+
Content-Type: application/json
|
|
246
|
+
|
|
247
|
+
{
|
|
248
|
+
"tenant": "5c0e74ba9acc3c5a84a51259", // required, the tenant the GET was cached under
|
|
249
|
+
"service": "spaces", // required
|
|
250
|
+
"api": "GET /spaces", // optional, as written in the cache configuration
|
|
251
|
+
"user": "5c8d0c505653de3985aa2001" // optional, the user id of a tenant_user entry
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{ "result": true, "data": { "removed": 3 } }
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| Given | Removed |
|
|
260
|
+
|-------|---------|
|
|
261
|
+
| `tenant`, `service`, `api`, `user` | That user's entries of that api |
|
|
262
|
+
| `tenant`, `service`, `api` | That api's entries for every user, and its tenant scoped entry |
|
|
263
|
+
| `tenant`, `service`, `user` | That user's entries of every api of the service |
|
|
264
|
+
| `tenant`, `service` | Every entry of the service in the tenant |
|
|
265
|
+
|
|
266
|
+
Every cached variant of a matching entry is removed: every query string, and every value of a path
|
|
267
|
+
parameter. `"api": "GET /space/:id"` removes `/space/1`, `/space/2` and so on. A `user` only matches
|
|
268
|
+
`tenant_user` entries, a `tenant` scoped entry has no user.
|
|
269
|
+
|
|
270
|
+
**Only the mongo model is supported.** It is the one shared by every gateway, so one call clears the
|
|
271
|
+
entry everywhere. With the memory model the route answers `400`.
|
|
272
|
+
|
|
273
|
+
**Example.** A service caches `GET /spaces`, the spaces a user is invited to, per user. When user A
|
|
274
|
+
invites user B, the invite API removes B's list before it answers, so B's next call loads the new one:
|
|
275
|
+
|
|
276
|
+
```javascript
|
|
277
|
+
// at the end of the invite API, before replying
|
|
278
|
+
await post("http://" + gatewayHost + ":" + maintenancePort + "/cache/invalidate", {
|
|
279
|
+
"tenant": req.soajs.tenant.id,
|
|
280
|
+
"service": "spaces",
|
|
281
|
+
"api": "GET /spaces",
|
|
282
|
+
"user": invitedUserId
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Wait for it with a short timeout. If the gateway cannot be reached, the change still stands and the
|
|
287
|
+
entry expires on its TTL.
|
|
288
|
+
|
|
289
|
+
**Requests already in flight.** A GET that started before the invalidation may have read the data
|
|
290
|
+
before the change. Its response is still sent to the client but not stored, so it cannot put the old
|
|
291
|
+
list back after the removal. Each invalidation is remembered for 15 minutes in the `cache_invalidation`
|
|
292
|
+
collection for this check. It compares the time the gateway handling the GET received it with the time
|
|
293
|
+
the invalidation ran, keep the gateways' clocks in sync.
|
|
294
|
+
|
|
295
|
+
Entries cached before this version carry no service, api or user, an invalidation does not match them,
|
|
296
|
+
they expire on their TTL.
|
|
297
|
+
|
|
298
|
+
| Status | Reason |
|
|
299
|
+
|--------|--------|
|
|
300
|
+
| `200` | Removed, `data.removed` is the number of entries |
|
|
301
|
+
| `400` | Missing `tenant` or `service`, an `api` not written as `GET /path`, a `user` that is not a string, caching not configured, or not the mongo model |
|
|
302
|
+
| `405` | Not a `POST` |
|
|
303
|
+
| `500` | The cache store failed or is not ready |
|
|
304
|
+
|
|
232
305
|
## Usage Notes
|
|
233
306
|
|
|
234
307
|
- Only GET requests are cached
|
package/mw/cache/index.js
CHANGED
|
@@ -16,17 +16,28 @@ const { logOnce } = require('../../lib/logOnce.js');
|
|
|
16
16
|
|
|
17
17
|
let model = { memory, mongo };
|
|
18
18
|
|
|
19
|
+
//NOTE: the access token is how the caller is identified, not what is asked for. Kept in, every token
|
|
20
|
+
// gets its own entry and a tenant scoped api is never shared, the user is already in the key of a
|
|
21
|
+
// tenant_user entry.
|
|
22
|
+
const QUERY_PARAMS_NOT_IN_KEY = ['access_token'];
|
|
23
|
+
|
|
19
24
|
function hashQuery(query) {
|
|
20
|
-
if (!query
|
|
25
|
+
if (!query) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
const params = Object.keys(query).filter((key) => !QUERY_PARAMS_NOT_IN_KEY.includes(key));
|
|
29
|
+
if (params.length === 0) {
|
|
21
30
|
return '';
|
|
22
31
|
}
|
|
23
|
-
const sortedQuery =
|
|
32
|
+
const sortedQuery = params.sort().reduce((obj, key) => {
|
|
24
33
|
obj[key] = query[key];
|
|
25
34
|
return obj;
|
|
26
35
|
}, {});
|
|
27
36
|
return crypto.createHash('md5').update(JSON.stringify(sortedQuery)).digest('hex');
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
//NOTE: returns the entry name as written in the configuration with its config, an invalidation names
|
|
40
|
+
// the api the same way
|
|
30
41
|
function matchesApi(method, path, apis) {
|
|
31
42
|
if (!apis || typeof apis !== 'object') {
|
|
32
43
|
return null;
|
|
@@ -34,7 +45,7 @@ function matchesApi(method, path, apis) {
|
|
|
34
45
|
const requestSignature = `${method} ${path}`;
|
|
35
46
|
|
|
36
47
|
if (apis[requestSignature]) {
|
|
37
|
-
return apis[requestSignature];
|
|
48
|
+
return { 'name': requestSignature, 'config': apis[requestSignature] };
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
for (const api in apis) {
|
|
@@ -59,7 +70,7 @@ function matchesApi(method, path, apis) {
|
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
if (matches) {
|
|
62
|
-
return apis[api];
|
|
73
|
+
return { 'name': api, 'config': apis[api] };
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
}
|
|
@@ -96,7 +107,8 @@ module.exports = function (configuration) {
|
|
|
96
107
|
return next();
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
const
|
|
110
|
+
const matchedApi = matchesApi(method, apiPath, serviceConfig.apis);
|
|
111
|
+
const apiConfig = matchedApi ? matchedApi.config : null;
|
|
100
112
|
if (!apiConfig || !apiConfig.enabled) {
|
|
101
113
|
return next();
|
|
102
114
|
}
|
|
@@ -141,6 +153,7 @@ module.exports = function (configuration) {
|
|
|
141
153
|
'l2': `${serviceName}:GET:${apiPath}:${queryHash}${userPart}`
|
|
142
154
|
};
|
|
143
155
|
|
|
156
|
+
const startedAt = Date.now();
|
|
144
157
|
const processCache = async () => {
|
|
145
158
|
try {
|
|
146
159
|
const cached = await model[modelType].get(key);
|
|
@@ -157,6 +170,12 @@ module.exports = function (configuration) {
|
|
|
157
170
|
req.soajs.cacheContext = {
|
|
158
171
|
key: key,
|
|
159
172
|
ttl: ttl,
|
|
173
|
+
meta: {
|
|
174
|
+
service: serviceName,
|
|
175
|
+
api: matchedApi.name,
|
|
176
|
+
user: (scope === 'tenant_user') ? String(userId) : null,
|
|
177
|
+
startedAt: startedAt
|
|
178
|
+
},
|
|
160
179
|
modelType: modelType,
|
|
161
180
|
model: model[modelType],
|
|
162
181
|
log: configuration.log
|
|
@@ -0,0 +1,71 @@
|
|
|
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 mongo = require('./model/mongo.js');
|
|
13
|
+
|
|
14
|
+
const API_REGEX = /^GET \/\S*$/;
|
|
15
|
+
|
|
16
|
+
function isNonEmptyString(value) {
|
|
17
|
+
return typeof value === 'string' && value.length > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Remove cached GET responses a service knows are out of date, called on the maintenance port.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: only the mongo model is supported, it is the one shared by every gateway, the memory model
|
|
24
|
+
* is per gateway and meant for testing
|
|
25
|
+
*
|
|
26
|
+
* @param registry the gateway registry
|
|
27
|
+
* @param input {tenant, service, api, user} tenant and service are required
|
|
28
|
+
* @returns {Promise<{status: number, removed: number}|{status: number, error: string}>}
|
|
29
|
+
*/
|
|
30
|
+
async function invalidate(registry, input) {
|
|
31
|
+
if (!input || typeof input !== 'object') {
|
|
32
|
+
return { 'status': 400, 'error': 'A JSON body with tenant and service is required.' };
|
|
33
|
+
}
|
|
34
|
+
if (!isNonEmptyString(input.tenant) || !isNonEmptyString(input.service)) {
|
|
35
|
+
return { 'status': 400, 'error': 'tenant and service are required.' };
|
|
36
|
+
}
|
|
37
|
+
if (input.api !== undefined && (!isNonEmptyString(input.api) || !API_REGEX.test(input.api))) {
|
|
38
|
+
return { 'status': 400, 'error': 'api must be written as in the cache configuration, for example "GET /spaces".' };
|
|
39
|
+
}
|
|
40
|
+
if (input.user !== undefined && !isNonEmptyString(input.user)) {
|
|
41
|
+
return { 'status': 400, 'error': 'user must be a user id.' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cacheConfig = get(["custom", "gateway", "value", "cache"], registry);
|
|
45
|
+
if (!cacheConfig) {
|
|
46
|
+
return { 'status': 400, 'error': 'Caching is not configured.' };
|
|
47
|
+
}
|
|
48
|
+
if (cacheConfig.model !== 'mongo') {
|
|
49
|
+
return { 'status': 400, 'error': 'Cache invalidation needs the mongo model, the cache model is [' + (cacheConfig.model || 'memory') + '].' };
|
|
50
|
+
}
|
|
51
|
+
if (!mongo.mongo) {
|
|
52
|
+
return { 'status': 500, 'error': 'The cache store is not ready.' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const removed = await mongo.invalidateMatching({
|
|
57
|
+
'tenant': input.tenant,
|
|
58
|
+
'service': input.service,
|
|
59
|
+
'api': input.api,
|
|
60
|
+
'user': input.user
|
|
61
|
+
});
|
|
62
|
+
return { 'status': 200, 'removed': removed };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (mongo.log) {
|
|
65
|
+
mongo.log.error('Cache invalidate:', e.message);
|
|
66
|
+
}
|
|
67
|
+
return { 'status': 500, 'error': 'Cache invalidation failed.' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = invalidate;
|
package/mw/cache/model/mongo.js
CHANGED
|
@@ -12,6 +12,11 @@ const core = require('soajs.core.modules');
|
|
|
12
12
|
const Mongo = core.mongo;
|
|
13
13
|
|
|
14
14
|
const cache_store = 'cache_store';
|
|
15
|
+
const cache_invalidation = 'cache_invalidation';
|
|
16
|
+
|
|
17
|
+
//NOTE: an invalidation is remembered long enough to outlive any request that started before it,
|
|
18
|
+
// so that request cannot store what it read before the change
|
|
19
|
+
const INVALIDATION_RETENTION = 15 * 60 * 1000;
|
|
15
20
|
|
|
16
21
|
let model = {
|
|
17
22
|
'mongo': null,
|
|
@@ -35,6 +40,27 @@ let model = {
|
|
|
35
40
|
log.debug("Cache TTL index created: " + index);
|
|
36
41
|
}
|
|
37
42
|
});
|
|
43
|
+
model.mongo.createIndex(cache_store, { 'l1': 1, 'service': 1, 'api': 1, 'user': 1 }, {}, (err, index) => {
|
|
44
|
+
if (err) {
|
|
45
|
+
log.error("Cache invalidation lookup index creation failed: " + err.message);
|
|
46
|
+
} else {
|
|
47
|
+
log.debug("Cache invalidation lookup index created: " + index);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
model.mongo.createIndex(cache_invalidation, { 'l1': 1, 'service': 1, 'api': 1, 'user': 1 }, { unique: true }, (err, index) => {
|
|
51
|
+
if (err) {
|
|
52
|
+
log.error("Cache invalidation index creation failed: " + err.message);
|
|
53
|
+
} else {
|
|
54
|
+
log.debug("Cache invalidation index created: " + index);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
model.mongo.createIndex(cache_invalidation, { 'expiresAt': 1 }, { expireAfterSeconds: 0 }, (err, index) => {
|
|
58
|
+
if (err) {
|
|
59
|
+
log.error("Cache invalidation TTL index creation failed: " + err.message);
|
|
60
|
+
} else {
|
|
61
|
+
log.debug("Cache invalidation TTL index created: " + index);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
38
64
|
}
|
|
39
65
|
},
|
|
40
66
|
|
|
@@ -58,7 +84,15 @@ let model = {
|
|
|
58
84
|
}
|
|
59
85
|
},
|
|
60
86
|
|
|
61
|
-
|
|
87
|
+
/**
|
|
88
|
+
* @param key {l1, l2}
|
|
89
|
+
* @param response {statusCode, headers, body}
|
|
90
|
+
* @param ttl
|
|
91
|
+
* @param meta {service, api, user, startedAt} what an invalidation matches on, user is null for a
|
|
92
|
+
* tenant scoped entry
|
|
93
|
+
* @returns {Promise<boolean>} false when not stored
|
|
94
|
+
*/
|
|
95
|
+
'set': async (key, response, ttl, meta) => {
|
|
62
96
|
let condition = {
|
|
63
97
|
'l1': key.l1,
|
|
64
98
|
'l2': key.l2
|
|
@@ -70,13 +104,73 @@ let model = {
|
|
|
70
104
|
'cachedAt': new Date(),
|
|
71
105
|
'expiresAt': new Date(Date.now() + ttl)
|
|
72
106
|
};
|
|
107
|
+
if (meta) {
|
|
108
|
+
doc.service = meta.service;
|
|
109
|
+
doc.api = meta.api;
|
|
110
|
+
doc.user = meta.user || null;
|
|
111
|
+
}
|
|
73
112
|
let options = { 'upsert': true };
|
|
74
113
|
let s = { '$set': doc };
|
|
75
114
|
try {
|
|
115
|
+
//NOTE: a request that started before an invalidation covering it may have read the data
|
|
116
|
+
// before the change, its response is passed on but not stored
|
|
117
|
+
if (meta && meta.startedAt) {
|
|
118
|
+
let invalidated = await model.mongo.findOne(cache_invalidation, {
|
|
119
|
+
'l1': key.l1,
|
|
120
|
+
'service': meta.service,
|
|
121
|
+
'api': { '$in': [meta.api, null] },
|
|
122
|
+
'user': { '$in': [doc.user, null] },
|
|
123
|
+
'clearedAt': { '$gt': new Date(meta.startedAt) }
|
|
124
|
+
});
|
|
125
|
+
if (invalidated) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
76
129
|
await model.mongo.updateOne(cache_store, condition, s, options);
|
|
130
|
+
return true;
|
|
77
131
|
} catch (e) {
|
|
78
132
|
model.log.error('Cache set:', e.message);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Remove every cached variant of the matching entries, all query strings and path params.
|
|
139
|
+
*
|
|
140
|
+
* NOTE: the invalidation is recorded before the entries are removed, a request that read before the
|
|
141
|
+
* change and stores after the removal is caught by set
|
|
142
|
+
*
|
|
143
|
+
* @param criteria {tenant, service, api, user} api and user are optional, without them every api of
|
|
144
|
+
* the service, for every user and the tenant scoped entries
|
|
145
|
+
* @returns {Promise<number>} the number of entries removed
|
|
146
|
+
*/
|
|
147
|
+
'invalidateMatching': async (criteria) => {
|
|
148
|
+
let marker = {
|
|
149
|
+
'l1': criteria.tenant,
|
|
150
|
+
'service': criteria.service,
|
|
151
|
+
'api': criteria.api || null,
|
|
152
|
+
'user': criteria.user || null
|
|
153
|
+
};
|
|
154
|
+
let now = new Date();
|
|
155
|
+
await model.mongo.updateOne(cache_invalidation, marker, {
|
|
156
|
+
'$set': Object.assign({}, marker, {
|
|
157
|
+
'clearedAt': now,
|
|
158
|
+
'expiresAt': new Date(now.getTime() + INVALIDATION_RETENTION)
|
|
159
|
+
})
|
|
160
|
+
}, { 'upsert': true });
|
|
161
|
+
|
|
162
|
+
let condition = {
|
|
163
|
+
'l1': criteria.tenant,
|
|
164
|
+
'service': criteria.service
|
|
165
|
+
};
|
|
166
|
+
if (criteria.api) {
|
|
167
|
+
condition.api = criteria.api;
|
|
168
|
+
}
|
|
169
|
+
if (criteria.user) {
|
|
170
|
+
condition.user = criteria.user;
|
|
79
171
|
}
|
|
172
|
+
let response = await model.mongo.deleteMany(cache_store, condition);
|
|
173
|
+
return (response && response.deletedCount) || 0;
|
|
80
174
|
},
|
|
81
175
|
|
|
82
176
|
'invalidate': async (key) => {
|
|
@@ -411,7 +411,7 @@ module.exports = (configuration) => {
|
|
|
411
411
|
headers: cacheResponseHeaders,
|
|
412
412
|
body: cacheBody
|
|
413
413
|
};
|
|
414
|
-
cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl);
|
|
414
|
+
cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl, cacheContext.meta);
|
|
415
415
|
}
|
|
416
416
|
// Complete idempotency if applicable
|
|
417
417
|
//NOTE: only an answer of the service is replayed, a 5xx means it could not answer, release
|
package/package.json
CHANGED
package/server/maintenance.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
|
|
12
12
|
|
|
13
13
|
const registryModule = require("./../modules/registry");
|
|
14
|
+
const cacheInvalidate = require("./../mw/cache/invalidate.js");
|
|
14
15
|
|
|
15
16
|
const httpProxy = require('http-proxy');
|
|
16
17
|
const http = require('http');
|
|
@@ -311,6 +312,46 @@ let Maintenance = (core, log, param, serviceIp, regEnvironment, awareness_mw, so
|
|
|
311
312
|
};
|
|
312
313
|
proxy.web(req, res, {target: haTarget});
|
|
313
314
|
}
|
|
315
|
+
else if (parsedUrl.pathname === '/cache/invalidate') {
|
|
316
|
+
let response = maintenanceResponse(parsedUrl, param);
|
|
317
|
+
let reply = (status, extra) => {
|
|
318
|
+
res.writeHead(status, {'Content-Type': 'application/json'});
|
|
319
|
+
return res.end(JSON.stringify(Object.assign(response, extra)));
|
|
320
|
+
};
|
|
321
|
+
if (req.method !== 'POST') {
|
|
322
|
+
return reply(405, {"error": "Use POST."});
|
|
323
|
+
}
|
|
324
|
+
let body = "";
|
|
325
|
+
let tooLarge = false;
|
|
326
|
+
req.on('data', function (chunk) {
|
|
327
|
+
body += chunk;
|
|
328
|
+
//NOTE: the body only names what to remove, anything larger is not a valid request
|
|
329
|
+
if (body.length > 16384) {
|
|
330
|
+
tooLarge = true;
|
|
331
|
+
req.destroy();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
req.on('end', function () {
|
|
335
|
+
if (tooLarge) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
let input = null;
|
|
339
|
+
try {
|
|
340
|
+
input = body ? JSON.parse(body) : null;
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return reply(400, {"error": "The body is not valid JSON."});
|
|
343
|
+
}
|
|
344
|
+
cacheInvalidate(registryModule.get(), input).then((outcome) => {
|
|
345
|
+
if (outcome.status !== 200) {
|
|
346
|
+
return reply(outcome.status, {"error": outcome.error});
|
|
347
|
+
}
|
|
348
|
+
log.info("Cache invalidated for tenant [" + input.tenant + "] service [" + input.service + "]" +
|
|
349
|
+
(input.api ? " api [" + input.api + "]" : "") + (input.user ? " user [" + input.user + "]" : "") +
|
|
350
|
+
", removed [" + outcome.removed + "]");
|
|
351
|
+
return reply(200, {"result": true, "data": {"removed": outcome.removed}});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
}
|
|
314
355
|
else if (parsedUrl.pathname === '/loadProvision') {
|
|
315
356
|
provision.loadProvision(function (loaded) {
|
|
316
357
|
let response = maintenanceResponse(parsedUrl, param);
|
|
@@ -16,6 +16,21 @@ let doc = {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
+
"cache": {
|
|
20
|
+
"model": "mongo",
|
|
21
|
+
"defaultTTL": 60000,
|
|
22
|
+
"services": {
|
|
23
|
+
"spaces": {
|
|
24
|
+
"enabled": true,
|
|
25
|
+
"apis": {
|
|
26
|
+
"GET /spaces": { "enabled": true, "scope": "tenant_user" },
|
|
27
|
+
"GET /space/:id": { "enabled": true, "scope": "tenant_user" },
|
|
28
|
+
"GET /slow": { "enabled": true, "scope": "tenant_user" },
|
|
29
|
+
"GET /catalog": { "enabled": true, "scope": "tenant" }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
19
34
|
"traffic": {
|
|
20
35
|
"throttling": {
|
|
21
36
|
"idempotent": {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let service = {
|
|
4
|
+
_id: "5da078c92357684b57effcf4",
|
|
5
|
+
"type": "service",
|
|
6
|
+
name: "spaces",
|
|
7
|
+
configuration: {
|
|
8
|
+
group: "SOAJS test",
|
|
9
|
+
port: 4015,
|
|
10
|
+
requestTimeout: 30,
|
|
11
|
+
requestTimeoutRenewal: 5
|
|
12
|
+
},
|
|
13
|
+
versions: [{
|
|
14
|
+
"version": "1",
|
|
15
|
+
maintenance: {
|
|
16
|
+
"port": {
|
|
17
|
+
"type": "maintenance"
|
|
18
|
+
},
|
|
19
|
+
"readiness": "/heartbeat"
|
|
20
|
+
},
|
|
21
|
+
oauth: true,
|
|
22
|
+
extKeyRequired: true,
|
|
23
|
+
urac: true,
|
|
24
|
+
urac_Profile: false,
|
|
25
|
+
urac_ACL: false,
|
|
26
|
+
provision_ACL: false,
|
|
27
|
+
apis: [
|
|
28
|
+
{ l: "Spaces of the user", v: "/spaces", m: "get", group: "Integration test" },
|
|
29
|
+
{ l: "One space", v: "/space/:id", m: "get", group: "Integration test" },
|
|
30
|
+
{ l: "Spaces after a delay", v: "/slow", m: "get", group: "Integration test" },
|
|
31
|
+
{ l: "Same for the whole tenant", v: "/catalog", m: "get", group: "Integration test" }
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
};
|
|
36
|
+
module.exports = service;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let token = {
|
|
4
|
+
_id: "5cdc190fd52c82e0ddb1dcd8",
|
|
5
|
+
type: "accessToken",
|
|
6
|
+
token: "c4c4e5e5f6f6a7a7b8b8c9c9d0d0e1e1f2f2a301",
|
|
7
|
+
clientId: "5c0e74ba9acc3c5a84a51259",
|
|
8
|
+
user: {
|
|
9
|
+
_id: "5c8d0c505653de3985aa2001",
|
|
10
|
+
locked: true,
|
|
11
|
+
username: "second_owner",
|
|
12
|
+
firstName: "owner3'sèį",
|
|
13
|
+
lastName: "owner",
|
|
14
|
+
email: "me@localhost.com",
|
|
15
|
+
"ts": new Date().getTime(),
|
|
16
|
+
status: "active",
|
|
17
|
+
profile: {},
|
|
18
|
+
groups: [
|
|
19
|
+
"owner"
|
|
20
|
+
],
|
|
21
|
+
config: {
|
|
22
|
+
packages: {},
|
|
23
|
+
keys: {},
|
|
24
|
+
allowedTenants: [
|
|
25
|
+
{
|
|
26
|
+
tenant: {
|
|
27
|
+
id: "THYME_tID",
|
|
28
|
+
code: "THYME_CODE",
|
|
29
|
+
pin: {
|
|
30
|
+
code: "5678",
|
|
31
|
+
allowed: true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
groups: [
|
|
35
|
+
"waiter"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
tenant: {
|
|
40
|
+
id: "ELVIRA_tID",
|
|
41
|
+
code: "ELVIRA_CODE"
|
|
42
|
+
},
|
|
43
|
+
groups: [
|
|
44
|
+
"manager"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
tenant: {
|
|
50
|
+
id: "5c0e74ba9acc3c5a84a51259",
|
|
51
|
+
code: "DBTN",
|
|
52
|
+
pin: {
|
|
53
|
+
code: "1235",
|
|
54
|
+
allowed: true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
lastLogin: new Date().getTime(),
|
|
58
|
+
groupsConfig: {
|
|
59
|
+
allowedPackages: {
|
|
60
|
+
DSBRD: [
|
|
61
|
+
"DSBRD_OWNER"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
allowedEnvironments: {}
|
|
65
|
+
},
|
|
66
|
+
loginMode: "urac",
|
|
67
|
+
id: "5c8d0c505653de3985aa2001"
|
|
68
|
+
},
|
|
69
|
+
env: "dev",
|
|
70
|
+
expires: new Date((new Date().getFullYear()) + 2, 0, 1)
|
|
71
|
+
};
|
|
72
|
+
module.exports = token;
|
|
@@ -9,6 +9,22 @@ let prod = {
|
|
|
9
9
|
"scope": {
|
|
10
10
|
"acl": {
|
|
11
11
|
"dev": {
|
|
12
|
+
"spaces": {
|
|
13
|
+
"1": {
|
|
14
|
+
"access": true,
|
|
15
|
+
"get": [
|
|
16
|
+
{
|
|
17
|
+
"group": "Spaces group",
|
|
18
|
+
"apis": {
|
|
19
|
+
"/spaces": {},
|
|
20
|
+
"/space/:id": {},
|
|
21
|
+
"/slow": {},
|
|
22
|
+
"/catalog": {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
12
28
|
"idempotent": {
|
|
13
29
|
"1": {
|
|
14
30
|
"access": true,
|
|
@@ -217,6 +233,14 @@ let prod = {
|
|
|
217
233
|
"locked": true,
|
|
218
234
|
"acl": {
|
|
219
235
|
"dev": {
|
|
236
|
+
"spaces": [
|
|
237
|
+
{
|
|
238
|
+
"version": "1",
|
|
239
|
+
"get": [
|
|
240
|
+
"Spaces group"
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
],
|
|
220
244
|
"idempotent": [
|
|
221
245
|
{
|
|
222
246
|
"version": "1",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
let user = {
|
|
3
|
+
_id: "5c8d0c505653de3985aa2001",
|
|
4
|
+
locked: true,
|
|
5
|
+
username: "second_owner",
|
|
6
|
+
password: "$2a$12$geJJfv33wkYIXEAlDkeeuOgiQ6y6MjP/YxbqLdHdDSK7LDG.7n7Pq",
|
|
7
|
+
firstName: "owner3'sèį",
|
|
8
|
+
lastName: "owner",
|
|
9
|
+
email: "second_owner@localhost.com",
|
|
10
|
+
ts: 1552747600152,
|
|
11
|
+
status: "active",
|
|
12
|
+
profile: {
|
|
13
|
+
|
|
14
|
+
},
|
|
15
|
+
groups: [
|
|
16
|
+
"owner"
|
|
17
|
+
],
|
|
18
|
+
config: {
|
|
19
|
+
packages: {
|
|
20
|
+
|
|
21
|
+
},
|
|
22
|
+
keys: {
|
|
23
|
+
|
|
24
|
+
},
|
|
25
|
+
allowedTenants: [
|
|
26
|
+
{
|
|
27
|
+
tenant: {
|
|
28
|
+
id: "THYME_tID",
|
|
29
|
+
code: "THYME_CODE",
|
|
30
|
+
pin: {
|
|
31
|
+
code: "5678",
|
|
32
|
+
allowed: true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
groups: [
|
|
36
|
+
"waiter"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
tenant: {
|
|
41
|
+
id: "ELVIRA_tID",
|
|
42
|
+
code: "ELVIRA_CODE"
|
|
43
|
+
},
|
|
44
|
+
groups: [
|
|
45
|
+
"manager"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
tenant: {
|
|
51
|
+
id: "5c0e74ba9acc3c5a84a51259",
|
|
52
|
+
code: "DBTN",
|
|
53
|
+
pin: {
|
|
54
|
+
code: "1235",
|
|
55
|
+
allowed: true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
lastLogin: 1559836949372
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
module.exports = user;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const request = require('request');
|
|
6
|
+
const helper = require("../../helper.js");
|
|
7
|
+
let Controller = helper.requireModule('./server/controller');
|
|
8
|
+
let localConfig = helper.requireModule('./config.js');
|
|
9
|
+
|
|
10
|
+
//NOTE: the gateway custom registry caches the spaces service with the mongo model, per user except
|
|
11
|
+
// /catalog which is per tenant. Both tokens are users of the console tenant.
|
|
12
|
+
const tenantId = "5c0e74ba9acc3c5a84a51259";
|
|
13
|
+
const ownerToken = "cfb209a91b23896820f510aadbf1f4284b512123";
|
|
14
|
+
const ownerId = "5c8d0c505653de3985aa0ffd";
|
|
15
|
+
const secondToken = "c4c4e5e5f6f6a7a7b8b8c9c9d0d0e1e1f2f2a301";
|
|
16
|
+
const secondId = "5c8d0c505653de3985aa2001";
|
|
17
|
+
const maintenanceUri = 'http://127.0.0.1:5001/cache/invalidate';
|
|
18
|
+
|
|
19
|
+
function startService(calls, callback) {
|
|
20
|
+
const sApp = express();
|
|
21
|
+
const mApp = express();
|
|
22
|
+
let answer = (name) => (req, res) => {
|
|
23
|
+
calls[name] = (calls[name] || 0) + 1;
|
|
24
|
+
res.json({ "result": true, "data": { "call": calls[name], "url": req.url } });
|
|
25
|
+
};
|
|
26
|
+
sApp.get('/spaces', answer("spaces"));
|
|
27
|
+
sApp.get('/space/:id', answer("space"));
|
|
28
|
+
sApp.get('/catalog', answer("catalog"));
|
|
29
|
+
sApp.get('/slow', (req, res) => {
|
|
30
|
+
calls.slow = (calls.slow || 0) + 1;
|
|
31
|
+
let call = calls.slow;
|
|
32
|
+
setTimeout(() => {
|
|
33
|
+
res.json({ "result": true, "data": { "call": call } });
|
|
34
|
+
}, 1000);
|
|
35
|
+
});
|
|
36
|
+
mApp.get('/heartbeat', (req, res) => {
|
|
37
|
+
res.json({ "result": true, "ts": Date.now(), "service": { "service": "spaces", "type": "rest", "route": "/heartbeat" } });
|
|
38
|
+
});
|
|
39
|
+
let sAppServer = sApp.listen(4015, () => {
|
|
40
|
+
let mAppServer = mApp.listen(5016, () => {
|
|
41
|
+
return callback({ "sAppServer": sAppServer, "mAppServer": mAppServer });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("Integration for Usecase 7 - cache invalidation", function () {
|
|
47
|
+
let extKey = "3d90163cf9d6b3076ad26aa5ed58556348069258e5c6c941ee0f18448b570ad1c5c790e2d2a1989680c55f4904e2005ff5f8e71606e4aa641e67882f4210ebbc5460ff305dcb36e6ec2a2299cf0448ef60b9e38f41950ec251c1cf41f05f3ce9";
|
|
48
|
+
|
|
49
|
+
let c = new Controller({
|
|
50
|
+
"serviceName": localConfig.serviceName,
|
|
51
|
+
"serviceVersion": localConfig.serviceVersion,
|
|
52
|
+
"serviceGroup": localConfig.serviceGroup,
|
|
53
|
+
"servicePort": localConfig.servicePort,
|
|
54
|
+
"maintenance": localConfig.maintenance
|
|
55
|
+
});
|
|
56
|
+
let registry = null;
|
|
57
|
+
let log = null;
|
|
58
|
+
let service = null;
|
|
59
|
+
let server = null;
|
|
60
|
+
let serverMaintenance = null;
|
|
61
|
+
let mockServers = null;
|
|
62
|
+
let calls = {};
|
|
63
|
+
|
|
64
|
+
// GET through the gateway, resolves with the X-Cache header and the body
|
|
65
|
+
function get(path, token) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
request.get({
|
|
68
|
+
"uri": 'http://127.0.0.1:' + localConfig.servicePort + '/spaces' + path,
|
|
69
|
+
"json": true,
|
|
70
|
+
"headers": {
|
|
71
|
+
"key": extKey,
|
|
72
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36"
|
|
73
|
+
},
|
|
74
|
+
"qs": { "access_token": token }
|
|
75
|
+
}, (error, response, body) => {
|
|
76
|
+
assert.ifError(error);
|
|
77
|
+
resolve({ "cache": response.headers['x-cache'], "status": response.statusCode, body });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function invalidate(body, method) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
request({
|
|
85
|
+
"method": method || 'POST',
|
|
86
|
+
"uri": maintenanceUri,
|
|
87
|
+
"json": body === undefined ? true : body
|
|
88
|
+
}, (error, response, responseBody) => {
|
|
89
|
+
assert.ifError(error);
|
|
90
|
+
resolve({ "status": response.statusCode, "body": responseBody });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// the response is stored once the proxy finishes, after the client got it
|
|
96
|
+
const settle = () => new Promise((r) => setTimeout(r, 150));
|
|
97
|
+
|
|
98
|
+
before((done) => {
|
|
99
|
+
//NOTE: the unit tests run in the same process and connect the cache mongo model to a dummy
|
|
100
|
+
// database, the model connects only once, drop it so the gateway connects it to the real one
|
|
101
|
+
helper.requireModule('./mw/cache/model/mongo').mongo = null;
|
|
102
|
+
startService(calls, (servers) => {
|
|
103
|
+
mockServers = servers;
|
|
104
|
+
c.init((_registry, _log, _service, _server, _serverMaintenance) => {
|
|
105
|
+
registry = _registry;
|
|
106
|
+
log = _log;
|
|
107
|
+
service = _service;
|
|
108
|
+
server = _server;
|
|
109
|
+
serverMaintenance = _serverMaintenance;
|
|
110
|
+
c.start(registry, log, service, server, serverMaintenance, () => {
|
|
111
|
+
setTimeout(function () {
|
|
112
|
+
done();
|
|
113
|
+
}, 5000);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("a cached response is served from the cache", async () => {
|
|
120
|
+
let first = await get('/spaces', ownerToken);
|
|
121
|
+
assert.strictEqual(first.status, 200, JSON.stringify(first.body));
|
|
122
|
+
assert.strictEqual(first.cache, 'MISS');
|
|
123
|
+
await settle();
|
|
124
|
+
let second = await get('/spaces', ownerToken);
|
|
125
|
+
assert.strictEqual(second.cache, 'HIT');
|
|
126
|
+
assert.strictEqual(second.body.data.call, first.body.data.call);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("one user: every query variant of the api is removed, the other user keeps theirs", async () => {
|
|
130
|
+
await get('/spaces?page=1', ownerToken);
|
|
131
|
+
await get('/spaces?page=2', ownerToken);
|
|
132
|
+
await get('/spaces', secondToken);
|
|
133
|
+
await settle();
|
|
134
|
+
assert.strictEqual((await get('/spaces?page=1', ownerToken)).cache, 'HIT');
|
|
135
|
+
assert.strictEqual((await get('/spaces', secondToken)).cache, 'HIT');
|
|
136
|
+
|
|
137
|
+
let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /spaces", "user": ownerId });
|
|
138
|
+
assert.strictEqual(outcome.status, 200, JSON.stringify(outcome.body));
|
|
139
|
+
assert.strictEqual(outcome.body.result, true);
|
|
140
|
+
assert.strictEqual(outcome.body.data.removed, 3);
|
|
141
|
+
|
|
142
|
+
assert.strictEqual((await get('/spaces', ownerToken)).cache, 'MISS');
|
|
143
|
+
assert.strictEqual((await get('/spaces?page=1', ownerToken)).cache, 'MISS');
|
|
144
|
+
assert.strictEqual((await get('/spaces?page=2', ownerToken)).cache, 'MISS');
|
|
145
|
+
assert.strictEqual((await get('/spaces', secondToken)).cache, 'HIT');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("without a user every user's entries are removed", async () => {
|
|
149
|
+
await settle();
|
|
150
|
+
assert.strictEqual((await get('/spaces', ownerToken)).cache, 'HIT');
|
|
151
|
+
assert.strictEqual((await get('/spaces', secondToken)).cache, 'HIT');
|
|
152
|
+
let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /spaces" });
|
|
153
|
+
assert.strictEqual(outcome.status, 200);
|
|
154
|
+
assert.ok(outcome.body.data.removed >= 2);
|
|
155
|
+
assert.strictEqual((await get('/spaces', ownerToken)).cache, 'MISS');
|
|
156
|
+
assert.strictEqual((await get('/spaces', secondToken)).cache, 'MISS');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("an api with a path param is removed for every value", async () => {
|
|
160
|
+
await get('/space/1', ownerToken);
|
|
161
|
+
await get('/space/2', ownerToken);
|
|
162
|
+
await settle();
|
|
163
|
+
assert.strictEqual((await get('/space/1', ownerToken)).cache, 'HIT');
|
|
164
|
+
let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /space/:id", "user": ownerId });
|
|
165
|
+
assert.strictEqual(outcome.body.data.removed, 2);
|
|
166
|
+
assert.strictEqual((await get('/space/1', ownerToken)).cache, 'MISS');
|
|
167
|
+
assert.strictEqual((await get('/space/2', ownerToken)).cache, 'MISS');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("a tenant scoped entry is shared by users, and removed without a user", async () => {
|
|
171
|
+
await get('/catalog', ownerToken);
|
|
172
|
+
await settle();
|
|
173
|
+
assert.strictEqual((await get('/catalog', secondToken)).cache, 'HIT');
|
|
174
|
+
let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /catalog" });
|
|
175
|
+
assert.strictEqual(outcome.body.data.removed, 1);
|
|
176
|
+
assert.strictEqual((await get('/catalog', secondToken)).cache, 'MISS');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a request that started before the invalidation is not stored", async () => {
|
|
180
|
+
let inFlight = get('/slow', ownerToken);
|
|
181
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
182
|
+
let outcome = await invalidate({ "tenant": tenantId, "service": "spaces", "api": "GET /slow", "user": ownerId });
|
|
183
|
+
assert.strictEqual(outcome.status, 200);
|
|
184
|
+
let first = await inFlight;
|
|
185
|
+
assert.strictEqual(first.status, 200);
|
|
186
|
+
await settle();
|
|
187
|
+
let next = await get('/slow', ownerToken);
|
|
188
|
+
assert.strictEqual(next.cache, 'MISS');
|
|
189
|
+
assert.strictEqual(next.body.data.call, first.body.data.call + 1);
|
|
190
|
+
await settle();
|
|
191
|
+
assert.strictEqual((await get('/slow', ownerToken)).cache, 'HIT');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("refuses an invalid request", async () => {
|
|
195
|
+
assert.strictEqual((await invalidate({ "service": "spaces" })).status, 400);
|
|
196
|
+
assert.strictEqual((await invalidate({ "tenant": tenantId, "service": "spaces", "api": "/spaces" })).status, 400);
|
|
197
|
+
let wrongMethod = await invalidate(undefined, 'GET');
|
|
198
|
+
assert.strictEqual(wrongMethod.status, 405);
|
|
199
|
+
assert.strictEqual(wrongMethod.body.result, false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
after((done) => {
|
|
203
|
+
mockServers.sAppServer.close();
|
|
204
|
+
mockServers.mAppServer.close();
|
|
205
|
+
c.stop(registry, log, service, server, serverMaintenance, () => {
|
|
206
|
+
done();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -44,6 +44,10 @@ describe("starting integration tests", () => {
|
|
|
44
44
|
// to test idempotency with the mongo model
|
|
45
45
|
require("./UC6-idempotency/index.js");
|
|
46
46
|
|
|
47
|
+
|
|
48
|
+
// to test cache invalidation with the mongo model
|
|
49
|
+
require("./UC7-cache-invalidate/index.js");
|
|
50
|
+
|
|
47
51
|
done();
|
|
48
52
|
});
|
|
49
53
|
});
|
package/test/unit/index.js
CHANGED
|
@@ -42,6 +42,8 @@ describe("Starting Gateway Unit test", () => {
|
|
|
42
42
|
require("./mw/idempotency/mongo.js");
|
|
43
43
|
require("./mw/cache/index.js");
|
|
44
44
|
require("./mw/cache/memory.js");
|
|
45
|
+
require("./mw/cache/mongo.js");
|
|
46
|
+
require("./mw/cache/invalidate.js");
|
|
45
47
|
require("./mw/lastSeen/index.js");
|
|
46
48
|
|
|
47
49
|
require("./utilities/utils.js");
|
|
@@ -575,4 +575,126 @@ describe("Unit test for: mw - cache", () => {
|
|
|
575
575
|
});
|
|
576
576
|
});
|
|
577
577
|
|
|
578
|
+
describe("what an invalidation matches on", () => {
|
|
579
|
+
let noopRes = {
|
|
580
|
+
setHeader: function() {},
|
|
581
|
+
writeHead: function() { return this; },
|
|
582
|
+
write: function() { return true; },
|
|
583
|
+
end: function() {}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
function buildReq(path, isAPIPublic, apis) {
|
|
587
|
+
return {
|
|
588
|
+
method: 'GET',
|
|
589
|
+
query: { page: '2' },
|
|
590
|
+
soajs: {
|
|
591
|
+
tenant: { id: 'tenant-meta' },
|
|
592
|
+
uracDriver: { id: 'user-7' },
|
|
593
|
+
controller: {
|
|
594
|
+
serviceParams: { name: 'spaces', serviceInfo: ['', 'spaces'].concat(path.split('/').slice(1)), isAPIPublic: isAPIPublic }
|
|
595
|
+
},
|
|
596
|
+
registry: {
|
|
597
|
+
custom: {
|
|
598
|
+
gateway: {
|
|
599
|
+
value: {
|
|
600
|
+
cache: {
|
|
601
|
+
model: 'memory',
|
|
602
|
+
services: {
|
|
603
|
+
spaces: { enabled: true, apis: apis }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
it("records the service, the api as configured and the user", (done) => {
|
|
615
|
+
let before = Date.now();
|
|
616
|
+
let req = buildReq('/space/42', false, { 'GET /space/:id': { enabled: true } });
|
|
617
|
+
mw(mockConfig)(req, noopRes, (error) => {
|
|
618
|
+
assert.ifError(error);
|
|
619
|
+
let meta = req.soajs.cacheContext.meta;
|
|
620
|
+
assert.strictEqual(meta.service, 'spaces');
|
|
621
|
+
assert.strictEqual(meta.api, 'GET /space/:id');
|
|
622
|
+
assert.strictEqual(meta.user, 'user-7');
|
|
623
|
+
assert.ok(meta.startedAt >= before && meta.startedAt <= Date.now());
|
|
624
|
+
done();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("records no user for a tenant scoped api", (done) => {
|
|
629
|
+
let req = buildReq('/catalog', false, { 'GET /catalog': { enabled: true, scope: 'tenant' } });
|
|
630
|
+
mw(mockConfig)(req, noopRes, (error) => {
|
|
631
|
+
assert.ifError(error);
|
|
632
|
+
assert.strictEqual(req.soajs.cacheContext.meta.api, 'GET /catalog');
|
|
633
|
+
assert.strictEqual(req.soajs.cacheContext.meta.user, null);
|
|
634
|
+
done();
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
describe("the access token is not part of the key", () => {
|
|
640
|
+
let noopRes = {
|
|
641
|
+
setHeader: function() {},
|
|
642
|
+
writeHead: function() { return this; },
|
|
643
|
+
write: function() { return true; },
|
|
644
|
+
end: function() {}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
function buildReq(query, userId) {
|
|
648
|
+
return {
|
|
649
|
+
method: 'GET',
|
|
650
|
+
query: query,
|
|
651
|
+
soajs: {
|
|
652
|
+
tenant: { id: 'tenant-token' },
|
|
653
|
+
uracDriver: { id: userId },
|
|
654
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'catalog'], isAPIPublic: true } },
|
|
655
|
+
registry: {
|
|
656
|
+
custom: {
|
|
657
|
+
gateway: {
|
|
658
|
+
value: {
|
|
659
|
+
cache: {
|
|
660
|
+
model: 'memory',
|
|
661
|
+
services: {
|
|
662
|
+
test: { enabled: true, apis: { 'GET /catalog': { enabled: true, scope: 'tenant' } } }
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function keyOf(query, userId) {
|
|
674
|
+
return new Promise((resolve) => {
|
|
675
|
+
let req = buildReq(query, userId);
|
|
676
|
+
mw(mockConfig)(req, noopRes, () => {
|
|
677
|
+
resolve(req.soajs.cacheContext.key.l2);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
it("two users with their own tokens share a tenant scoped entry", async () => {
|
|
683
|
+
let ann = await keyOf({ access_token: 'AAA', page: '1' }, 'ann');
|
|
684
|
+
let bob = await keyOf({ access_token: 'BBB', page: '1' }, 'bob');
|
|
685
|
+
assert.strictEqual(ann, bob);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("a token alone gives the same key as no query", async () => {
|
|
689
|
+
let withToken = await keyOf({ access_token: 'AAA' }, 'ann');
|
|
690
|
+
let without = await keyOf({}, 'ann');
|
|
691
|
+
assert.strictEqual(withToken, without);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("the other query params still make the key", async () => {
|
|
695
|
+
let one = await keyOf({ access_token: 'AAA', page: '1' }, 'ann');
|
|
696
|
+
let two = await keyOf({ access_token: 'AAA', page: '2' }, 'ann');
|
|
697
|
+
assert.notStrictEqual(one, two);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
578
700
|
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const helper = require("../../../helper.js");
|
|
4
|
+
const invalidate = helper.requireModule('./mw/cache/invalidate');
|
|
5
|
+
const model = helper.requireModule('./mw/cache/model/mongo');
|
|
6
|
+
const assert = require('assert');
|
|
7
|
+
|
|
8
|
+
describe("Unit test for: mw - cache invalidate", () => {
|
|
9
|
+
|
|
10
|
+
let received = null;
|
|
11
|
+
let failure = null;
|
|
12
|
+
let originalInvalidateMatching = null;
|
|
13
|
+
let originalMongo = null;
|
|
14
|
+
|
|
15
|
+
function registry(model) {
|
|
16
|
+
return { custom: { gateway: { value: { cache: { model: model, services: {} } } } } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
before(() => {
|
|
20
|
+
originalInvalidateMatching = model.invalidateMatching;
|
|
21
|
+
originalMongo = model.mongo;
|
|
22
|
+
model.mongo = {};
|
|
23
|
+
model.invalidateMatching = async (criteria) => {
|
|
24
|
+
received = criteria;
|
|
25
|
+
if (failure) {
|
|
26
|
+
throw failure;
|
|
27
|
+
}
|
|
28
|
+
return 2;
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
after(() => {
|
|
32
|
+
model.invalidateMatching = originalInvalidateMatching;
|
|
33
|
+
model.mongo = originalMongo;
|
|
34
|
+
});
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
received = null;
|
|
37
|
+
failure = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("removes the matching entries", async () => {
|
|
41
|
+
let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', api: 'GET /spaces', user: 'u1' });
|
|
42
|
+
assert.deepStrictEqual(outcome, { status: 200, removed: 2 });
|
|
43
|
+
assert.deepStrictEqual(received, { tenant: 't1', service: 'spaces', api: 'GET /spaces', user: 'u1' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("api and user are optional", async () => {
|
|
47
|
+
let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces' });
|
|
48
|
+
assert.strictEqual(outcome.status, 200);
|
|
49
|
+
assert.deepStrictEqual(received, { tenant: 't1', service: 'spaces', api: undefined, user: undefined });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("refuses the memory model", async () => {
|
|
53
|
+
let outcome = await invalidate(registry('memory'), { tenant: 't1', service: 'spaces' });
|
|
54
|
+
assert.strictEqual(outcome.status, 400);
|
|
55
|
+
assert.ok(outcome.error.indexOf('mongo') !== -1);
|
|
56
|
+
assert.strictEqual(received, null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("refuses when no model is set, memory is the default", async () => {
|
|
60
|
+
let outcome = await invalidate(registry(undefined), { tenant: 't1', service: 'spaces' });
|
|
61
|
+
assert.strictEqual(outcome.status, 400);
|
|
62
|
+
assert.strictEqual(received, null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("refuses when caching is not configured", async () => {
|
|
66
|
+
let outcome = await invalidate({ custom: {} }, { tenant: 't1', service: 'spaces' });
|
|
67
|
+
assert.strictEqual(outcome.status, 400);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("requires tenant and service", async () => {
|
|
71
|
+
assert.strictEqual((await invalidate(registry('mongo'), null)).status, 400);
|
|
72
|
+
assert.strictEqual((await invalidate(registry('mongo'), { service: 'spaces' })).status, 400);
|
|
73
|
+
assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1' })).status, 400);
|
|
74
|
+
assert.strictEqual(received, null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("requires the api as written in the configuration", async () => {
|
|
78
|
+
assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', api: '/spaces' })).status, 400);
|
|
79
|
+
assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', api: 'POST /spaces' })).status, 400);
|
|
80
|
+
assert.strictEqual((await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces', user: 42 })).status, 400);
|
|
81
|
+
assert.strictEqual(received, null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("answers 500 when the store fails", async () => {
|
|
85
|
+
failure = new Error("down");
|
|
86
|
+
let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces' });
|
|
87
|
+
assert.strictEqual(outcome.status, 500);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("answers 500 when the store is not ready", async () => {
|
|
91
|
+
model.mongo = null;
|
|
92
|
+
try {
|
|
93
|
+
let outcome = await invalidate(registry('mongo'), { tenant: 't1', service: 'spaces' });
|
|
94
|
+
assert.strictEqual(outcome.status, 500);
|
|
95
|
+
} finally {
|
|
96
|
+
model.mongo = {};
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const helper = require("../../../helper.js");
|
|
4
|
+
const model = helper.requireModule('./mw/cache/model/mongo');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
|
|
7
|
+
describe("Unit test for: mw - cache/model/mongo invalidation", () => {
|
|
8
|
+
|
|
9
|
+
let calls = null;
|
|
10
|
+
let invalidation = null;
|
|
11
|
+
let deleted = 0;
|
|
12
|
+
let originalMongo = null;
|
|
13
|
+
let originalLog = null;
|
|
14
|
+
|
|
15
|
+
before(() => {
|
|
16
|
+
originalMongo = model.mongo;
|
|
17
|
+
originalLog = model.log;
|
|
18
|
+
model.log = {
|
|
19
|
+
debug: () => {},
|
|
20
|
+
info: () => {},
|
|
21
|
+
error: () => {}
|
|
22
|
+
};
|
|
23
|
+
model.mongo = {
|
|
24
|
+
findOne: async (col, condition) => {
|
|
25
|
+
calls.push({ "op": "findOne", col, condition });
|
|
26
|
+
return invalidation;
|
|
27
|
+
},
|
|
28
|
+
updateOne: async (col, condition, s, options) => {
|
|
29
|
+
calls.push({ "op": "updateOne", col, condition, s, options });
|
|
30
|
+
},
|
|
31
|
+
deleteMany: async (col, condition) => {
|
|
32
|
+
calls.push({ "op": "deleteMany", col, condition });
|
|
33
|
+
return { deletedCount: deleted };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
after(() => {
|
|
38
|
+
model.mongo = originalMongo;
|
|
39
|
+
model.log = originalLog;
|
|
40
|
+
});
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
calls = [];
|
|
43
|
+
invalidation = null;
|
|
44
|
+
deleted = 0;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let key = { l1: 'tenant1', l2: 'spaces:GET:/spaces::u:user-1' };
|
|
48
|
+
let response = { statusCode: 200, headers: {}, body: '[]' };
|
|
49
|
+
|
|
50
|
+
it("set stores the fields an invalidation matches on", async () => {
|
|
51
|
+
let stored = await model.set(key, response, 60000, { service: 'spaces', api: 'GET /spaces', user: 'user-1', startedAt: Date.now() });
|
|
52
|
+
assert.strictEqual(stored, true);
|
|
53
|
+
let update = calls.find((c) => c.op === "updateOne");
|
|
54
|
+
assert.strictEqual(update.col, 'cache_store');
|
|
55
|
+
assert.strictEqual(update.s.$set.service, 'spaces');
|
|
56
|
+
assert.strictEqual(update.s.$set.api, 'GET /spaces');
|
|
57
|
+
assert.strictEqual(update.s.$set.user, 'user-1');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("set looks for an invalidation after the request started, for that api or all, that user or all", async () => {
|
|
61
|
+
let startedAt = Date.now();
|
|
62
|
+
await model.set(key, response, 60000, { service: 'spaces', api: 'GET /spaces', user: 'user-1', startedAt: startedAt });
|
|
63
|
+
let lookup = calls.find((c) => c.op === "findOne");
|
|
64
|
+
assert.strictEqual(lookup.col, 'cache_invalidation');
|
|
65
|
+
assert.deepStrictEqual(lookup.condition, {
|
|
66
|
+
l1: 'tenant1',
|
|
67
|
+
service: 'spaces',
|
|
68
|
+
api: { $in: ['GET /spaces', null] },
|
|
69
|
+
user: { $in: ['user-1', null] },
|
|
70
|
+
clearedAt: { $gt: new Date(startedAt) }
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("set does not store a response read before an invalidation", async () => {
|
|
75
|
+
invalidation = { clearedAt: new Date() };
|
|
76
|
+
let stored = await model.set(key, response, 60000, { service: 'spaces', api: 'GET /spaces', user: 'user-1', startedAt: Date.now() - 1000 });
|
|
77
|
+
assert.strictEqual(stored, false);
|
|
78
|
+
assert.strictEqual(calls.filter((c) => c.op === "updateOne").length, 0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("set without meta stores as before", async () => {
|
|
82
|
+
let stored = await model.set(key, response, 60000);
|
|
83
|
+
assert.strictEqual(stored, true);
|
|
84
|
+
assert.strictEqual(calls.filter((c) => c.op === "findOne").length, 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("invalidateMatching records the invalidation before removing", async () => {
|
|
88
|
+
deleted = 3;
|
|
89
|
+
let removed = await model.invalidateMatching({ tenant: 'tenant1', service: 'spaces', api: 'GET /spaces', user: 'user-1' });
|
|
90
|
+
assert.strictEqual(removed, 3);
|
|
91
|
+
assert.deepStrictEqual(calls.map((c) => c.op), ['updateOne', 'deleteMany']);
|
|
92
|
+
assert.strictEqual(calls[0].col, 'cache_invalidation');
|
|
93
|
+
assert.deepStrictEqual(calls[0].condition, { l1: 'tenant1', service: 'spaces', api: 'GET /spaces', user: 'user-1' });
|
|
94
|
+
assert.ok(calls[0].s.$set.clearedAt instanceof Date);
|
|
95
|
+
assert.ok(calls[0].s.$set.expiresAt > calls[0].s.$set.clearedAt);
|
|
96
|
+
assert.deepStrictEqual(calls[1].condition, { l1: 'tenant1', service: 'spaces', api: 'GET /spaces', user: 'user-1' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("invalidateMatching without api and user covers the whole service", async () => {
|
|
100
|
+
await model.invalidateMatching({ tenant: 'tenant1', service: 'spaces' });
|
|
101
|
+
assert.deepStrictEqual(calls[0].condition, { l1: 'tenant1', service: 'spaces', api: null, user: null });
|
|
102
|
+
assert.deepStrictEqual(calls[1].condition, { l1: 'tenant1', service: 'spaces' });
|
|
103
|
+
});
|
|
104
|
+
});
|