Validating responses against the spec
Finding the operation for a request, choosing the response by status, and checking every field.
Lesson 2 showed where the spec describes a response, and lesson 3 how to check a value against a schema. Put together, they give the core of provider-driven contract testing: send real requests to the API, and check each response against what the spec says that operation returns. This lesson builds that check and runs it across the products, posts and todos endpoints.
Four questions about every response
| Question | Fails when |
|---|---|
| Is the request's operation in the spec? | The client is calling an endpoint the contract doesn't have |
| Is this status code documented for it? | The API answers with a status the contract never mentions |
| Is this content type documented for that status? | The body is in a format the contract doesn't describe |
| Does the body match the schema? | A field is missing, has the wrong type, is null, or is unexpected |
Each question depends on the one before. There's no schema to check against if the status isn't documented, so the check stops at the first question that fails.
The check
// Checking real requests and responses against the operation the spec describes for them.
import { findOperation, resolveRef } from "./openapi.mjs";
import { validate } from "./validate.mjs";
/**
* Does this response keep the contract? Returns a list of problems: a status the spec doesn't mention, a content
* type it doesn't mention, a missing body, or a body that doesn't match the schema. An empty list means it does.
*/
export function checkResponse(spec, method, path, { status, contentType, body }) {
const found = findOperation(spec, method, path);
if (!found) return [`${method} ${path}: no such operation in the spec`];
const where = `${method} ${found.template} ${status}`;
const responses = found.operation.responses ?? {};
const documented = responses[String(status)] ?? responses.default;
if (!documented) return [`${where}: status ${status} isn't documented (documented: ${Object.keys(responses).join(", ")})`];
if (!documented.content) return []; // the spec promises nothing about the body
const type = contentType.split(";")[0].trim().toLowerCase();
const media = documented.content[type];
if (!media) {
return [`${where}: content type ${type || "(none)"} isn't documented (documented: ${Object.keys(documented.content).join(", ")})`];
}
if (body === undefined) return [`${where}: the response has no body, but the spec describes one`];
if (!media.schema) return [];
return validate(media.schema, body, spec).map((problem) => `${where}: ${problem}`);
}
/**
* Does this request keep the contract? Checks path parameters against their declared types and a JSON body against
* the request body schema, so a client can find its own mistakes before blaming the server.
*/
export function checkRequest(spec, method, path, body) {
const found = findOperation(spec, method, path);
if (!found) return [`${method} ${path}: no such operation in the spec`];
const where = `${method} ${found.template} request`;
const problems = [];
for (const parameter of found.operation.parameters ?? []) {
const declared = parameter.$ref ? resolveRef(spec, parameter.$ref) : parameter;
if (declared.in !== "path") continue;
const raw = found.params[declared.name];
const value = declared.schema?.type === "integer" && /^-?\d+$/.test(raw) ? Number(raw) : raw;
problems.push(...validate(declared.schema ?? {}, value, spec, `path parameter ${declared.name}`).map((p) => `${where}: ${p}`));
}
const schema = found.operation.requestBody?.content?.["application/json"]?.schema;
if (body !== undefined && schema) problems.push(...validate(schema, body, spec, "$body").map((p) => `${where}: ${p}`));
return problems;
}Going through checkResponse:
findOperation, from lesson 2, turnsGET /api/Products/1into the operation for/api/Products/{id}.- The response is chosen by status code. OpenAPI also allows a
defaultentry for "any other status", which is used if there is one. - If the documented response has no
content, the spec promises nothing about the body, so there is nothing to check. Like this API's 500 responses. - The content type comes from the response header, without its parameters:
application/json; charset=utf-8becomesapplication/json. - Finally the body goes through
validate, and each problem is prefixed with the operation and status, so a failure says exactly which response broke the contract.
checkRequest, the second function, checks the other direction; lesson 5 uses it. The requests themselves go through a small send helper in src/api.mjs, which returns the status, the content type and the parsed body.
Running it against the API
A short script checks a few endpoints and prints the result:
// Calls a handful of endpoints and checks every response against the spec.
// Run: node demos/check-api.mjs
import { send } from "../src/api.mjs";
import { checkResponse } from "../src/contract.mjs";
import { loadSpec } from "../src/openapi.mjs";
const spec = await loadSpec();
const requests = ["/api/Products/1", "/api/Products", "/api/Products/categories", "/api/Posts/1/comments", "/api/Todos/1", "/api/Products/9999"];
for (const path of requests) {
const response = await send("GET", path);
const problems = checkResponse(spec, "GET", path, response);
console.log(`${problems.length ? "FAIL" : "ok "} GET ${path.padEnd(26)} ${response.status}`);
for (const problem of problems) console.log(` ${problem}`);
}$ node demos/check-api.mjs
ok GET /api/Products/1 200
ok GET /api/Products 200
ok GET /api/Products/categories 200
ok GET /api/Posts/1/comments 200
ok GET /api/Todos/1 200
ok GET /api/Products/9999 404Six responses, all keeping the contract, including the 404, whose body matches the documented Error schema. For the lists, every item was checked: GET /api/Products returned twenty products when this was run, and each one had to match ProductResponse.
As tests
The test file does the same for every documented read on the three resources, plus writes:
// Lesson 4: real responses checked against the spec.
import assert from "node:assert/strict";
import { before, test } from "node:test";
import { send } from "../src/api.mjs";
import { checkResponse } from "../src/contract.mjs";
import { loadSpec } from "../src/openapi.mjs";
let spec;
before(async () => {
spec = await loadSpec();
});
// Sends the request and returns the contract problems with its response.
async function problems(method, path, options) {
const response = await send(method, path, options);
return { response, problems: checkResponse(spec, method, path, response) };
}
for (const path of ["/api/Products/1", "/api/Products", "/api/Products/categories", "/api/Products/category/books",
"/api/Posts/1", "/api/Posts/1/comments", "/api/Posts/user/101", "/api/Todos/1", "/api/Todos"]) {
test(`GET ${path} keeps the contract`, async () => {
const { response, problems: found } = await problems("GET", path);
assert.equal(response.status, 200);
assert.deepEqual(found, []);
});
}
test("a 404 keeps the contract too: the documented Error shape", async () => {
const { response, problems: found } = await problems("GET", "/api/Products/9999");
assert.equal(response.status, 404);
assert.deepEqual(found, []);
});
test("create, update and delete keep the contract", async () => {
const created = await problems("POST", "/api/Todos", { body: { userId: 1, title: "contract test", priority: "low" } });
assert.equal(created.response.status, 201);
assert.deepEqual(created.problems, []);
const id = created.response.body.data.id;
try {
const updated = await problems("PUT", `/api/Todos/${id}`, { body: { userId: 1, title: "contract test, updated", priority: "low" } });
assert.deepEqual(updated.problems, []);
} finally {
const deleted = await problems("DELETE", `/api/Todos/${id}`);
assert.deepEqual(deleted.problems, []);
}
});
test("a broken response is caught: the same check on a doctored body", async () => {
const response = await send("GET", "/api/Products/1");
const doctored = { ...response.body, sale: true };
delete doctored.price;
assert.deepEqual(checkResponse(spec, "GET", "/api/Products/1", { ...response, body: doctored }), [
'GET /api/Products/{id} 200: $: missing required property "price"',
'GET /api/Products/{id} 200: $: unexpected property "sale"',
]);
});- The
forloop creates one test per path, so a failure names the endpoint that broke. - The write test creates a todo, updates it and deletes it, checking each response, and deletes it in a
finallyblock so a failure halfway doesn't leave test data behind. - The last test proves the check can fail. It takes a real response, removes
priceand adds a field, and expects exactly two problems. A contract check that has never been seen to fail might not be checking anything.
What this does and doesn't prove
A passing run proves that these responses, today, match the spec. It doesn't prove the API keeps the contract everywhere. Three gaps to keep in mind:
- Coverage. Only the operations and statuses you exercise are checked. This suite never triggers a 400 on the todos endpoints, so it says nothing about their 400 bodies. Listing which documented responses your tests hit is a useful report.
- Data. A field that is only null for some records passes until a test meets one of those records.
- The spec's own accuracy. A response can match a spec that is wrong, or too loose to catch anything. Lesson 5 is about finding those.
Tools such as Schemathesis attack the first two gaps by generating many requests from the spec automatically, including unusual ones, and checking every response. The idea is the same as here, applied much more widely.
Check your understanding
1.In what order does checkResponse ask its questions, and why does it stop early?Show answer
Operation, status, content type, body. Each needs the one before: there's no schema to check without a documented status and content type.
2.A documented response has a description but no content. What does checkResponse report?Show answer
Nothing. The spec promises nothing about the body, so there's nothing to check.
3.Why does the suite include a test with a deliberately broken body?Show answer
To prove the check can fail. A check that has never failed might not be checking anything.
4.Why do these tests keep passing when other people create products on the shared API?Show answer
They check the shape of each response, not specific values, so new data with the right shape still passes.
Exercise
Extend demos/check-api.mjs so it also records which documented responses it saw, as operationId plus status, and at the end prints the documented responses on /api/Products that were never seen. Then add requests to cover as many of them as you can, such as a PUTto a product that doesn't exist.