@onlineapps/service-validator-core 1.0.11 → 1.0.13
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/package.json +1 -1
- package/src/config.js +3 -0
- package/src/defaults.js +3 -0
- package/src/utils/FingerprintUtils.js +40 -4
- package/tests/unit/FingerprintUtils.test.js +80 -0
package/package.json
CHANGED
package/src/config.js
CHANGED
package/src/defaults.js
CHANGED
|
@@ -52,12 +52,20 @@ class FingerprintUtils {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Generate fingerprint for service configuration
|
|
56
|
-
* Used for cache validation - skip re-validation if config unchanged
|
|
55
|
+
* Generate fingerprint for service configuration.
|
|
56
|
+
* Used for cache validation - skip re-validation if config unchanged.
|
|
57
|
+
*
|
|
58
|
+
* The fingerprint input includes `operations` (per the Operations Registry
|
|
59
|
+
* contract) so that changes to `mutates` / `resource_type` / schemas
|
|
60
|
+
* invalidate the cached validation proof as expected.
|
|
61
|
+
*
|
|
62
|
+
* @see api/docs/standards/operations-registry-contract.md §6 Fingerprint
|
|
63
|
+
* @see api/docs/standards/OPERATIONS.md
|
|
57
64
|
*
|
|
58
65
|
* @param {Object} config - Service configuration
|
|
59
66
|
* @param {string} config.version - Service version (REQUIRED)
|
|
60
|
-
* @param {Array}
|
|
67
|
+
* @param {Array} config.endpoints - API endpoints (REQUIRED, may be empty)
|
|
68
|
+
* @param {Object} config.operations - Operations map from operations.json (REQUIRED, may be empty)
|
|
61
69
|
* @param {Object} config.metadata - Service metadata (REQUIRED)
|
|
62
70
|
* @param {string} config.health - Health check endpoint (REQUIRED)
|
|
63
71
|
* @returns {string} Configuration fingerprint
|
|
@@ -73,6 +81,9 @@ class FingerprintUtils {
|
|
|
73
81
|
if (!Array.isArray(config.endpoints)) {
|
|
74
82
|
throw new Error('config.endpoints must be an array');
|
|
75
83
|
}
|
|
84
|
+
if (typeof config.operations !== 'object' || config.operations === null || Array.isArray(config.operations)) {
|
|
85
|
+
throw new Error('config.operations must be an object (see operations-registry-contract.md §3)');
|
|
86
|
+
}
|
|
76
87
|
if (typeof config.metadata !== 'object' || config.metadata === null) {
|
|
77
88
|
throw new Error('config.metadata must be an object');
|
|
78
89
|
}
|
|
@@ -80,12 +91,17 @@ class FingerprintUtils {
|
|
|
80
91
|
throw new Error('config.health is required');
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
|
|
94
|
+
// Stable, deep-sorted JSON so nested operation schemas actually contribute
|
|
95
|
+
// to the hash. The generic generate() uses JSON.stringify(data, keys) which
|
|
96
|
+
// filters keys recursively and would drop nested operation properties.
|
|
97
|
+
const stable = _stableStringify({
|
|
84
98
|
version: config.version,
|
|
85
99
|
endpoints: config.endpoints,
|
|
100
|
+
operations: config.operations,
|
|
86
101
|
metadata: config.metadata,
|
|
87
102
|
health: config.health
|
|
88
103
|
});
|
|
104
|
+
return crypto.createHash('sha256').update(stable).digest('hex');
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
/**
|
|
@@ -123,4 +139,24 @@ class FingerprintUtils {
|
|
|
123
139
|
}
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Deep stable JSON serializer — sorts keys at every depth so that
|
|
144
|
+
* logically-equal objects always produce the same string (and therefore the
|
|
145
|
+
* same hash), regardless of key insertion order.
|
|
146
|
+
*
|
|
147
|
+
* Private helper: only used by generateConfigFingerprint to avoid the
|
|
148
|
+
* recursive-filter bug in JSON.stringify(data, keysArray).
|
|
149
|
+
*/
|
|
150
|
+
function _stableStringify(value) {
|
|
151
|
+
if (value === null || typeof value !== 'object') {
|
|
152
|
+
return JSON.stringify(value);
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
return '[' + value.map(_stableStringify).join(',') + ']';
|
|
156
|
+
}
|
|
157
|
+
const keys = Object.keys(value).sort();
|
|
158
|
+
const parts = keys.map(k => JSON.stringify(k) + ':' + _stableStringify(value[k]));
|
|
159
|
+
return '{' + parts.join(',') + '}';
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
module.exports = FingerprintUtils;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const FingerprintUtils = require('../../src/utils/FingerprintUtils');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unit tests for FingerprintUtils.generateConfigFingerprint — verifies that
|
|
5
|
+
* `operations` is a required input so changes to mutates / resource_type
|
|
6
|
+
* invalidate the cached validation proof.
|
|
7
|
+
*
|
|
8
|
+
* @see api/docs/standards/operations-registry-contract.md §6 Fingerprint
|
|
9
|
+
*/
|
|
10
|
+
describe('FingerprintUtils.generateConfigFingerprint @unit', () => {
|
|
11
|
+
const baseConfig = () => ({
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
endpoints: [],
|
|
14
|
+
operations: {},
|
|
15
|
+
metadata: {},
|
|
16
|
+
health: '/health'
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns a 64-char SHA256 hex for a valid config', () => {
|
|
20
|
+
const hash = FingerprintUtils.generateConfigFingerprint(baseConfig());
|
|
21
|
+
expect(typeof hash).toBe('string');
|
|
22
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('produces a different fingerprint when operations change', () => {
|
|
26
|
+
const a = FingerprintUtils.generateConfigFingerprint({
|
|
27
|
+
...baseConfig(),
|
|
28
|
+
operations: { 'get-x': { mutates: false, resource_type: null } }
|
|
29
|
+
});
|
|
30
|
+
const b = FingerprintUtils.generateConfigFingerprint({
|
|
31
|
+
...baseConfig(),
|
|
32
|
+
operations: { 'get-x': { mutates: true, resource_type: 'doc' } }
|
|
33
|
+
});
|
|
34
|
+
expect(a).not.toEqual(b);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('throws when operations is missing', () => {
|
|
38
|
+
const cfg = baseConfig();
|
|
39
|
+
delete cfg.operations;
|
|
40
|
+
expect(() => FingerprintUtils.generateConfigFingerprint(cfg))
|
|
41
|
+
.toThrow(/operations must be an object/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws when operations is an array (must be a keyed object)', () => {
|
|
45
|
+
expect(() => FingerprintUtils.generateConfigFingerprint({
|
|
46
|
+
...baseConfig(),
|
|
47
|
+
operations: []
|
|
48
|
+
})).toThrow(/operations must be an object/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('throws when operations is null', () => {
|
|
52
|
+
expect(() => FingerprintUtils.generateConfigFingerprint({
|
|
53
|
+
...baseConfig(),
|
|
54
|
+
operations: null
|
|
55
|
+
})).toThrow(/operations must be an object/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('accepts an empty operations object (service with no operations yet)', () => {
|
|
59
|
+
const hash = FingerprintUtils.generateConfigFingerprint({
|
|
60
|
+
...baseConfig(),
|
|
61
|
+
operations: {}
|
|
62
|
+
});
|
|
63
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('still enforces other required fields', () => {
|
|
67
|
+
expect(() => FingerprintUtils.generateConfigFingerprint({
|
|
68
|
+
endpoints: [], operations: {}, metadata: {}, health: '/h'
|
|
69
|
+
})).toThrow(/version is required/);
|
|
70
|
+
expect(() => FingerprintUtils.generateConfigFingerprint({
|
|
71
|
+
version: '1', operations: {}, metadata: {}, health: '/h'
|
|
72
|
+
})).toThrow(/endpoints must be an array/);
|
|
73
|
+
expect(() => FingerprintUtils.generateConfigFingerprint({
|
|
74
|
+
version: '1', endpoints: [], operations: {}, health: '/h'
|
|
75
|
+
})).toThrow(/metadata must be an object/);
|
|
76
|
+
expect(() => FingerprintUtils.generateConfigFingerprint({
|
|
77
|
+
version: '1', endpoints: [], operations: {}, metadata: {}
|
|
78
|
+
})).toThrow(/health is required/);
|
|
79
|
+
});
|
|
80
|
+
});
|