soajs.controller 4.3.6 → 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.
|
@@ -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/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]');
|
package/package.json
CHANGED
|
@@ -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 = {
|