Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close