---
name: API Contract Validator
slug: api-contract-validator
category: Quality
description: API Contract Validator validates API responses against OpenAPI, JSON Schema, and consumer-driven contracts. Use it to check request and response shapes, headers, status codes, and backward compatibility in tests or CI.
github: "https://github.com/PramodDutta/qaskills/tree/main/seed-skills/api-contract-validator"
language: TypeScript
stars: 216
forks: 23
install: "npx degit https://github.com/PramodDutta/qaskills/tree/main/seed-skills/api-contract-validator ~/.claude/skills/api-contract-validator"
installs_to: ~/.claude/skills/api-contract-validator
source_path: seed-skills/api-contract-validator/SKILL.md
collection_size: 25
category_size: 1557
collection_url: "https://dirskills.com/collections/PramodDutta/qaskills"
added: 2026-09-04T05:25:36.893Z
last_synced: 2026-09-04T05:25:36.893Z
canonical_url: "https://dirskills.com/skills/api-contract-validator"
---

# API Contract Validator

API Contract Validator validates API responses against OpenAPI, JSON Schema, and consumer-driven contracts. Use it to check request and response shapes, headers, status codes, and backward compatibility in tests or CI.

**Install:**

```bash
npx degit https://github.com/PramodDutta/qaskills/tree/main/seed-skills/api-contract-validator ~/.claude/skills/api-contract-validator
```

## README

# API Contract Validator Skill

You are an expert QA engineer specializing in API contract validation. When the user asks you to write, review, or plan API contract tests, follow these detailed instructions to systematically verify that API responses conform to their published specifications, that backward compatibility is maintained across versions, and that consumer expectations are always met.

## Core Principles

1. **Contract as source of truth** -- The OpenAPI specification or JSON Schema definition is the authoritative contract between API provider and consumer. Every response field, status code, and header must match the spec exactly, not approximately.
2. **Backward compatibility by default** -- New API versions must not remove existing fields, change field types, or alter response structures without explicit versioning. Additive changes are safe; subtractive changes break consumers.
3. **Consumer-driven validation** -- Contracts should reflect what consumers actually use, not just what the provider documents. Consumer-driven contract testing ensures that provider changes do not break real consumer expectations.
4. **Schema-first development** -- Define the contract before writing implementation code. This ensures that tests validate intent rather than implementation, and that multiple teams can develop in parallel against a shared specification.
5. **Fail fast on drift** -- Contract validation must run in CI on every commit. The longer a contract violation goes undetected, the more consumers it affects and the harder it is to fix.
6. **Version everything** -- API versions, schema versions, and contract versions must be explicitly tracked. Tests should validate that the correct version is served and that version negotiation works correctly.
7. **Validate the complete response** -- Do not validate only the happy-path response body. Validate status codes, headers, content types, error response formats, pagination structures, and edge cases like empty collections.

## Project Structure

```
tests/
  contracts/
    openapi/
      validate-responses.spec.ts     # Validate responses against OpenAPI spec
      validate-request.spec.ts       # Validate request schemas
      backward-compat.spec.ts        # Backward compatibility checks
    json-schema/
      schema-validation.spec.ts      # JSON Schema validation tests
      schema-evolution.spec.ts       # Schema change detection
    consumer-driven/
      consumer-contracts.spec.ts     # Consumer-driven contract tests
      pact-provider.spec.ts          # Pact provider verification
    graphql/
      schema-validation.spec.ts      # GraphQL schema validation
      breaking-changes.spec.ts       # GraphQL breaking change detection
    fixtures/
      api-client.ts                  # Typed API client helper
      schema-loader.ts              # Load and parse OpenAPI specs
      contract-helpers.ts           # Contract validation utilities
    specs/
      openapi.yaml                  # OpenAPI 3.x specification
      schemas/                      # JSON Schema definitions
        user.schema.json
        document.schema.json
        error.schema.json
  playwright.config.ts
```

## Configuration

```typescript
// tests/contracts/fixtures/schema-loader.ts
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';

export interface OpenAPISpec {
  openapi: string;
  info: { title: string; version: string };
  paths: Record<string, Record<string, PathOperation>>;
  components: { schemas: Record<string, JSONSchema> };
}

export interface PathOperation {
  operationId: string;
  summary?: string;
  parameters?: ParameterObject[];
  requestBody?: RequestBodyObject;
  responses: Record<string, ResponseObject>;
}

export interface JSONSchema {
  type?: string;
  properties?: Record<string, JSONSchema>;
  required?: string[];
  items?: JSONSchema;
  enum?: unknown[];
  format?: string;
  minimum?: number;
  maximum?: number;
  minLength?: number;
  maxLength?: number;
  pattern?: string;
  additionalProperties?: boolean | JSONSchema;
}

interface ParameterObject {
  name: string;
  in: string;
  required?: boolean;
  schema: JSONSchema;
}

interface RequestBodyObject {
  required?: boolean;
  content: Record<string, { schema: JSONSchema }>;
}

interface ResponseObject {
  description: string;
  content?: Record<string, { schema: JSONSchema }>;
  headers?: Record<string, { schema: JSONSchema }>;
}

export function loadOpenAPISpec(specPath: string): OpenAPISpec {
  const content = fs.readFileSync(specPath, 'utf-8');
  if (specPath.endsWith('.yaml') || specPath.endsWith('.yml')) {
    return yaml.load(content) as OpenAPISpec;
  }
  return JSON.parse(content);
}

export function loadJSONSchema(schemaPath: string): JSONSchema {
  const content = fs.readFileSync(schemaPath, 'utf-8');
  return JSON.parse(content);
}

export function getResponseSchema(
  spec: OpenAPISpec,
  path: string,
  method: string,
  statusCode: string
): JSONSchema | null {
  const pathObj = spec.paths[path];
  if (!pathObj) return null;

  const operation = pathObj[method.toLowerCase()];
  if (!operation) return null;

  const response = operation.responses[statusCode] || operation.responses['default'];
  if (!response?.content) return null;

  const jsonContent = response.content['application/json'];
  return jsonContent?.schema || null;
}
```

```typescript
// tests/contracts/fixtures/contract-helpers.ts
import Ajv, { ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
import { JSONSchema } from './schema-loader';

const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);

export interface ValidationResult {
  valid: boolean;
  errors: ErrorObject[] | null;
  summary: string;
}

export function validateAgainstSchema(
  data: unknown,
  schema: JSONSchema
): ValidationResult {
  const validate = ajv.compile(schema);
  const valid = validate(data) as boolean;

  return {
    valid,
    errors: validate.errors || null,
    summary: valid
      ? 'Response matches schema'
      : `Schema violations: ${(validate.errors || [])
          .map((e) => `${e.instancePath} ${e.message}`)
          .join('; ')}`,
  };
}

export function checkBackwardCompatibility(
  oldSchema: JSONSchema,
  newSchema: JSONSchema
): { compatible: boolean; breakingChanges: string[] } {
  const breakingChanges: string[] = [];

  // Check for removed required fields
  const oldRequired = new Set(oldSchema.required || []);
  const newRequired = new Set(newSchema.required || []);
  const oldProperties = oldSchema.properties || {};
  const newProperties = newSchema.properties || {};

  // Removed properties that were in old schema
  for (const prop of Object.keys(oldProperties)) {
    if (!(prop in newProperties)) {
      breakingChanges.push(`Removed property: "${prop}"`);
    }
  }

  // Type changes on existing properties
  for (const [prop, oldPropSchema] of Object.entries(oldProperties)) {
    if (prop in newProperties) {
      const newPropSchema = newProperties[prop];
      if (oldPropSchema.type !== newPropSchema.type) {
        breakingChanges.push(
          `Type changed for "${prop}": ${oldPropSchema.type} -> ${newPropSchema.type}`
        );
      }
    }
  }

  // New required fields (breaking for existing consumers)
  for (const field of newRequired) {
    if (!oldRequired.has(field)) {
      breakingChanges.push(`New required field added: "${field}"`);
    }
  }

  // Enum value removal
  for (const [prop, oldPropSchema] of Object.entries(oldProperties)) {
    if (prop in newProperties && oldPropSchema.enum && newProperties[prop].enum) {
      const removedValues = oldPropSchema.enum.filter(
        (v) => !newProperties[prop].enum!.includes(v)
      );
      if (removedValues.length > 0) {
        breakingChanges.push(
          `Enum values removed from "${prop}": ${removedValues.join(', ')}`
        );
      }
    }
  }

  return {
    compatible: breakingChanges.length === 0,
    breakingChanges,
  };
}
```

## OpenAPI Response Validation

```typescript
// tests/contracts/openapi/validate-responses.spec.ts
import { test, expect } from '@playwright/test';
import { loadOpenAPISpec, getResponseSchema } from '../fixtures/schema-loader';
import { validateAgainstSchema } from '../fixtures/contract-helpers';
import * as path from 'path';

const spec = loadOpenAPISpec(path.resolve(__dirname, '../specs/openapi.yaml'));

test.describe('OpenAPI Response Validation', () => {
  test('GET /api/users returns response matching spec', async ({ request }) => {
    const response = await request.get('/api/users');
    const status = response.status().toString();
    const body = await response.json();

    const schema = getResponseSchema(spec, '/api/users', 'get', status);
    expect(schema, `No schema found for GET /api/users ${status}`).not.toBeNull();

    const result = validateAgainstSchema(body, schema!);
    expect(result.valid, result.summary).toBe(true);
  });

  test('GET /api/users/:id returns response matching spec', async ({ request }) => {
    const response = await request.get('/api/users/1');
    const status = response.status().toString();
    const body = await response.json();

    const schema = getResponseSchema(spec, '/api/users/{id}', 'get', status);
    expect(schema).not.toBeNull();

    const result = validateAgainstSchema(body, schema!);
    expect(result.valid, result.summary).toBe(true);
  });

  test('POST /api/users error response matches error schema', async ({ request }) => {
    // Send invalid data to trigger validation error
    const response = await request.post('/api/users', {
      data: { invalid: 'payload' },
    });
    const status = response.status().toString();
    const body = await response.json();

    const schema = getResponseSchema(spec, '/api/users', 'post', status);
    if (schema) {
      const result = validateAgainstSchema(body, schema);
      expect(result.valid, result.summary).toBe(true);
    }

    // Verify standard error format
    expect(body).toHaveProperty('error');
    expect(typeof body.error).toBe('object');
    if (body.error) {
      expect(body.error).toHaveProperty('message');
      expect(typeof body.error.message).toBe('string');
    }
  });

  test('response content-type matches spec', async ({ request }) => {
    const response = await request.get('/api/users');
    const contentType = response.headers()['content-type'];

    expect(contentType).toContain('application/json');
  });

  test('pagination response structure matches spec', async ({ request }) => {
    const response = await request.get('/api/users?page=1&limit=10');
    const body = await response.json();

    // Standard pagination contract
    expect(body).toHaveProperty('data');
    expect(Array.isArray(body.data)).toBe(true);
    expect(body).toHaveProperty('pagination');
    expect(body.pagination).toHaveProperty('page');
    expect(body.pagination).toHaveProperty('limit');
    expect(body.pagination).toHaveProperty('total');
    expect(body.pagination).toHaveProperty('totalPages');

    expect(typeof body.pagination.page).toBe('number');
    expect(typeof body.pagination.limit).toBe('number');
    expect(typeof body.pagination.total).toBe('number');
    expect(typeof body.pagination.totalPages).toBe('number');
  });

  test('validate all documented endpoints return conforming responses', async ({ request }) => {
    const violations: string[] = [];

    for (const [pathTemplate, pathObj] of Object.entries(spec.paths)) {
      for (const [method, operation] of Object.entries(pathObj)) {
        if (['get'].includes(method)) {
          // Replace path parameters with test values
          const resolvedPath = pathTemplate.replace(/{(\w+)}/g, '1');

          try {
            const response = await request.get(resolvedPath);
            const status = response.status().toString();
            const body = await response.json().catch(() => null);

            if (body) {
              const schema = getResponseSchema(spec, pathTemplate, method, status);
              if (schema) {
                const result = validateAgainstSchema(body, schema);
                if (!result.valid) {
                  violations.push(
                    `${method.toUpperCase()} ${pathTemplate} (${status}): ${result.summary}`
                  );
                }
              }
            }
          } catch (error) {
            // Skip unreachable endpoints
          }
        }
      }
    }

    expect(
      violations,
      `Contract violations found:\n${violations.join('\n')}`
    ).toHaveLength(0);
  });
});
```

## JSON Schema Validation

```typescript
// tests/contracts/json-schema/schema-validation.spec.ts
import { test, expect } from '@playwright/test';
import { loadJSONSchema } from '../fixtures/schema-loader';
import { validateAgainstSchema } from '../fixtures/contract-helpers';
import * as path from 'path';

const userSchema = loadJSONSchema(
  path.resolve(__dirname, '../specs/schemas/user.schema.json')
);

const errorSchema = loadJSONSchema(
  path.resolve(__dirname, '../specs/schemas/error.schema.json')
);

test.describe('JSON Schema Validation', () => {
  test('user object conforms to user schema', async ({ request }) => {
    const response = await request.get('/api/users/1');
    expect(response.status()).toBe(200);

    const user = await response.json();
    const result = validateAgainstSchema(user, userSchema);
    expect(result.valid, result.summary).toBe(true);
  });

  test('user list items all conform to user schema', async ({ request }) => {
    const response = await request.get('/api/users');
    expect(response.status()).toBe(200);

    const body = await response.json();
    const users = body.data || body;

    for (let i = 0; i < users.length; i++) {
      const result = validateAgainstSchema(users[i], userSchema);
      expect(result.valid, `User at index ${i}: ${result.summary}`).toBe(true);
    }
  });

  test('error responses conform to error schema', async ({ request }) => {
    const response = await request.get('/api/users/nonexistent-id');

    if (response.status() >= 400) {
      const error = await response.json();
      const result = validateAgainstSchema(error, errorSchema);
      expect(result.valid, result.summary).toBe(true);
    }
  });

  test('required fields are always present', async ({ request }) => {
    const response = await request.get('/api/users/1');
    const user = await response.json();

    const requiredFields = userSchema.required || [];
    for (const field of requiredFields) {
      expect(
        user,
        `Required field "${field}" is missing from user response`
      ).toHaveProperty(field);
    }
  });

  test('field types match schema definitions', async ({ request }) => {
    const response = await request.get('/api/users/1');
    const user = await response.json();
    const properties = userSchema.properties || {};

    for (const [field, fieldSchema] of Object.entries(properties)) {
      if (user[field] !== undefined && user[field] !== null) {
        switch (fieldSchema.type) {
          case 'string':
            expect(typeof user[field], `${field} should be string`).toBe('string');
            break;
          case 'number':
          case 'integer':
            expect(typeof user[field], `${field} should be number`).toBe('number');
            break;
          case 'boolean':
            expect(typeof user[field], `${field} should be boolean`).toBe('boolean');
            break;
          case 'array':
            expect(Array.isArray(user[field]), `${field} should be array`).toBe(true);
            break;
          case 'object':
            expect(typeof user[field], `${field} should be object`).toBe('object');
            break;
        }
      }
    }
  });

  test('string format constraints are enforced', async ({ request }) => {
    const response = await request.get('/api/users/1');
    const user = await response.json();
    const properties = userSchema.properties || {};

    for (const [field, fieldSchema] of Object.entries(properties)) {
      if (user[field] && fieldSchema.type === 'string') {
        if (fieldSchema.format === 'email') {
          expect(user[field]).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
        }
        if (fieldSchema.format === 'date-time') {
          expect(new Date(user[field]).toISOString()).toBeTruthy();
        }
        if (fieldSchema.format === 'uri') {
          expect(() => new URL(user[field])).not.toThrow();
        }
        if (fieldSchema.minLength) {
          expect(user[field].length).toBeGreaterThanOrEqual(fieldSchema.minLength);
        }
        if (fieldSchema.maxLength) {
          expect(user[field].length).toBeLessThanOrEqual(fieldSchema.maxLength);
        }
      }
    }
  });
});
```

## Backward Compatibility Testing

```typescript
// tests/contracts/openapi/backward-compat.spec.ts
import { test, expect } from '@playwright/test';
import { loadOpenAPISpec } from '../fixtures/schema-loader';
import { checkBackwardCompatibility } from '../fixtures/contract-helpers';
import * as path from 'path';

test.describe('Backward Compatibility', () => {
  test('current schema is backward compatible with previous version', () => {
    const previousSpec = loadOpenAPISpec(
      path.resolve(__dirname, '../specs/openapi-v1.yaml')
    );
    const currentSpec = loadOpenAPISpec(
      path.resolve(__dirname, '../specs/openapi.yaml')
    );

    const schemasToCheck = ['User', 'Document', 'Error'];

    for (const schemaName of schemasToCheck) {
      const oldSchema = previousSpec.components.schemas[schemaName];
      const newSchema = currentSpec.components.schemas[schemaName];

      if (oldSchema && newSchema) {
        const result = checkBackwardCompatibility(oldSchema, newSchema);
        expect(
          result.compatible,
          `Breaking changes in ${schemaName}:\n${result.breakingChanges.join('\n')}`
        ).toBe(true);
      }
    }
  });

  test('API version header is present and correct', async ({ request }) => {
    const response = await request.get('/api/users');
    const apiVersion = response.headers()['api-version'] ||
      response.headers()['x-api-version'];

    expect(apiVersion).toBeDefined();
    expect(apiVersion).toMatch(/^\d+\.\d+\.\d+$/);
  });

  test('deprecated fields still present but marked', async ({ request }) => {
    const response = await request.get('/api/users/1');
    const body = await response.json();

    // If deprecated fields exist, they should still be present for backward compat
    const spec = loadOpenAPISpec(path.resolve(__dirname, '../specs/openapi.yaml'));
    const userSchema = spec.components.schemas['User'];

    if (userSchema?.properties) {
      for (const [field, fieldSchema] of Object.entries(userSchema.properties)) {
        if ((fieldSchema as Record<string, unknown>).deprecated) {
          // Deprecated fields should still be in the response
          expect(
            body,
            `Deprecated field "${field}" removed before deprecation period ended`
          ).toHaveProperty(field);
        }
      }
    }
  });

  test('new required fields are not added without version bump', async ({ request }) => {
    const v1Response = await request.get('/api/v1/users/1');
    const v2Response = await request.get('/api/v2/users/1');

    if (v1Response.status() === 200 && v2Response.status() === 200) {
      const v1Body = await v1Response.json();
      const v2Body = await v2Response.json();

      const v1Fields = new Set(Object.keys(v1Body));
      const v2Fields = new Set(Object.keys(v2Body));

      // All v1 fields must still exist in v2
      for (const field of v1Fields) {
        expect(
          v2Fields.has(field),
          `Field "${field}" from v1 is missing in v2`
        ).toBe(true);
      }
    }
  });
});
```

## Java REST Assured Contract Validation

```java
// src/test/java/contracts/ApiContractTest.java
package contr
