Lesson 23 / 34
Machine-Readable Documentation
Writing the contract as a schema, producing both a validator and human-readable documentation from the same definition, and catching at runtime the case where the server's actual response diverges from the definition.
Contents
Everything built up to this point — error format, field-level validation, versions, breakingness rules, the deprecation window — is part of the contract. Where the contract is written down, however, has remained open. Written in prose, two things cannot be done: it cannot be handed to a machine, and it cannot be compared against reality.
The differ was already working on a schema. This lesson turns that schema into the contract’s single source: a validator and documentation are produced from the same definition, and then the response the server actually produces is tested against that same definition.
Writing the Contract as a Schema
JSON Schema is used for body schemas; type, requiredness, allowed values, and pattern are expressed with this vocabulary. The path/method/response layout wrapping the schemas does the job interface definition formats (OpenAPI) do: it states which method a given path supports and which body comes back for each status code.
// definition.mjs — the loan service's machine-readable definition // Body schemas are written with JSON Schema vocabulary; the wrapper around them // reflects the path/method/response layout of interface definition formats (OpenAPI). const LOAN_REQUEST = { type: "object", required: ["member", "items"], additionalProperties: false, properties: { member: { type: "string", pattern: "^U-\\d{4}$" }, items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false, properties: { isbn: { type: "string", pattern: "^97[89]-\\d{10}$" } } }, }, branch: { type: "string", enum: ["central", "shore", "hill"] }, }, }; const LOAN_RESPONSE = { type: "object", required: ["id", "member", "items", "returnDate", "status"], additionalProperties: false, properties: { id: { type: "string", pattern: "^O-\\d+$" }, member: { type: "string" }, items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false, properties: { isbn: { type: "string" } } } }, returnDate: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, status: { type: "string", enum: ["open", "closed"] }, }, }; const PROBLEM = { type: "object", required: ["type", "title", "status", "detail", "instance"], properties: { type: { type: "string" }, title: { type: "string" }, status: { type: "integer" }, detail: { type: "string" }, instance: { type: "string" }, }, }; export const DEFINITION = { "POST /loans": { request: LOAN_REQUEST, response: { 201: LOAN_RESPONSE, 422: PROBLEM } }, "GET /loans/{id}": { request: null, response: { 200: LOAN_RESPONSE, 404: PROBLEM } }, };
Notice that the definition also covers the error responses. If the contract only defined the success body, the client still would not know what to expect in an error condition; the problem details format built in the first lesson is written here as a schema too, and thereby becomes checkable.
Producing a Validator From the Definition
For a schema to be machine-readable means a validator can be produced from it. The program below interprets the subset of JSON Schema in use and returns a validation function for every schema.
// validator.mjs — produces a validator from a JSON Schema subset // Supported keywords: type, required, properties, items, enum, pattern, additionalProperties const typeOf = (d) => d === null ? "null" : Array.isArray(d) ? "array" : typeof d === "number" ? (Number.isInteger(d) ? "integer" : "number") : typeof d; function check(schema, value, path, errors) { const t = typeOf(value); if (schema.type && t !== schema.type && !(schema.type === "number" && t === "integer")) { errors.push(`${path || "/"}: expected ${schema.type}, got ${t}`); return; } if (schema.enum && !schema.enum.includes(value)) errors.push(`${path}: ${JSON.stringify(value)} is not in the allowed values`); if (schema.pattern && !new RegExp(schema.pattern).test(String(value))) errors.push(`${path}: ${JSON.stringify(value)} does not match the pattern`); if (schema.type === "object") { for (const name of schema.required ?? []) if (!(name in value)) errors.push(`${path}/${name}: required field missing`); if (schema.additionalProperties === false) { for (const name of Object.keys(value)) { if (!(name in (schema.properties ?? {}))) errors.push(`${path}/${name}: field not in definition`); } } for (const [name, sub] of Object.entries(schema.properties ?? {})) { if (name in value) check(sub, value[name], `${path}/${name}`, errors); } } if (schema.type === "array" && schema.items) value.forEach((o, i) => check(schema.items, o, `${path}/${i}`, errors)); } // Produces a validator function from a schema: (value) -> error list export const makeValidator = (schema) => (value) => { const errors = []; check(schema, value, "", errors); return errors; };
Notice that the error messages carry the body path: the field naming convention built in the Validation Errors lesson is preserved here automatically, because the path is produced while the schema is being walked. When the schema is the single source, field names come from a single source too.
Human-readable documentation is also produced from the same definition. Having the documentation and the validator come from the same file makes documentation drift structurally impossible.
// docs.mjs — produces human-readable documentation from the same definition that produces the validator import { DEFINITION } from "./definition.mjs"; const attribute = (s) => [ s.type, s.enum ? `values: ${s.enum.join("|")}` : null, s.pattern ? `pattern: ${s.pattern}` : null, ].filter(Boolean).join(", "); function write(schema, indent = " ") { if (schema.type === "object") { for (const [name, sub] of Object.entries(schema.properties ?? {})) { const required = (schema.required ?? []).includes(name) ? "required" : "optional"; console.log(`${indent}${name.padEnd(12)} ${required.padEnd(12)} ${attribute(sub)}`); if (sub.type === "object" || sub.type === "array") write(sub.type === "array" ? sub.items : sub, indent + " "); } if (schema.additionalProperties === false) console.log(`${indent}(fields not in the definition are not accepted)`); } } const FILTER = process.argv[2]; // optional: only this key is written for (const [key, t] of Object.entries(DEFINITION)) { if (FILTER && key !== FILTER) continue; console.log(`\n## ${key}`); if (t.request) { console.log(" request body:"); write(t.request); } for (const [code, schema] of Object.entries(t.response)) { console.log(` response ${code}:`); write(schema); } }
node docs.mjs "POST /loans"
## POST /loans
request body:
member required string, pattern: ^U-\d{4}$
items required array
isbn required string, pattern: ^97[89]-\d{10}$
(fields not in the definition are not accepted)
branch optional string, values: central|shore|hill
(fields not in the definition are not accepted)
response 201:
id required string, pattern: ^O-\d+$
member required string
items required array
isbn required string
(fields not in the definition are not accepted)
returnDate required string, pattern: ^\d{4}-\d{2}-\d{2}$
status required string, values: open|closed
(fields not in the definition are not accepted)
response 422:
type required string
title required string
status required integer
detail required string
instance required string
Checking the Response Against the Definition
Validating the request is a common practice. The real drift occurs in the response the server produces: a field gets added, a value set widens, a field stops coming back in some conditions. None of these is caught by validating the request.
The server below has a single point that writes the response, and at that point it also
tests the response against the definition. Deviations are recorded into a list and read
from the /check path. A deviation has been planted in it on purpose: for the O-1
record, a field not in the definition and a status value not in the definition are
produced.
// server.mjs — validates the request with the validator produced from the definition, and checks the response against the definition too import { createServer } from "node:http"; import { DEFINITION } from "./definition.mjs"; import { makeValidator } from "./validator.mjs"; // A validator is produced once for each schema in the definition. const REQUEST_VALIDATORS = Object.fromEntries( Object.entries(DEFINITION).filter(([, t]) => t.request).map(([a, t]) => [a, makeValidator(t.request)])); const RESPONSE_VALIDATORS = Object.fromEntries( Object.entries(DEFINITION).flatMap(([a, t]) => Object.entries(t.response).map(([k, s]) => [`${a} ${k}`, makeValidator(s)]))); const deviations = []; // places where the response and the definition diverge const loans = new Map([["O-1", { id: "O-1", member: "U-1001", items: [{ isbn: "978-0262033848" }], returnDate: "2026-03-20", status: "open" }]]); let counter = 1; const readBody = (req) => new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); }); // The single point that writes the response: the check against the definition happens here. const respond = (res, key, code, body, type = "application/json") => { const validate = RESPONSE_VALIDATORS[`${key} ${code}`]; const errors = validate ? validate(body) : ["this response code is not in the definition"]; if (errors.length) deviations.push({ key, code, errors }); res.writeHead(code, { "content-type": `${type}; charset=utf-8` }); res.end(JSON.stringify(body)); }; const problem = (type, title, code, detail) => ({ type: `https://example.library/problems/${type}`, title, status: code, detail, instance: `oc-${counter++}` }); createServer(async (req, res) => { res.sendDate = false; const path = req.url.split("?")[0]; if (path === "/check") { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); return res.end(JSON.stringify(deviations, null, 1)); } if (req.method === "POST" && path === "/loans") { const key = "POST /loans"; const body = JSON.parse((await readBody(req)) || "{}"); const errors = REQUEST_VALIDATORS[key](body); if (errors.length) { const g = problem("validation", "Request body failed validation", 422, errors.join("; ")); return respond(res, key, 422, g, "application/problem+json"); } const record = { id: `O-${loans.size + 1}`, member: body.member, items: body.items, returnDate: "2026-04-15", status: "open" }; loans.set(record.id, record); return respond(res, key, 201, record); } if (req.method === "GET" && path.startsWith("/loans/")) { const key = "GET /loans/{id}"; const record = loans.get(path.slice("/loans/".length)); if (!record) return respond(res, key, 404, problem("resource-not-found", "Resource not found", 404, `${path} does not exist.`), "application/problem+json"); // DEVIATION: overdue records produce a field and a value that are not in the definition. const output = record.id === "O-1" ? { ...record, status: "overdue", overdueDays: 12 } : record; return respond(res, key, 200, output); } res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" }); res.end(JSON.stringify(problem("resource-not-found", "Resource not found", 404, `${path} does not exist.`))); }).listen(8437, "127.0.0.1", () => console.log("server 127.0.0.1:8437"));
#!/usr/bin/env bash # A valid request, an invalid request, and a response that diverges from the definition; then the check dump. node server.mjs & s=$! sleep 0.5 echo "--- valid request ---" curl -sS -w ' [%{http_code}]\n' -X POST -H 'content-type: application/json' \ -d '{"member":"U-1002","items":[{"isbn":"978-0201896831"}],"branch":"shore"}' http://127.0.0.1:8437/loans echo "--- invalid request ---" curl -sS -w ' [%{http_code}]\n' -X POST -H 'content-type: application/json' \ -d '{"member":"1002","items":[{"isbn":"0201896831","quantity":2}],"branch":"sea","note":"urgent"}' http://127.0.0.1:8437/loans echo "--- response that diverges from the definition ---" curl -sS -w ' [%{http_code}]\n' http://127.0.0.1:8437/loans/O-1 echo "--- check ---" curl -sS http://127.0.0.1:8437/check kill "$s"; wait "$s" 2>/dev/null
server 127.0.0.1:8437
--- valid request ---
{"id":"O-2","member":"U-1002","items":[{"isbn":"978-0201896831"}],"returnDate":"2026-04-15","status":"open"} [201]
--- invalid request ---
{"type":"https://example.library/problems/validation","title":"Request body failed validation","status":422,"detail":"/note: field not in definition; /member: \"1002\" does not match the pattern; /items/0/quantity: field not in definition; /items/0/isbn: \"0201896831\" does not match the pattern; /branch: \"sea\" is not in the allowed values","instance":"oc-1"} [422]
--- response that diverges from the definition ---
{"id":"O-1","member":"U-1001","items":[{"isbn":"978-0262033848"}],"returnDate":"2026-03-20","status":"overdue","overdueDays":12} [200]
--- check ---
[
{
"key": "GET /loans/{id}",
"code": 200,
"errors": [
"/overdueDays: field not in definition",
"/status: \"overdue\" is not in the allowed values"
]
}
]
The invalid request was rejected with five separate violations, and no rule was written by hand; all of it came from the definition. What matters most is the third line: the server produced a response containing a field and a status value not in the definition, the client received that response with status code 200 and the full body, but the deviation was recorded.
This is the runtime counterpart of the previous lesson’s breakingness classification.
Adding the overdue value is a widening of the value set in the response direction, and
it is breaking without a tolerance declaration. The differ finds this when two schemas are
compared; the check here finds the case where the code changed without the schema ever
being updated. The two close different leaks: one classifies a deliberate change, the
other makes an unintentional change visible.
The Check Not Being Blocking
When the response departs from the definition, two behaviors can be chosen. If the check is blocking, the server rejects its own response and produces a 500; if it is recording, it sends the response and writes the deviation to the log. The server above does the second.
The choice looks at whom the deviation harms. Adding a field not in the definition is harmless for most clients; blocking that response makes a working function unusable. In development and test environments, by contrast, blocking mode is appropriate: the deviation breaks before it reaches the production environment. The same check is run with two different behaviors depending on the environment.
Recording mode has one condition: the deviation list has to be read. A deviation written to a log nobody looks at is the same thing as a deviation never detected.
Summary
- When the contract is written in prose, it cannot be handed to a machine and cannot be compared against reality; written as a schema, both become possible.
- Body schemas are written with JSON Schema, and the path/method/response layout is written with a wrapper that does the job interface definition formats do; it covers the error responses too.
- When both the validator and human-readable documentation are produced from the same definition, documentation drift becomes structurally impossible; field names come from a single source too.
- Validating only the request leaves a gap: the drift mostly occurs in the response the server produces, and it is only caught if the response is also tested against the definition.
- The response check finds the case where the code changed without the schema ever being updated; the differ, meanwhile, classifies two schemas that were deliberately changed.
- The check runs in recording mode in production, and in blocking mode in development and test; recording mode only works if the deviation list is actually read.
Next Step
The definition now checks what the server actually produces, but leaves one question
unanswered: which fields does the consumer actually use? The definition says every
element of the items array has an isbn; does the shelf terminal read that field, or is
id and status all it needs? Without knowing this, it cannot be predicted which change
will break whom, and every change gets planned for the worst case. The next lesson writes
consumer expectations to a file, runs them against the provider, and shows which
consumer’s test fails when the provider makes a breaking change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.