API Contract Validator
SkillDev toolsValidate API responses against OpenAPI/Swagger specifications, JSON Schema definitions, and consumer-driven contracts to prevent breaking changes
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the API Contract Validator skill
What this skill tells your AI
The instructions your AI receives, as published by pramoddutta/qaskills in seed-skills/api-contract-validator/SKILL.md and read by ahel’s review.
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
// 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;
}
// 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
// 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
// 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
// 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
// src/test/java/contracts/ApiContractTest.java
package contracts;
import io.restassured.RestAssured;
import io.restassured.module.jsv.JsonSchemaValidator;
import io.restassured.response.Response;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
public class ApiContractTest {
@BeforeAll
static void setup() {
RestAssured.baseURI = System.getProperty("api.baseUrl", "http://localhost:3000");
}
@Test
@DisplayName("GET /api/users response matches JSON Schema")
void getUsersResponseMatchesSchema() {
given()
.header("Accept", "application/json")
.when()
.get("/api/users")
.then()
.statusCode(200)
.contentType("application/json")
.body(JsonSchemaValidator.matchesJsonSchemaInClasspath(
"schemas/users-list-response.json"
));
}
@Test
@DisplayName("GET /api/users/:id response matches User schema")
void getUserByIdMatchesSchema() {
given()
.header("Accept", "application/json")
.pathParam("id", 1)
.when()
.get("/api/users/{id}")
.then()
.statusCode(200)
.contentType("application/json")
.body(JsonSchemaValidator.matchesJsonSchemaInClasspath(
"schemas/user.schema.json"
))
.body("id", notNullValue())
.body("email", matchesPattern("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"))
.body("createdAt", matchesPattern(
"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"
));
}
@Test
@DisplayName("Error responses follow standard error contract")
void errorResponseFollowsContract() {
given()
.header("Accept", "application/json")
.when()
.get("/api/users/nonexistent")
.then()
.statusCode(anyOf(is(404), is(400)))
.contentType("application/json")
.body("error", notNullValue())
.body("error.message", not(emptyOrNullString()))
.body("error.code", notNullValue());
}
@Test
@DisplayName("Pagination contract is consistent across endpoints")
void paginationContractConsistency() {
String[] paginatedEndpoints = {
"/api/users",
"/api/documents",
"/api/reports"
};
for (String endpoint : paginatedEndpoints) {
Response response = given()
.queryParam("page", 1)
.queryParam("limit", 10)
.when()
.get(endpoint);
if (response.statusCode() == 200) {
response.then()
.body("data", instanceOf(java.util.List.class))
.body("pagination.page", equalTo(1))
.body("pagination.limit", equalTo(10))
.body("pagination.total", instanceOf(Integer.class))
.body("pagination.totalPages", instanceOf(Integer.class));
}
}
}
@ParameterizedTest
@ValueSource(strings = {"application/json", "application/xml"})
@DisplayName("Content negotiation returns correct content type")
void contentNegotiation(String acceptHeader) {
Response response = given()
.header("Accept", acceptHeader)
.when()
.get("/api/users");
String contentType = response.getContentType();
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 224
- Forks
- 27
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
api-contract-validator- Source
- github.com/pramoddutta/qaskills