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 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
+ }
@@ -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';
@@ -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
+ }
@@ -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
+ }