Skip to content
Fondazione ITS INCOM

Examples · Web Development

An OpenAPI description of your API

Qwen3-Coder reads a small Node.js API and writes its OpenAPI 3.1 description as JSON. Your code checks that it parses, that every route of the code is in it and nothing more, and that the path parameters match. Without a model, the same check tells you when the code and the description drift apart.

Advanced · 45 min

The idea

An OpenAPI description is a JSON (or YAML) file that lists the routes of an HTTP API: paths, methods, parameters, request bodies, responses. Swagger UI turns it into documentation you can click; other tools generate clients and tests from it. Writing it by hand is long and boring, and it gets old the day someone adds a route.

A model reads your code and writes a first version in seconds. But a description that is almost right is dangerous: a client generated from it calls a route that does not exist. So the routes are not trusted, they are compared with the code:

  1. the program imports the real route table from your API;
  2. the model writes openapi.json;
  3. the code checks that it parses, that the same routes are in both, and that the path parameters have the same names and are declared.

What you need

A key and the openai package, as in Your first API call in JavaScript. No framework: the API uses node:http.

The API

The school lends Arduino kits, Raspberry Pis and laptops. Save this as api.mjs:

import { createServer } from "node:http";
import { fileURLToPath } from "node:url";

const devices = [
  { id: 1, name: "Arduino Uno kit", room: "Lab IoT", available: true },
  { id: 2, name: "Raspberry Pi 5", room: "Lab IoT", available: false },
  { id: 3, name: "Laptop 14 inch", room: "Library", available: true },
];
const loans = [{ id: 1, deviceId: 2, studentId: "s1042", until: "2026-10-15" }];
let nextLoanId = 2;

function send(res, status, body) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(body));
}

async function readJson(req) {
  let text = "";
  for await (const chunk of req) text += chunk;
  try { return JSON.parse(text || "{}"); } catch { return {}; }
}

export const routes = [
  ["GET", "/health", (req, res) => send(res, 200, { ok: true })],
  ["GET", "/devices", (req, res, params, query) => {
    send(res, 200, query.get("available") === "true" ? devices.filter((d) => d.available) : devices);
  }],
  ["GET", "/devices/:id", (req, res, { id }) => {
    const device = devices.find((d) => d.id === Number(id));
    device ? send(res, 200, device) : send(res, 404, { error: "no such device" });
  }],
  ["POST", "/devices/:id/loans", async (req, res, { id }) => {
    const device = devices.find((d) => d.id === Number(id));
    if (!device) return send(res, 404, { error: "no such device" });
    if (!device.available) return send(res, 409, { error: "already on loan" });
    const { studentId, until } = await readJson(req);
    if (!studentId || !/^\d{4}-\d{2}-\d{2}$/.test(until ?? "")) {
      return send(res, 400, { error: "studentId and until (YYYY-MM-DD) are required" });
    }
    const loan = { id: nextLoanId++, deviceId: device.id, studentId, until };
    loans.push(loan);
    device.available = false;
    send(res, 201, loan);
  }],
  ["GET", "/students/:studentId/loans", (req, res, { studentId }) => {
    send(res, 200, loans.filter((l) => l.studentId === studentId));
  }],
  ["DELETE", "/loans/:loanId", (req, res, { loanId }) => {
    const i = loans.findIndex((l) => l.id === Number(loanId));
    if (i < 0) return send(res, 404, { error: "no such loan" });
    devices.find((d) => d.id === loans[i].deviceId).available = true;
    loans.splice(i, 1);
    res.writeHead(204).end();
  }],
];

const server = createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");
  for (const [method, pattern, handler] of routes) {
    const names = [];
    const regex = new RegExp("^" + pattern.replace(/:(\w+)/g, (_, name) => (names.push(name), "([^/]+)")) + "$");
    const found = url.pathname.match(regex);
    if (req.method === method && found) {
      const params = Object.fromEntries(names.map((name, i) => [name, decodeURIComponent(found[i + 1])]));
      return handler(req, res, params, url.searchParams);
    }
  }
  send(res, 404, { error: "not found" });
});

// Start the server only with `node api.mjs`: check.mjs imports the routes without starting it.
if (process.argv[1] === fileURLToPath(import.meta.url)) server.listen(3000, () => console.log("http://localhost:3000"));

Try it: node api.mjs, then in another terminal curl "localhost:3000/devices?available=true".

The routes are a table, routes, and the file exports it. That table is the truth the check compares with: with Express you would collect the same list from your app.get(…) and app.post(…) calls.

The program

Save it as check.mjs, next to api.mjs:

import { readFileSync, writeFileSync } from "node:fs";
import OpenAI from "openai";
import { routes } from "./api.mjs";

// With a model name, the model writes openapi.json first. Without, the program checks the openapi.json you have.
const model = process.argv[2];
if (model) {
  const client = new OpenAI({ baseURL: "https://api.ai.itsincom.org/v1", apiKey: process.env.API_KEY });
  const answer = await client.chat.completions.create({
    model,
    temperature: 0,
    messages: [
      { role: "system", content: "You write OpenAPI 3.1 descriptions of HTTP APIs. Answer with the JSON document only." },
      { role: "user", content: "Describe every route of this Node.js API: path and query parameters, request bodies, " +
                               "responses with status codes and JSON schemas.\n\n" + readFileSync("api.mjs", "utf8") },
    ],
    response_format: { type: "json_object" },
  });
  if (answer.choices[0].finish_reason !== "stop") throw new Error("The answer was cut off: nothing to check.");
  // 1. It must parse, and be OpenAPI 3.1: otherwise the old openapi.json stays as it is.
  const draft = JSON.parse(answer.choices[0].message.content.trim());
  if (!String(draft.openapi).startsWith("3.1") || typeof draft.paths !== "object") {
    throw new Error(`Not an OpenAPI 3.1 document (openapi: ${draft.openapi})`);
  }
  writeFileSync("openapi.json", JSON.stringify(draft, null, 2));
}
const spec = JSON.parse(readFileSync("openapi.json", "utf8"));

// 2. The same routes in the code and in the spec. "/devices/:id" in the code is "/devices/{id}" in OpenAPI;
//    the shape "/devices/{}" matches a route even when the parameter has another name.
const METHODS = ["get", "put", "post", "delete", "patch", "head", "options", "trace"];
const shape = (path) => path.replace(/\{[^}]*\}/g, "{}");
const namesIn = (path) => [...path.matchAll(/\{([^}]*)\}/g)].map((m) => m[1]);
// A parameter can be written once in "components" and used with "$ref": follow it.
const resolve = (p) => (p.$ref ? p.$ref.split("/").slice(1).reduce((o, key) => o?.[key], spec) ?? {} : p);

const inCode = new Map(routes.map(([method, path]) => {
  const openApiPath = path.replace(/:(\w+)/g, "{$1}");
  return [`${method} ${shape(openApiPath)}`, openApiPath];
}));
const inSpec = new Map();
for (const [path, item] of Object.entries(spec.paths)) {
  for (const method of Object.keys(item).filter((key) => METHODS.includes(key))) {
    const parameters = [...(item.parameters ?? []), ...(item[method].parameters ?? [])].map(resolve);
    inSpec.set(`${method.toUpperCase()} ${shape(path)}`, { path, parameters });
  }
}

const problems = [];
for (const [key, codePath] of inCode) {
  const method = key.split(" ")[0];
  const found = inSpec.get(key);
  if (!found) { problems.push(`MISSING   ${method} ${codePath}`); continue; }
  const before = problems.length;

  // 3. Path parameters: the same names as in the code, each one declared "in": "path" and required.
  const names = namesIn(found.path);
  if (names.join() !== namesIn(codePath).join()) {
    problems.push(`RENAMED   ${method} ${codePath}: the spec writes ${found.path}`);
  }
  const declared = found.parameters.filter((p) => p.in === "path");
  for (const name of names) {
    const p = declared.find((d) => d.name === name);
    if (!p) problems.push(`UNDECLARED ${method} ${found.path}: {${name}} is not in "parameters"`);
    else if (p.required !== true) problems.push(`OPTIONAL  ${method} ${found.path}: {${name}} needs "required": true`);
  }
  for (const p of declared) {
    if (!names.includes(p.name)) problems.push(`EXTRA     ${method} ${found.path}: parameter "${p.name}" is not in the path`);
  }
  if (problems.length === before) console.log(`ok        ${method} ${codePath}`);
}
for (const [key, { path }] of inSpec) {
  if (!inCode.has(key)) problems.push(`INVENTED  ${key.split(" ")[0]} ${path}`);
}

for (const problem of problems) console.log(problem);
console.log(`${model ?? "openapi.json"}: ${inCode.size} routes in the code, ${inSpec.size} in the spec, problems: ${problems.length}`);
process.exitCode = problems.length > 0 ? 1 : 0; // 1 stops a CI pipeline or a Git hook

Run it with node check.mjs qwen3-coder-30b. It takes about 15 seconds.

What you should see

ok        GET /health
ok        GET /devices
ok        GET /devices/{id}
ok        POST /devices/{id}/loans
ok        GET /students/{studentId}/loans
ok        DELETE /loans/{loanId}
qwen3-coder-30b: 6 routes in the code, 6 in the spec, problems: 0

And openapi.json, about 9 KB. A piece of it:

"/devices/{id}": {
  "get": {
    "summary": "Get a specific device by ID",
    "parameters": [
      { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
    ],
    "responses": {
      "200": {
        "description": "Device details",
        …

Two Qwens, and what the others got wrong

qwen3-coder-30b and qwen3.8-27b passed every check in all our runs: seven each. Both found the 400, 404 and 409 of the loan route, the available query parameter and the 204 of the delete. qwen3.8-27b took 12 to 19 seconds. It wrote the shapes of a device, a loan and an error only once, in components, and pointed to them with $ref, so its files were a little shorter: 7.7 to 8.3 KB. It described available as a boolean in four of the runs we saved, and once as a string with "enum": ["true", "false"]. We also added a commented-out route, // ["PUT", "/devices/:id", updateDevice]: neither model described it, in three runs.

The check earns its place with other models. node check.mjs ministral-3-14b, in all six of our runs:

ok        GET /health
ok        GET /devices
ok        GET /devices/{id}
ok        GET /students/{studentId}/loans
ok        DELETE /loans/{loanId}
MISSING   POST /devices/{id}/loans
INVENTED  POST /devices/{id}
ministral-3-14b: 6 routes in the code, 6 in the spec, problems: 2

The summary of that route was right, “Create a new loan for a device”; the path was not. Read quickly, POST /devices/{id} looks fine. Call it, and the server answers 404 Not Found. apertus-70b-instruct, in one run of four, lost GET /devices/{id} and put its id parameter on GET /devices: the check printed EXTRA and MISSING.

When the code changes

The most useful run has no model in it. Add a route to api.mjs, after the loan route:

  ["GET", "/devices/:id/loans", (req, res, { id }) => send(res, 200, loans.filter((l) => l.deviceId === Number(id)))],

and run node check.mjs, with no model name:

…
ok        DELETE /loans/{loanId}
MISSING   GET /devices/{id}/loans
openapi.json: 7 routes in the code, 6 in the spec, problems: 1

The exit code is 1. Put node check.mjs in your CI or in a Git pre-commit hook, and a new route without its documentation stops the build or the commit. Then ask the model again, or add the route to openapi.json by hand.

The parameter checks work the same way. We renamed {studentId} to {student_id} in the file, made loanId optional and removed the id parameter of a route:

UNDECLARED GET /devices/{id}: {id} is not in "parameters"
RENAMED   GET /students/{studentId}/loans: the spec writes /students/{student_id}/loans
OPTIONAL  DELETE /loans/{loanId}: {loanId} needs "required": true

What the check does not see

It compares routes and path parameters, because the code has them in a table. It does not check the request bodies, the response schemas or the status codes: read those. A linter checks the rules of OpenAPI itself. We ran npx @redocly/cli lint openapi.json (it downloads the tool) on the file of qwen3-coder-30b: 6 errors, all the same, “Every operation should have security defined”. That is true of the code, not only of the description: the API has no login, and anyone can delete a loan.

Good to know

Exercise

All examples