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
 
@@ -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
- if (!idempotencyKey) {
66
- return next();
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.6",
4
+ "version": "4.3.7",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -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 = {