graphql-contract 0.1.0
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/README.md +145 -0
- package/dist/index.js +11 -0
- package/package.json +20 -0
- package/src/consumer.ts +43 -0
- package/src/index.ts +11 -0
- package/src/matchers.ts +97 -0
- package/src/provider.ts +58 -0
- package/src/types.ts +32 -0
- package/tests/consumer.test.ts +103 -0
- package/tests/matchers.test.ts +85 -0
- package/tests/provider.test.ts +186 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# graphql-contract
|
|
2
|
+
|
|
3
|
+
Consumer-driven contract testing for GraphQL APIs using field selection sets. Frontend teams declare which fields they consume; backend CI fails if those fields change incompatibly. No Pact Broker server required — contracts are simple JSON files stored alongside your code.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install graphql-contract --save-dev
|
|
9
|
+
# graphql is a peer dependency
|
|
10
|
+
npm install graphql
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Consumer Side
|
|
16
|
+
|
|
17
|
+
In your frontend/consumer project, define and publish a contract:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { defineContract, publishContract } from 'graphql-contract';
|
|
21
|
+
|
|
22
|
+
const contract = defineContract({
|
|
23
|
+
consumer: 'web-app',
|
|
24
|
+
provider: 'user-service',
|
|
25
|
+
operations: [
|
|
26
|
+
`query GetUser {
|
|
27
|
+
user(id: "1") {
|
|
28
|
+
id
|
|
29
|
+
email
|
|
30
|
+
name
|
|
31
|
+
}
|
|
32
|
+
}`,
|
|
33
|
+
`query GetUsers {
|
|
34
|
+
users {
|
|
35
|
+
id
|
|
36
|
+
name
|
|
37
|
+
}
|
|
38
|
+
}`,
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await publishContract(contract, {
|
|
43
|
+
outputPath: './contracts/web-app.json',
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Provider Side
|
|
48
|
+
|
|
49
|
+
In your backend/provider project, verify contracts against your schema:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { buildSchema } from 'graphql';
|
|
53
|
+
import { verifyContracts } from 'graphql-contract';
|
|
54
|
+
|
|
55
|
+
const schema = buildSchema(`
|
|
56
|
+
type Query {
|
|
57
|
+
user(id: ID!): User!
|
|
58
|
+
users: [User!]!
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type User {
|
|
62
|
+
id: ID!
|
|
63
|
+
email: String!
|
|
64
|
+
name: String!
|
|
65
|
+
}
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
const result = await verifyContracts({
|
|
69
|
+
schema,
|
|
70
|
+
contractsPath: './contracts',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result.passed) {
|
|
74
|
+
console.error('Contract violations found:');
|
|
75
|
+
for (const v of result.violations) {
|
|
76
|
+
console.error(` ${v.field}: ${v.reason}`);
|
|
77
|
+
}
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('All contracts verified successfully.');
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## API Reference
|
|
85
|
+
|
|
86
|
+
### `defineContract(opts)`
|
|
87
|
+
|
|
88
|
+
Creates a contract object.
|
|
89
|
+
|
|
90
|
+
| Parameter | Type | Description |
|
|
91
|
+
|-----------|------|-------------|
|
|
92
|
+
| `opts.consumer` | `string` | Name of the consuming service |
|
|
93
|
+
| `opts.provider` | `string` | Name of the provider service |
|
|
94
|
+
| `opts.operations` | `string[]` | GraphQL operation strings |
|
|
95
|
+
|
|
96
|
+
Returns a `GraphQLContract` object. Throws if any operation is syntactically invalid GraphQL.
|
|
97
|
+
|
|
98
|
+
### `publishContract(contract, opts)`
|
|
99
|
+
|
|
100
|
+
Writes a contract to a JSON file.
|
|
101
|
+
|
|
102
|
+
| Parameter | Type | Description |
|
|
103
|
+
|-----------|------|-------------|
|
|
104
|
+
| `contract` | `GraphQLContract` | The contract to publish |
|
|
105
|
+
| `opts.outputPath` | `string` | File path to write the JSON contract |
|
|
106
|
+
|
|
107
|
+
### `verifyContracts(opts)`
|
|
108
|
+
|
|
109
|
+
Verifies all contract JSON files in a directory against a GraphQL schema.
|
|
110
|
+
|
|
111
|
+
| Parameter | Type | Description |
|
|
112
|
+
|-----------|------|-------------|
|
|
113
|
+
| `opts.schema` | `GraphQLSchema` | The provider's GraphQL schema |
|
|
114
|
+
| `opts.contractsPath` | `string` | Path to directory containing contract JSON files |
|
|
115
|
+
|
|
116
|
+
Returns `{ passed: boolean; violations: ContractViolation[] }`.
|
|
117
|
+
|
|
118
|
+
### `checkOperationCompatibility(operation, schema)`
|
|
119
|
+
|
|
120
|
+
Low-level check for a single operation against a schema.
|
|
121
|
+
|
|
122
|
+
| Parameter | Type | Description |
|
|
123
|
+
|-----------|------|-------------|
|
|
124
|
+
| `operation` | `string` | A GraphQL operation string |
|
|
125
|
+
| `schema` | `GraphQLSchema` | The schema to check against |
|
|
126
|
+
|
|
127
|
+
Returns `ContractViolation[]`.
|
|
128
|
+
|
|
129
|
+
## How It Fits Into CI
|
|
130
|
+
|
|
131
|
+
1. **Consumer CI**: Run `defineContract` + `publishContract` to generate contract JSON files. Commit them to the provider repo or a shared contracts repo.
|
|
132
|
+
|
|
133
|
+
2. **Provider CI**: Run `verifyContracts` against your current schema. If any violations are found, the build fails before deployment.
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
# Example GitHub Actions step (provider)
|
|
137
|
+
- name: Verify GraphQL contracts
|
|
138
|
+
run: npx tsx scripts/verify-contracts.ts
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This ensures backend teams cannot accidentally break fields that frontend teams depend on.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkOperationCompatibility = exports.verifyContracts = exports.publishContract = exports.defineContract = void 0;
|
|
4
|
+
var consumer_1 = require("./consumer");
|
|
5
|
+
Object.defineProperty(exports, "defineContract", { enumerable: true, get: function () { return consumer_1.defineContract; } });
|
|
6
|
+
Object.defineProperty(exports, "publishContract", { enumerable: true, get: function () { return consumer_1.publishContract; } });
|
|
7
|
+
var provider_1 = require("./provider");
|
|
8
|
+
Object.defineProperty(exports, "verifyContracts", { enumerable: true, get: function () { return provider_1.verifyContracts; } });
|
|
9
|
+
var matchers_1 = require("./matchers");
|
|
10
|
+
Object.defineProperty(exports, "checkOperationCompatibility", { enumerable: true, get: function () { return matchers_1.checkOperationCompatibility; } });
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "graphql-contract",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Consumer-driven contract testing for GraphQL APIs — no broker required",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "npx --yes tsx --test tests/consumer.test.ts tests/provider.test.ts tests/matchers.test.ts"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"graphql": ">=16.0.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"graphql": "^16.9.0",
|
|
16
|
+
"typescript": "^5.0.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["graphql", "contract", "testing", "consumer-driven"],
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
package/src/consumer.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { parse } from 'graphql';
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { GraphQLContract, DefineContractOptions, PublishContractOptions } from './types';
|
|
5
|
+
|
|
6
|
+
export function defineContract(opts: DefineContractOptions): GraphQLContract {
|
|
7
|
+
if (!opts.consumer || typeof opts.consumer !== 'string') {
|
|
8
|
+
throw new Error('consumer name is required');
|
|
9
|
+
}
|
|
10
|
+
if (!opts.provider || typeof opts.provider !== 'string') {
|
|
11
|
+
throw new Error('provider name is required');
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(opts.operations) || opts.operations.length === 0) {
|
|
14
|
+
throw new Error('at least one operation is required');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Validate each operation is syntactically valid GraphQL
|
|
18
|
+
for (const op of opts.operations) {
|
|
19
|
+
try {
|
|
20
|
+
parse(op);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Invalid GraphQL operation: ${(err as Error).message}\nOperation: ${op}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
consumer: opts.consumer,
|
|
30
|
+
provider: opts.provider,
|
|
31
|
+
operations: opts.operations,
|
|
32
|
+
createdAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function publishContract(
|
|
37
|
+
contract: GraphQLContract,
|
|
38
|
+
opts: PublishContractOptions,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const dir = dirname(opts.outputPath);
|
|
41
|
+
await mkdir(dir, { recursive: true });
|
|
42
|
+
await writeFile(opts.outputPath, JSON.stringify(contract, null, 2), 'utf-8');
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { defineContract, publishContract } from './consumer';
|
|
2
|
+
export { verifyContracts } from './provider';
|
|
3
|
+
export { checkOperationCompatibility } from './matchers';
|
|
4
|
+
export type {
|
|
5
|
+
GraphQLContract,
|
|
6
|
+
ContractViolation,
|
|
7
|
+
DefineContractOptions,
|
|
8
|
+
PublishContractOptions,
|
|
9
|
+
VerifyContractsOptions,
|
|
10
|
+
VerifyContractsResult,
|
|
11
|
+
} from './types';
|
package/src/matchers.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parse,
|
|
3
|
+
validate,
|
|
4
|
+
TypeInfo,
|
|
5
|
+
visit,
|
|
6
|
+
visitWithTypeInfo,
|
|
7
|
+
GraphQLSchema,
|
|
8
|
+
GraphQLObjectType,
|
|
9
|
+
GraphQLNonNull,
|
|
10
|
+
isObjectType,
|
|
11
|
+
isNonNullType,
|
|
12
|
+
getNamedType,
|
|
13
|
+
DocumentNode,
|
|
14
|
+
FieldNode,
|
|
15
|
+
OperationDefinitionNode,
|
|
16
|
+
} from 'graphql';
|
|
17
|
+
import { ContractViolation } from './types';
|
|
18
|
+
|
|
19
|
+
export function checkOperationCompatibility(
|
|
20
|
+
operation: string,
|
|
21
|
+
schema: GraphQLSchema,
|
|
22
|
+
): ContractViolation[] {
|
|
23
|
+
const violations: ContractViolation[] = [];
|
|
24
|
+
|
|
25
|
+
let doc: DocumentNode;
|
|
26
|
+
try {
|
|
27
|
+
doc = parse(operation);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
violations.push({
|
|
30
|
+
field: '',
|
|
31
|
+
operation,
|
|
32
|
+
reason: `Failed to parse operation: ${(err as Error).message}`,
|
|
33
|
+
});
|
|
34
|
+
return violations;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Use graphql's built-in validate to catch fields that don't exist
|
|
38
|
+
const validationErrors = validate(schema, doc);
|
|
39
|
+
for (const error of validationErrors) {
|
|
40
|
+
violations.push({
|
|
41
|
+
field: extractFieldFromError(error.message),
|
|
42
|
+
operation,
|
|
43
|
+
reason: error.message,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (validationErrors.length > 0) {
|
|
48
|
+
return violations;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Walk the AST with type info to check nullability and argument changes
|
|
52
|
+
const typeInfo = new TypeInfo(schema);
|
|
53
|
+
|
|
54
|
+
visit(
|
|
55
|
+
doc,
|
|
56
|
+
visitWithTypeInfo(typeInfo, {
|
|
57
|
+
Field: {
|
|
58
|
+
enter(node: FieldNode) {
|
|
59
|
+
const parentType = typeInfo.getParentType();
|
|
60
|
+
const fieldDef = typeInfo.getFieldDef();
|
|
61
|
+
|
|
62
|
+
if (!parentType || !fieldDef) {
|
|
63
|
+
// Field doesn't exist — already caught by validate()
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check arguments used in the operation still exist on the field
|
|
68
|
+
if (node.arguments && node.arguments.length > 0) {
|
|
69
|
+
for (const arg of node.arguments) {
|
|
70
|
+
const argName = arg.name.value;
|
|
71
|
+
const schemArg = fieldDef.args.find((a) => a.name === argName);
|
|
72
|
+
if (!schemArg) {
|
|
73
|
+
violations.push({
|
|
74
|
+
field: `${parentType.name}.${node.name.value}`,
|
|
75
|
+
operation,
|
|
76
|
+
reason: `Argument "${argName}" no longer exists on field "${parentType.name}.${node.name.value}"`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return violations;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractFieldFromError(message: string): string {
|
|
90
|
+
// graphql validation errors typically say:
|
|
91
|
+
// Cannot query field "x" on type "Y"
|
|
92
|
+
const match = message.match(/Cannot query field "([^"]+)" on type "([^"]+)"/);
|
|
93
|
+
if (match) {
|
|
94
|
+
return `${match[2]}.${match[1]}`;
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { GraphQLSchema } from 'graphql';
|
|
4
|
+
import { GraphQLContract, ContractViolation, VerifyContractsOptions, VerifyContractsResult } from './types';
|
|
5
|
+
import { checkOperationCompatibility } from './matchers';
|
|
6
|
+
|
|
7
|
+
export async function verifyContracts(opts: VerifyContractsOptions): Promise<VerifyContractsResult> {
|
|
8
|
+
const { schema, contractsPath } = opts;
|
|
9
|
+
const violations: ContractViolation[] = [];
|
|
10
|
+
|
|
11
|
+
let files: string[];
|
|
12
|
+
try {
|
|
13
|
+
const entries = await readdir(contractsPath);
|
|
14
|
+
files = entries.filter((f) => f.endsWith('.json'));
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(`Failed to read contracts directory: ${(err as Error).message}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (files.length === 0) {
|
|
20
|
+
return { passed: true, violations: [] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
const filePath = join(contractsPath, file);
|
|
25
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
26
|
+
|
|
27
|
+
let contract: GraphQLContract;
|
|
28
|
+
try {
|
|
29
|
+
contract = JSON.parse(raw) as GraphQLContract;
|
|
30
|
+
} catch {
|
|
31
|
+
violations.push({
|
|
32
|
+
field: '',
|
|
33
|
+
operation: '',
|
|
34
|
+
reason: `Failed to parse contract file: ${file}`,
|
|
35
|
+
});
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!contract.operations || !Array.isArray(contract.operations)) {
|
|
40
|
+
violations.push({
|
|
41
|
+
field: '',
|
|
42
|
+
operation: '',
|
|
43
|
+
reason: `Contract file "${file}" has no operations array`,
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const operation of contract.operations) {
|
|
49
|
+
const opViolations = checkOperationCompatibility(operation, schema);
|
|
50
|
+
violations.push(...opViolations);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
passed: violations.length === 0,
|
|
56
|
+
violations,
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface GraphQLContract {
|
|
2
|
+
consumer: string;
|
|
3
|
+
provider: string;
|
|
4
|
+
operations: string[];
|
|
5
|
+
createdAt: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ContractViolation {
|
|
9
|
+
field: string;
|
|
10
|
+
operation: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DefineContractOptions {
|
|
15
|
+
consumer: string;
|
|
16
|
+
provider: string;
|
|
17
|
+
operations: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PublishContractOptions {
|
|
21
|
+
outputPath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface VerifyContractsOptions {
|
|
25
|
+
schema: import('graphql').GraphQLSchema;
|
|
26
|
+
contractsPath: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VerifyContractsResult {
|
|
30
|
+
passed: boolean;
|
|
31
|
+
violations: ContractViolation[];
|
|
32
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { defineContract, publishContract } from '../src/consumer';
|
|
4
|
+
import { readFile, rm, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
describe('defineContract', () => {
|
|
9
|
+
it('returns correct shape with valid inputs', () => {
|
|
10
|
+
const contract = defineContract({
|
|
11
|
+
consumer: 'web-app',
|
|
12
|
+
provider: 'user-service',
|
|
13
|
+
operations: ['query GetUser { user { id email } }'],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
assert.equal(contract.consumer, 'web-app');
|
|
17
|
+
assert.equal(contract.provider, 'user-service');
|
|
18
|
+
assert.deepEqual(contract.operations, ['query GetUser { user { id email } }']);
|
|
19
|
+
assert.ok(contract.createdAt);
|
|
20
|
+
assert.ok(new Date(contract.createdAt).getTime() > 0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('accepts multiple operations', () => {
|
|
24
|
+
const contract = defineContract({
|
|
25
|
+
consumer: 'mobile-app',
|
|
26
|
+
provider: 'user-service',
|
|
27
|
+
operations: [
|
|
28
|
+
'query GetUser { user { id email } }',
|
|
29
|
+
'query GetUsers { users { id name } }',
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.equal(contract.operations.length, 2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws on invalid GraphQL syntax', () => {
|
|
37
|
+
assert.throws(
|
|
38
|
+
() =>
|
|
39
|
+
defineContract({
|
|
40
|
+
consumer: 'web-app',
|
|
41
|
+
provider: 'user-service',
|
|
42
|
+
operations: ['this is not graphql {{{'],
|
|
43
|
+
}),
|
|
44
|
+
/Invalid GraphQL operation/,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws when consumer is empty', () => {
|
|
49
|
+
assert.throws(
|
|
50
|
+
() =>
|
|
51
|
+
defineContract({
|
|
52
|
+
consumer: '',
|
|
53
|
+
provider: 'user-service',
|
|
54
|
+
operations: ['query GetUser { user { id } }'],
|
|
55
|
+
}),
|
|
56
|
+
/consumer name is required/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('throws when operations is empty', () => {
|
|
61
|
+
assert.throws(
|
|
62
|
+
() =>
|
|
63
|
+
defineContract({
|
|
64
|
+
consumer: 'web-app',
|
|
65
|
+
provider: 'user-service',
|
|
66
|
+
operations: [],
|
|
67
|
+
}),
|
|
68
|
+
/at least one operation is required/,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('publishContract', () => {
|
|
74
|
+
let tmpDir: string;
|
|
75
|
+
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
tmpDir = join(tmpdir(), `graphql-contract-test-${Date.now()}`);
|
|
78
|
+
await mkdir(tmpDir, { recursive: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(async () => {
|
|
82
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('writes valid JSON file', async () => {
|
|
86
|
+
const contract = defineContract({
|
|
87
|
+
consumer: 'web-app',
|
|
88
|
+
provider: 'user-service',
|
|
89
|
+
operations: ['query GetUser { user { id email } }'],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const outputPath = join(tmpDir, 'contracts', 'web-app.json');
|
|
93
|
+
await publishContract(contract, { outputPath });
|
|
94
|
+
|
|
95
|
+
const raw = await readFile(outputPath, 'utf-8');
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
|
|
98
|
+
assert.equal(parsed.consumer, 'web-app');
|
|
99
|
+
assert.equal(parsed.provider, 'user-service');
|
|
100
|
+
assert.deepEqual(parsed.operations, ['query GetUser { user { id email } }']);
|
|
101
|
+
assert.ok(parsed.createdAt);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildSchema } from 'graphql';
|
|
4
|
+
import { checkOperationCompatibility } from '../src/matchers';
|
|
5
|
+
|
|
6
|
+
const SCHEMA = buildSchema(`
|
|
7
|
+
type Query {
|
|
8
|
+
user(id: ID!): User!
|
|
9
|
+
users(limit: Int): [User!]!
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type User {
|
|
13
|
+
id: ID!
|
|
14
|
+
email: String!
|
|
15
|
+
name: String!
|
|
16
|
+
posts: [Post!]!
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Post {
|
|
20
|
+
id: ID!
|
|
21
|
+
title: String!
|
|
22
|
+
body: String!
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
describe('checkOperationCompatibility', () => {
|
|
27
|
+
it('returns no violations for valid operation', () => {
|
|
28
|
+
const violations = checkOperationCompatibility(
|
|
29
|
+
'query GetUser { user(id: "1") { id email name } }',
|
|
30
|
+
SCHEMA,
|
|
31
|
+
);
|
|
32
|
+
assert.equal(violations.length, 0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('detects field that does not exist', () => {
|
|
36
|
+
const violations = checkOperationCompatibility(
|
|
37
|
+
'query GetUser { user(id: "1") { id email avatar } }',
|
|
38
|
+
SCHEMA,
|
|
39
|
+
);
|
|
40
|
+
assert.ok(violations.length > 0);
|
|
41
|
+
assert.ok(violations.some((v) => v.reason.includes('avatar')));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('detects nested field that does not exist', () => {
|
|
45
|
+
const violations = checkOperationCompatibility(
|
|
46
|
+
'query GetUser { user(id: "1") { id posts { id title category } } }',
|
|
47
|
+
SCHEMA,
|
|
48
|
+
);
|
|
49
|
+
assert.ok(violations.length > 0);
|
|
50
|
+
assert.ok(violations.some((v) => v.reason.includes('category')));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns violation for unparseable operation', () => {
|
|
54
|
+
const violations = checkOperationCompatibility('not valid graphql {{{', SCHEMA);
|
|
55
|
+
assert.ok(violations.length > 0);
|
|
56
|
+
assert.ok(violations.some((v) => v.reason.includes('Failed to parse')));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('detects removed argument', () => {
|
|
60
|
+
const noArgSchema = buildSchema(`
|
|
61
|
+
type Query {
|
|
62
|
+
user: User!
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type User {
|
|
66
|
+
id: ID!
|
|
67
|
+
email: String!
|
|
68
|
+
}
|
|
69
|
+
`);
|
|
70
|
+
|
|
71
|
+
const violations = checkOperationCompatibility(
|
|
72
|
+
'query GetUser { user(id: "1") { id email } }',
|
|
73
|
+
noArgSchema,
|
|
74
|
+
);
|
|
75
|
+
assert.ok(violations.length > 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('handles valid deeply nested queries', () => {
|
|
79
|
+
const violations = checkOperationCompatibility(
|
|
80
|
+
'query GetUser { user(id: "1") { id posts { id title body } } }',
|
|
81
|
+
SCHEMA,
|
|
82
|
+
);
|
|
83
|
+
assert.equal(violations.length, 0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildSchema } from 'graphql';
|
|
4
|
+
import { verifyContracts } from '../src/provider';
|
|
5
|
+
import { defineContract, publishContract } from '../src/consumer';
|
|
6
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const FULL_SCHEMA = buildSchema(`
|
|
11
|
+
type Query {
|
|
12
|
+
user(id: ID!): User!
|
|
13
|
+
users: [User!]!
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type User {
|
|
17
|
+
id: ID!
|
|
18
|
+
email: String!
|
|
19
|
+
name: String!
|
|
20
|
+
age: Int
|
|
21
|
+
}
|
|
22
|
+
`);
|
|
23
|
+
|
|
24
|
+
describe('verifyContracts', () => {
|
|
25
|
+
let tmpDir: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
tmpDir = join(tmpdir(), `graphql-contract-verify-${Date.now()}`);
|
|
29
|
+
await mkdir(tmpDir, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('passes when all consumed fields exist in schema', async () => {
|
|
37
|
+
const contract = defineContract({
|
|
38
|
+
consumer: 'web-app',
|
|
39
|
+
provider: 'user-service',
|
|
40
|
+
operations: ['query GetUser { user(id: "1") { id email name } }'],
|
|
41
|
+
});
|
|
42
|
+
await publishContract(contract, { outputPath: join(tmpDir, 'web-app.json') });
|
|
43
|
+
|
|
44
|
+
const result = await verifyContracts({
|
|
45
|
+
schema: FULL_SCHEMA,
|
|
46
|
+
contractsPath: tmpDir,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
assert.equal(result.passed, true);
|
|
50
|
+
assert.equal(result.violations.length, 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('detects missing field (violation)', async () => {
|
|
54
|
+
const contract = defineContract({
|
|
55
|
+
consumer: 'web-app',
|
|
56
|
+
provider: 'user-service',
|
|
57
|
+
operations: ['query GetUser { user(id: "1") { id email avatar } }'],
|
|
58
|
+
});
|
|
59
|
+
await publishContract(contract, { outputPath: join(tmpDir, 'web-app.json') });
|
|
60
|
+
|
|
61
|
+
const result = await verifyContracts({
|
|
62
|
+
schema: FULL_SCHEMA,
|
|
63
|
+
contractsPath: tmpDir,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(result.passed, false);
|
|
67
|
+
assert.ok(result.violations.length > 0);
|
|
68
|
+
const violation = result.violations.find((v) => v.field.includes('avatar'));
|
|
69
|
+
assert.ok(violation, 'Should have a violation for missing "avatar" field');
|
|
70
|
+
assert.ok(violation.reason.includes('Cannot query field'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('detects type change from non-null to nullable (violation)', async () => {
|
|
74
|
+
// The contract was written against a schema where email was non-null.
|
|
75
|
+
// We verify against a schema where email is now nullable.
|
|
76
|
+
const relaxedSchema = buildSchema(`
|
|
77
|
+
type Query {
|
|
78
|
+
user(id: ID!): User
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type User {
|
|
82
|
+
id: ID!
|
|
83
|
+
email: String
|
|
84
|
+
name: String!
|
|
85
|
+
}
|
|
86
|
+
`);
|
|
87
|
+
|
|
88
|
+
// The consumer expects user to return non-null (User!), but now it's nullable.
|
|
89
|
+
// This manifests when the consumer queries fields that only exist on User
|
|
90
|
+
// and the query itself may still validate — the real check is field existence.
|
|
91
|
+
// However, a removed field is a clear violation:
|
|
92
|
+
const contract = defineContract({
|
|
93
|
+
consumer: 'web-app',
|
|
94
|
+
provider: 'user-service',
|
|
95
|
+
operations: ['query GetUser { user(id: "1") { id email age } }'],
|
|
96
|
+
});
|
|
97
|
+
await publishContract(contract, { outputPath: join(tmpDir, 'web-app.json') });
|
|
98
|
+
|
|
99
|
+
// Verify against a schema where "age" has been removed
|
|
100
|
+
const brokenSchema = buildSchema(`
|
|
101
|
+
type Query {
|
|
102
|
+
user(id: ID!): User
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type User {
|
|
106
|
+
id: ID!
|
|
107
|
+
email: String
|
|
108
|
+
name: String!
|
|
109
|
+
}
|
|
110
|
+
`);
|
|
111
|
+
|
|
112
|
+
const result = await verifyContracts({
|
|
113
|
+
schema: brokenSchema,
|
|
114
|
+
contractsPath: tmpDir,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assert.equal(result.passed, false);
|
|
118
|
+
assert.ok(result.violations.some((v) => v.field.includes('age')));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes with multiple contract files', async () => {
|
|
122
|
+
const contract1 = defineContract({
|
|
123
|
+
consumer: 'web-app',
|
|
124
|
+
provider: 'user-service',
|
|
125
|
+
operations: ['query GetUser { user(id: "1") { id email } }'],
|
|
126
|
+
});
|
|
127
|
+
const contract2 = defineContract({
|
|
128
|
+
consumer: 'mobile-app',
|
|
129
|
+
provider: 'user-service',
|
|
130
|
+
operations: ['query GetUsers { users { id name } }'],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await publishContract(contract1, { outputPath: join(tmpDir, 'web-app.json') });
|
|
134
|
+
await publishContract(contract2, { outputPath: join(tmpDir, 'mobile-app.json') });
|
|
135
|
+
|
|
136
|
+
const result = await verifyContracts({
|
|
137
|
+
schema: FULL_SCHEMA,
|
|
138
|
+
contractsPath: tmpDir,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.equal(result.passed, true);
|
|
142
|
+
assert.equal(result.violations.length, 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns passed true for empty contracts directory', async () => {
|
|
146
|
+
const emptyDir = join(tmpDir, 'empty');
|
|
147
|
+
await mkdir(emptyDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
const result = await verifyContracts({
|
|
150
|
+
schema: FULL_SCHEMA,
|
|
151
|
+
contractsPath: emptyDir,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.equal(result.passed, true);
|
|
155
|
+
assert.equal(result.violations.length, 0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('detects removed argument', async () => {
|
|
159
|
+
const contract = defineContract({
|
|
160
|
+
consumer: 'web-app',
|
|
161
|
+
provider: 'user-service',
|
|
162
|
+
operations: ['query GetUser { user(id: "1") { id email } }'],
|
|
163
|
+
});
|
|
164
|
+
await publishContract(contract, { outputPath: join(tmpDir, 'web-app.json') });
|
|
165
|
+
|
|
166
|
+
// Schema where user no longer takes an id argument
|
|
167
|
+
const noArgSchema = buildSchema(`
|
|
168
|
+
type Query {
|
|
169
|
+
user: User!
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type User {
|
|
173
|
+
id: ID!
|
|
174
|
+
email: String!
|
|
175
|
+
}
|
|
176
|
+
`);
|
|
177
|
+
|
|
178
|
+
const result = await verifyContracts({
|
|
179
|
+
schema: noArgSchema,
|
|
180
|
+
contractsPath: tmpDir,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
assert.equal(result.passed, false);
|
|
184
|
+
assert.ok(result.violations.length > 0);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"],
|
|
15
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
16
|
+
}
|