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.
Files changed (37) hide show
  1. package/.eslintrc.js +29 -0
  2. package/README.md +346 -0
  3. package/examples/express-app/README.md +72 -0
  4. package/examples/express-app/contracts/auth.contract.json +38 -0
  5. package/examples/express-app/contracts/users.contract.json +49 -0
  6. package/examples/express-app/debug.js +13 -0
  7. package/examples/express-app/package-lock.json +913 -0
  8. package/examples/express-app/package.json +21 -0
  9. package/examples/express-app/server.js +116 -0
  10. package/jest.config.js +5 -0
  11. package/package.json +43 -0
  12. package/packages/cli/jest.config.js +7 -0
  13. package/packages/cli/package-lock.json +5041 -0
  14. package/packages/cli/package.json +37 -0
  15. package/packages/cli/src/cli.ts +49 -0
  16. package/packages/cli/src/commands/init.ts +103 -0
  17. package/packages/cli/src/commands/status.ts +75 -0
  18. package/packages/cli/src/commands/test.ts +188 -0
  19. package/packages/cli/src/commands/validate.ts +73 -0
  20. package/packages/cli/src/commands/watch.ts +655 -0
  21. package/packages/cli/src/index.ts +3 -0
  22. package/packages/cli/tsconfig.json +18 -0
  23. package/packages/core/jest.config.js +7 -0
  24. package/packages/core/package-lock.json +4581 -0
  25. package/packages/core/package.json +45 -0
  26. package/packages/core/src/__tests__/contract-loader.test.ts +112 -0
  27. package/packages/core/src/__tests__/validation-engine.test.ts +213 -0
  28. package/packages/core/src/contract-loader.ts +55 -0
  29. package/packages/core/src/engine.ts +95 -0
  30. package/packages/core/src/index.ts +9 -0
  31. package/packages/core/src/middleware.ts +97 -0
  32. package/packages/core/src/types/contract.ts +28 -0
  33. package/packages/core/src/types/options.ts +7 -0
  34. package/packages/core/src/types/violation.ts +19 -0
  35. package/packages/core/src/validation-engine.ts +157 -0
  36. package/packages/core/src/violation-store.ts +46 -0
  37. 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,7 @@
1
+ export interface MiddlewareOptions {
2
+ contractsPath: string;
3
+ mode: 'dev' | 'strict' | 'ci';
4
+ port?: number; // for future GUI
5
+ }
6
+
7
+ export type ValidationMode = 'dev' | 'strict' | 'ci';
@@ -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
+ }