heicat 0.1.2
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/.eslintrc.js +29 -0
- package/README.md +346 -0
- package/examples/express-app/README.md +72 -0
- package/examples/express-app/contracts/auth.contract.json +38 -0
- package/examples/express-app/contracts/users.contract.json +49 -0
- package/examples/express-app/debug.js +13 -0
- package/examples/express-app/package-lock.json +913 -0
- package/examples/express-app/package.json +21 -0
- package/examples/express-app/server.js +116 -0
- package/jest.config.js +5 -0
- package/package.json +43 -0
- package/packages/cli/jest.config.js +7 -0
- package/packages/cli/package-lock.json +5041 -0
- package/packages/cli/package.json +37 -0
- package/packages/cli/src/cli.ts +49 -0
- package/packages/cli/src/commands/init.ts +103 -0
- package/packages/cli/src/commands/status.ts +75 -0
- package/packages/cli/src/commands/test.ts +188 -0
- package/packages/cli/src/commands/validate.ts +73 -0
- package/packages/cli/src/commands/watch.ts +655 -0
- package/packages/cli/src/index.ts +3 -0
- package/packages/cli/tsconfig.json +18 -0
- package/packages/core/jest.config.js +7 -0
- package/packages/core/package-lock.json +4581 -0
- package/packages/core/package.json +45 -0
- package/packages/core/src/__tests__/contract-loader.test.ts +112 -0
- package/packages/core/src/__tests__/validation-engine.test.ts +213 -0
- package/packages/core/src/contract-loader.ts +55 -0
- package/packages/core/src/engine.ts +95 -0
- package/packages/core/src/index.ts +9 -0
- package/packages/core/src/middleware.ts +97 -0
- package/packages/core/src/types/contract.ts +28 -0
- package/packages/core/src/types/options.ts +7 -0
- package/packages/core/src/types/violation.ts +19 -0
- package/packages/core/src/validation-engine.ts +157 -0
- package/packages/core/src/violation-store.ts +46 -0
- package/packages/core/tsconfig.json +18 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@heicat/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime-enforced API contract validation middleware for Node.js",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"clean": "rm -rf dist",
|
|
10
|
+
"test": "jest",
|
|
11
|
+
"lint": "eslint src/**/*.ts",
|
|
12
|
+
"dev": "tsc --watch"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"api",
|
|
16
|
+
"contract",
|
|
17
|
+
"validation",
|
|
18
|
+
"middleware",
|
|
19
|
+
"express",
|
|
20
|
+
"fastify",
|
|
21
|
+
"nodejs"
|
|
22
|
+
],
|
|
23
|
+
"author": "Backend Contract Studio",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"zod": "^3.22.0",
|
|
27
|
+
"glob": "^10.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/express": "^4.17.0",
|
|
31
|
+
"@types/node": "^20.0.0",
|
|
32
|
+
"typescript": "^5.0.0",
|
|
33
|
+
"jest": "^29.0.0",
|
|
34
|
+
"@types/jest": "^29.0.0",
|
|
35
|
+
"ts-jest": "^29.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"express": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"express": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { loadContracts, findContract } from '../contract-loader';
|
|
2
|
+
import { Contract } from '../types/contract';
|
|
3
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
|
|
6
|
+
describe('Contract Loader', () => {
|
|
7
|
+
const testDir = resolve(__dirname, 'test-contracts');
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Create test directory
|
|
11
|
+
mkdirSync(testDir, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
// Clean up
|
|
16
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('loadContracts', () => {
|
|
20
|
+
it('should load valid contract files', async () => {
|
|
21
|
+
const contract1: Contract = {
|
|
22
|
+
method: 'GET',
|
|
23
|
+
path: '/users',
|
|
24
|
+
response: {
|
|
25
|
+
200: { data: { type: 'array' } }
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const contract2: Contract = {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
path: '/users',
|
|
32
|
+
request: {
|
|
33
|
+
body: { name: { type: 'string' } }
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
writeFileSync(resolve(testDir, 'users.contract.json'), JSON.stringify(contract1));
|
|
38
|
+
writeFileSync(resolve(testDir, 'create-user.contract.json'), JSON.stringify(contract2));
|
|
39
|
+
|
|
40
|
+
const contracts = await loadContracts(testDir);
|
|
41
|
+
|
|
42
|
+
expect(contracts).toHaveLength(2);
|
|
43
|
+
expect(contracts[0].method).toBe('GET');
|
|
44
|
+
expect(contracts[1].method).toBe('POST');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should ignore non-contract files', async () => {
|
|
48
|
+
const contract: Contract = {
|
|
49
|
+
method: 'GET',
|
|
50
|
+
path: '/users'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
writeFileSync(resolve(testDir, 'users.contract.json'), JSON.stringify(contract));
|
|
54
|
+
writeFileSync(resolve(testDir, 'config.json'), JSON.stringify({ key: 'value' }));
|
|
55
|
+
writeFileSync(resolve(testDir, 'readme.txt'), 'readme content');
|
|
56
|
+
|
|
57
|
+
const contracts = await loadContracts(testDir);
|
|
58
|
+
|
|
59
|
+
expect(contracts).toHaveLength(1);
|
|
60
|
+
expect(contracts[0].path).toBe('/users');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should throw error for invalid JSON', async () => {
|
|
64
|
+
writeFileSync(resolve(testDir, 'invalid.contract.json'), '{ invalid json }');
|
|
65
|
+
|
|
66
|
+
await expect(loadContracts(testDir)).rejects.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return empty array for empty directory', async () => {
|
|
70
|
+
const contracts = await loadContracts(testDir);
|
|
71
|
+
|
|
72
|
+
expect(contracts).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('findContract', () => {
|
|
77
|
+
it('should find contract by method and path', () => {
|
|
78
|
+
const contracts: Contract[] = [
|
|
79
|
+
{ method: 'GET', path: '/users' },
|
|
80
|
+
{ method: 'POST', path: '/users' },
|
|
81
|
+
{ method: 'GET', path: '/posts' }
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const contract = findContract(contracts, 'POST', '/users');
|
|
85
|
+
|
|
86
|
+
expect(contract).toBeDefined();
|
|
87
|
+
expect(contract?.method).toBe('POST');
|
|
88
|
+
expect(contract?.path).toBe('/users');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should return undefined for non-existent contract', () => {
|
|
92
|
+
const contracts: Contract[] = [
|
|
93
|
+
{ method: 'GET', path: '/users' }
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const contract = findContract(contracts, 'POST', '/users');
|
|
97
|
+
|
|
98
|
+
expect(contract).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should handle path matching with query parameters', () => {
|
|
102
|
+
const contracts: Contract[] = [
|
|
103
|
+
{ method: 'GET', path: '/users' }
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const contract = findContract(contracts, 'GET', '/users?page=1');
|
|
107
|
+
|
|
108
|
+
expect(contract).toBeDefined();
|
|
109
|
+
expect(contract?.path).toBe('/users');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ValidationEngine } from '../validation-engine';
|
|
2
|
+
import { Contract } from '../types/contract';
|
|
3
|
+
|
|
4
|
+
describe('ValidationEngine', () => {
|
|
5
|
+
let engine: ValidationEngine;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
engine = new ValidationEngine();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('validateRequest', () => {
|
|
12
|
+
it('should validate valid request body', () => {
|
|
13
|
+
const contract: Contract = {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
path: '/users',
|
|
16
|
+
request: {
|
|
17
|
+
body: {
|
|
18
|
+
email: { type: 'string', required: true },
|
|
19
|
+
password: { type: 'string', minLength: 8, required: true }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const req = {
|
|
25
|
+
body: {
|
|
26
|
+
email: 'test@example.com',
|
|
27
|
+
password: 'password123'
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = engine.validateRequest(contract, req);
|
|
32
|
+
|
|
33
|
+
expect(result.isValid).toBe(true);
|
|
34
|
+
expect(result.violations).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should reject request with missing required field', () => {
|
|
38
|
+
const contract: Contract = {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/users',
|
|
41
|
+
request: {
|
|
42
|
+
body: {
|
|
43
|
+
email: { type: 'string', required: true },
|
|
44
|
+
password: { type: 'string', required: true }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const req = {
|
|
50
|
+
body: {
|
|
51
|
+
password: 'password123'
|
|
52
|
+
// missing email
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const result = engine.validateRequest(contract, req);
|
|
57
|
+
|
|
58
|
+
expect(result.isValid).toBe(false);
|
|
59
|
+
expect(result.violations).toHaveLength(1);
|
|
60
|
+
expect(result.violations[0].severity).toBe('error');
|
|
61
|
+
expect(result.violations[0].message).toContain('email');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should reject request with password too short', () => {
|
|
65
|
+
const contract: Contract = {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
path: '/users',
|
|
68
|
+
request: {
|
|
69
|
+
body: {
|
|
70
|
+
email: { type: 'string', required: true },
|
|
71
|
+
password: { type: 'string', minLength: 8, required: true }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const req = {
|
|
77
|
+
body: {
|
|
78
|
+
email: 'test@example.com',
|
|
79
|
+
password: 'short'
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = engine.validateRequest(contract, req);
|
|
84
|
+
|
|
85
|
+
expect(result.isValid).toBe(false);
|
|
86
|
+
expect(result.violations).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should validate without request schema', () => {
|
|
90
|
+
const contract: Contract = {
|
|
91
|
+
method: 'GET',
|
|
92
|
+
path: '/users'
|
|
93
|
+
// no request schema
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const req = { body: {} };
|
|
97
|
+
|
|
98
|
+
const result = engine.validateRequest(contract, req);
|
|
99
|
+
|
|
100
|
+
expect(result.isValid).toBe(true);
|
|
101
|
+
expect(result.violations).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('validateResponse', () => {
|
|
106
|
+
it('should validate valid response', () => {
|
|
107
|
+
const contract: Contract = {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
path: '/users',
|
|
110
|
+
response: {
|
|
111
|
+
201: {
|
|
112
|
+
id: { type: 'string' },
|
|
113
|
+
email: { type: 'string' }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const response = {
|
|
119
|
+
id: '123',
|
|
120
|
+
email: 'test@example.com'
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const result = engine.validateResponse(contract, 201, response);
|
|
124
|
+
|
|
125
|
+
expect(result.isValid).toBe(true);
|
|
126
|
+
expect(result.violations).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should reject response with missing field', () => {
|
|
130
|
+
const contract: Contract = {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
path: '/users',
|
|
133
|
+
response: {
|
|
134
|
+
201: {
|
|
135
|
+
id: { type: 'string' },
|
|
136
|
+
email: { type: 'string' }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const response = {
|
|
142
|
+
id: '123'
|
|
143
|
+
// missing email
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const result = engine.validateResponse(contract, 201, response);
|
|
147
|
+
|
|
148
|
+
expect(result.isValid).toBe(false);
|
|
149
|
+
expect(result.violations).toHaveLength(1);
|
|
150
|
+
expect(result.violations[0].severity).toBe('warning');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should validate without response schema', () => {
|
|
154
|
+
const contract: Contract = {
|
|
155
|
+
method: 'GET',
|
|
156
|
+
path: '/users'
|
|
157
|
+
// no response schema
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const response = { data: [] };
|
|
161
|
+
|
|
162
|
+
const result = engine.validateResponse(contract, 200, response);
|
|
163
|
+
|
|
164
|
+
expect(result.isValid).toBe(true);
|
|
165
|
+
expect(result.violations).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('validateError', () => {
|
|
170
|
+
it('should validate valid error response', () => {
|
|
171
|
+
const contract: Contract = {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
path: '/users',
|
|
174
|
+
errors: {
|
|
175
|
+
400: {
|
|
176
|
+
message: { type: 'string' }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const error = {
|
|
182
|
+
message: 'Invalid input'
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const result = engine.validateError(contract, 400, error);
|
|
186
|
+
|
|
187
|
+
expect(result.isValid).toBe(true);
|
|
188
|
+
expect(result.violations).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should reject error with wrong schema', () => {
|
|
192
|
+
const contract: Contract = {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
path: '/users',
|
|
195
|
+
errors: {
|
|
196
|
+
400: {
|
|
197
|
+
message: { type: 'string' }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const error = {
|
|
203
|
+
error: 'Invalid input'
|
|
204
|
+
// should be 'message', not 'error'
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const result = engine.validateError(contract, 400, error);
|
|
208
|
+
|
|
209
|
+
expect(result.isValid).toBe(false);
|
|
210
|
+
expect(result.violations).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
3
|
+
import { resolve, extname, join } from 'path';
|
|
4
|
+
import { Contract } from './types/contract';
|
|
5
|
+
|
|
6
|
+
export async function loadContracts(contractsPath: string): Promise<Contract[]> {
|
|
7
|
+
// Use manual directory traversal (more reliable than glob)
|
|
8
|
+
return loadContractsManually(contractsPath);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function loadContractsManually(contractsPath: string): Contract[] {
|
|
12
|
+
const contracts: Contract[] = [];
|
|
13
|
+
const fullPath = resolve(contractsPath);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const items = readdirSync(fullPath);
|
|
17
|
+
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
const itemPath = join(fullPath, item);
|
|
20
|
+
const stat = statSync(itemPath);
|
|
21
|
+
|
|
22
|
+
if (stat.isFile() && item.endsWith('.contract.json')) {
|
|
23
|
+
try {
|
|
24
|
+
const content = readFileSync(itemPath, 'utf-8');
|
|
25
|
+
const contract = JSON.parse(content) as Contract;
|
|
26
|
+
|
|
27
|
+
// Basic validation
|
|
28
|
+
if (!contract.method || !contract.path) {
|
|
29
|
+
console.warn(`Invalid contract in ${itemPath}: missing method or path`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
contracts.push(contract);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn(`Failed to load contract from ${itemPath}:`, error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.warn(`Failed to read contracts directory ${fullPath}:`, error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return contracts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findContract(
|
|
47
|
+
contracts: Contract[],
|
|
48
|
+
method: string,
|
|
49
|
+
path: string
|
|
50
|
+
): Contract | undefined {
|
|
51
|
+
return contracts.find(contract =>
|
|
52
|
+
contract.method.toUpperCase() === method.toUpperCase() &&
|
|
53
|
+
contract.path === path
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Contract } from './types/contract';
|
|
2
|
+
import { Violation } from './types/violation';
|
|
3
|
+
import { ValidationEngine } from './validation-engine';
|
|
4
|
+
import { ViolationStore } from './violation-store';
|
|
5
|
+
import { loadContracts, findContract } from './contract-loader';
|
|
6
|
+
import { ValidationMode } from './types/options';
|
|
7
|
+
|
|
8
|
+
export class ContractEngine {
|
|
9
|
+
private contracts: Contract[] = [];
|
|
10
|
+
private validationEngine: ValidationEngine;
|
|
11
|
+
private violationStore: ViolationStore;
|
|
12
|
+
private mode: ValidationMode;
|
|
13
|
+
|
|
14
|
+
constructor(mode: ValidationMode) {
|
|
15
|
+
this.validationEngine = new ValidationEngine();
|
|
16
|
+
this.violationStore = new ViolationStore();
|
|
17
|
+
this.mode = mode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async loadContracts(contractsPath: string): Promise<void> {
|
|
21
|
+
this.contracts = await loadContracts(contractsPath);
|
|
22
|
+
console.log(`Loaded ${this.contracts.length} contracts`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
validateRequest(method: string, path: string, req: any): Violation[] {
|
|
26
|
+
const contract = findContract(this.contracts, method, path);
|
|
27
|
+
if (!contract) {
|
|
28
|
+
return []; // No contract means no validation
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = this.validationEngine.validateRequest(contract, req);
|
|
32
|
+
|
|
33
|
+
if (!result.isValid) {
|
|
34
|
+
result.violations.forEach(violation => {
|
|
35
|
+
this.violationStore.add(violation);
|
|
36
|
+
this.logViolation(violation);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result.violations;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
validateResponse(method: string, path: string, statusCode: number, body: any): Violation[] {
|
|
44
|
+
const contract = findContract(this.contracts, method, path);
|
|
45
|
+
if (!contract) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = this.validationEngine.validateResponse(contract, statusCode, body);
|
|
50
|
+
|
|
51
|
+
if (!result.isValid) {
|
|
52
|
+
result.violations.forEach(violation => {
|
|
53
|
+
this.violationStore.add(violation);
|
|
54
|
+
this.logViolation(violation);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result.violations;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
validateError(method: string, path: string, statusCode: number, body: any): Violation[] {
|
|
62
|
+
const contract = findContract(this.contracts, method, path);
|
|
63
|
+
if (!contract) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = this.validationEngine.validateError(contract, statusCode, body);
|
|
68
|
+
|
|
69
|
+
if (!result.isValid) {
|
|
70
|
+
result.violations.forEach(violation => {
|
|
71
|
+
this.violationStore.add(violation);
|
|
72
|
+
this.logViolation(violation);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result.violations;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private logViolation(violation: Violation): void {
|
|
80
|
+
const prefix = violation.severity === 'error' ? '❌' : '⚠️';
|
|
81
|
+
console.log(`${prefix} ${violation.endpoint.method} ${violation.endpoint.path}: ${violation.message}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getViolations() {
|
|
85
|
+
return this.violationStore.getAll();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getStats() {
|
|
89
|
+
return this.violationStore.getStats();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
clearViolations(): void {
|
|
93
|
+
this.violationStore.clear();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { contractMiddleware } from './middleware';
|
|
2
|
+
export { ContractEngine } from './engine';
|
|
3
|
+
export { ViolationStore } from './violation-store';
|
|
4
|
+
export { loadContracts } from './contract-loader';
|
|
5
|
+
|
|
6
|
+
// Types
|
|
7
|
+
export type { Contract } from './types/contract';
|
|
8
|
+
export type { Violation } from './types/violation';
|
|
9
|
+
export type { MiddlewareOptions } from './types/options';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { MiddlewareOptions } from './types/options';
|
|
3
|
+
import { ContractEngine } from './engine';
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
namespace Express {
|
|
7
|
+
interface Request {
|
|
8
|
+
_contractEngine?: ContractEngine;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Shared engine instance across all requests
|
|
14
|
+
let sharedEngine: ContractEngine | null = null;
|
|
15
|
+
|
|
16
|
+
export function contractMiddleware(options: MiddlewareOptions) {
|
|
17
|
+
// Create shared engine instance only once
|
|
18
|
+
if (!sharedEngine) {
|
|
19
|
+
sharedEngine = new ContractEngine(options.mode);
|
|
20
|
+
|
|
21
|
+
// Load contracts asynchronously
|
|
22
|
+
sharedEngine.loadContracts(options.contractsPath).catch(error => {
|
|
23
|
+
console.error('Failed to load contracts:', error);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return async (req: Request, res: Response, next: NextFunction) => {
|
|
28
|
+
req._contractEngine = sharedEngine!;
|
|
29
|
+
|
|
30
|
+
// Validate request
|
|
31
|
+
const requestViolations = sharedEngine!.validateRequest(req.method, req.path, req);
|
|
32
|
+
if (requestViolations.some((v: any) => v.severity === 'error')) {
|
|
33
|
+
// Block invalid requests in all modes
|
|
34
|
+
const violation = requestViolations.find((v: any) => v.severity === 'error')!;
|
|
35
|
+
return res.status(400).json({
|
|
36
|
+
error: 'Request validation failed',
|
|
37
|
+
message: violation.message,
|
|
38
|
+
contract: violation.contractPath
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Store original send method
|
|
43
|
+
const originalSend = res.send;
|
|
44
|
+
const originalJson = res.json;
|
|
45
|
+
const originalStatus = res.status;
|
|
46
|
+
|
|
47
|
+
let responseSent = false;
|
|
48
|
+
let responseStatus = 200;
|
|
49
|
+
let responseBody: any = null;
|
|
50
|
+
|
|
51
|
+
// Override status to capture status code
|
|
52
|
+
res.status = function(code: number) {
|
|
53
|
+
responseStatus = code;
|
|
54
|
+
return originalStatus.call(this, code);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Override json to capture response body
|
|
58
|
+
res.json = function(body: any) {
|
|
59
|
+
if (!responseSent) {
|
|
60
|
+
responseBody = body;
|
|
61
|
+
responseSent = true;
|
|
62
|
+
|
|
63
|
+
// Validate response
|
|
64
|
+
sharedEngine!.validateResponse(req.method, req.path, responseStatus, body);
|
|
65
|
+
}
|
|
66
|
+
return originalJson.call(this, body);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Override send to capture response body
|
|
70
|
+
res.send = function(body: any) {
|
|
71
|
+
if (!responseSent) {
|
|
72
|
+
responseBody = body;
|
|
73
|
+
responseSent = true;
|
|
74
|
+
|
|
75
|
+
// Try to parse JSON, otherwise treat as raw response
|
|
76
|
+
let parsedBody = body;
|
|
77
|
+
if (typeof body === 'string') {
|
|
78
|
+
try {
|
|
79
|
+
parsedBody = JSON.parse(body);
|
|
80
|
+
} catch {
|
|
81
|
+
// Keep as string
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validate response
|
|
86
|
+
sharedEngine!.validateResponse(req.method, req.path, responseStatus, parsedBody);
|
|
87
|
+
}
|
|
88
|
+
return originalSend.call(this, body);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
next();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getContractEngine(req: Request): ContractEngine | undefined {
|
|
96
|
+
return req._contractEngine;
|
|
97
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface Contract {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
auth?: string;
|
|
5
|
+
request?: {
|
|
6
|
+
body?: Record<string, any>;
|
|
7
|
+
query?: Record<string, any>;
|
|
8
|
+
params?: Record<string, any>;
|
|
9
|
+
headers?: Record<string, any>;
|
|
10
|
+
};
|
|
11
|
+
response?: Record<string, any>;
|
|
12
|
+
errors?: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RequestContract {
|
|
16
|
+
body?: Record<string, any>;
|
|
17
|
+
query?: Record<string, any>;
|
|
18
|
+
params?: Record<string, any>;
|
|
19
|
+
headers?: Record<string, any>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ResponseContract {
|
|
23
|
+
[statusCode: string]: Record<string, any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ErrorContract {
|
|
27
|
+
[statusCode: string]: Record<string, any>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Violation {
|
|
2
|
+
id: string;
|
|
3
|
+
timestamp: Date;
|
|
4
|
+
type: 'request' | 'response' | 'error';
|
|
5
|
+
contractPath: string;
|
|
6
|
+
endpoint: {
|
|
7
|
+
method: string;
|
|
8
|
+
path: string;
|
|
9
|
+
};
|
|
10
|
+
expected: any;
|
|
11
|
+
actual: any;
|
|
12
|
+
message: string;
|
|
13
|
+
severity: 'error' | 'warning';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ValidationResult {
|
|
17
|
+
isValid: boolean;
|
|
18
|
+
violations: Violation[];
|
|
19
|
+
}
|