---
title: 'Machine-Readable Documentation'
source: 'https://academia.sh/en/courses/api-design/machine-readable-documentation'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:27+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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); }
}
```

```bash
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.

```js
// 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"));
```

```bash
#!/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.
