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:
- the program imports the real route table from your API;
- the model writes
openapi.json; - 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
response_format: { type: "json_object" }asks for valid JSON without giving a schema: a schema of the whole OpenAPI format would be far too big to send. The program still parses the answer and checksopenapi.temperature: 0makes the answers alike, not identical. Of the six files ofqwen3-coder-30bwe saved, five were identical; the sixth had the same routes and other words in five summaries.- The model reads the whole file, comments included. Do not send a file with keys or passwords in it.
- Generate once, then maintain. After the first version, many teams edit
openapi.jsonby hand and let the check say when it no longer matches. - A bad address can stop the server.
decodeURIComponentthrows on a malformed path such as/devices/%E0, and here nothing catches it. Wrap it intry/catchand answer 400.
Exercise
- Add the
GET /devices/:id/loansroute, runnode check.mjs, thennode check.mjs qwen3.8-27b. Is the new route described right? - Write a fourth check: every route of the code that calls
send(res, 404, …)must have a"404"response in the spec. Hint:handler.toString()gives you the source of each handler. - Open the file in Swagger UI: paste it at editor.swagger.io. Which route would you call first?
- Rewrite
api.mjswith Express. How do you get the list of routes now?
