mirror of
https://github.com/docker/docs.git
synced 2026-11-04 10:01:00 +00:00
@netlify /reference/api/ [Preview the API reference](https://deploy-preview-26048--docsdocker.netlify.app/reference/api/) Adds a shared API reference experience for Hub, DVP, and Registry: a catalog, operation navigation, linked schemas, request and response examples, and matching HTML and Markdown output. A validation and generation pipeline prepares the reference data for Hugo and runs as part of Docker and Netlify builds. Migrates the three specifications to OpenAPI 3.2, correcting response shapes, nullable fields, and authentication and token examples. The Hub migration also corrects SCIM contracts and CSV exports; DVP gains complete report response schemas and examples. Registry keeps its documented Hub-specific subset, with authentication and media negotiation expressed through OpenAPI security and content fields. Validation is strict for both checks and generation, with no exception baseline or bypass flag. The build uses the migrated sources directly, without migration scripts or records. Page and specification URLs remain stable, including the DVP alias. Operation and schema anchors change. Engine and Governance retain their existing references. Validated locally with Go tests, strict validation of all three specifications (zero diagnostics), scoped formatting and lint, and a full Hugo build checking all 176 HTML/Markdown page pairs, example parity, reference links, and byte-identical published specifications. A browser check confirmed raw CSV rendering and the generated request header. No live API requests were made.
177 lines
6.0 KiB
JavaScript
177 lines
6.0 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
const root = path.resolve(import.meta.dirname, "../..");
|
|
const base = path.resolve(process.argv[2] || "public");
|
|
const data = JSON.parse(
|
|
fs.readFileSync(path.join(root, "tmp/api-reference/data/api-reference.json")),
|
|
);
|
|
const decode = (s) =>
|
|
s
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
.replaceAll("&", "&")
|
|
.replaceAll(""", '"')
|
|
.replaceAll("'", "'")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
// Hugo's production minifier removes optional attribute quotes.
|
|
function attributes(html, name) {
|
|
const pattern = new RegExp(
|
|
`\\s${name}=(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
"g",
|
|
);
|
|
return [...html.matchAll(pattern)].map((m) => decode(m[1] ?? m[2] ?? m[3]));
|
|
}
|
|
let count = 0;
|
|
const problems = [];
|
|
function check(url) {
|
|
const htmlPath = path.join(base, url, "index.html");
|
|
const mdPath = path.join(base, url.replace(/\/$/, "")) + ".md";
|
|
if (!fs.existsSync(htmlPath) || !fs.existsSync(mdPath)) {
|
|
problems.push(`Missing HTML/Markdown: ${url}`);
|
|
return ["", ""];
|
|
}
|
|
const html = fs.readFileSync(htmlPath, "utf8"),
|
|
md = fs.readFileSync(mdPath, "utf8");
|
|
count++;
|
|
return [decode(html), md];
|
|
}
|
|
check("/reference/api/");
|
|
for (const api of data.apis) {
|
|
check(api.url);
|
|
const publishedSource = fs.readFileSync(path.join(base, api.sourceURL));
|
|
if (!publishedSource.equals(fs.readFileSync(path.join(root, api.source))))
|
|
problems.push(`Published specification differs from source: ${api.id}`);
|
|
|
|
for (const op of api.operations) {
|
|
const [html, md] = check(op.url);
|
|
for (const variant of op.variants) {
|
|
if (
|
|
!attributes(html, "data-api-variant").includes(variant.pointer) ||
|
|
!md.includes(`### ${variant.direction} ${variant.status}`)
|
|
)
|
|
problems.push(`Missing variant: ${op.url} ${variant.pointer}`);
|
|
for (const example of variant.examples) {
|
|
if (
|
|
!html.includes(example.text.trim()) ||
|
|
!md.includes("```" + example.language + "\n" + example.text)
|
|
)
|
|
problems.push(`Example mismatch: ${op.url} ${variant.pointer}`);
|
|
}
|
|
}
|
|
for (const p of op.parameters)
|
|
if (
|
|
!attributes(html, "data-api-parameter").includes(p.name) ||
|
|
!md.includes(`### ${p.name}`)
|
|
)
|
|
problems.push(`Missing parameter: ${op.url} ${p.name}`);
|
|
if (!html.includes(op.path) || !md.includes(op.method + " " + op.path))
|
|
problems.push(`Missing operation signature: ${op.url}`);
|
|
if (!md.includes(op.curl))
|
|
problems.push(`Request example mismatch: ${op.url}`);
|
|
}
|
|
for (const schema of api.schemas) {
|
|
const [html, md] = check(schema.url);
|
|
if (
|
|
![...md.matchAll(/```json\n([\s\S]*?)\n```/g)].some((m) => {
|
|
try {
|
|
return isDeepStrictEqual(JSON.parse(m[1]), schema.schema);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
)
|
|
problems.push(`Incomplete Markdown schema: ${schema.url}`);
|
|
if (!html.includes(schema.name))
|
|
problems.push(`Missing schema heading: ${schema.url}`);
|
|
}
|
|
}
|
|
// Check local links and fragments in generated API pages, including specification prose.
|
|
function files(dir) {
|
|
return fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.flatMap((e) =>
|
|
e.isDirectory()
|
|
? files(path.join(dir, e.name))
|
|
: [path.join(dir, e.name)],
|
|
);
|
|
}
|
|
for (const file of [...files(path.join(base, "reference/api"))].filter((p) =>
|
|
p.endsWith(".html"),
|
|
)) {
|
|
const html = fs.readFileSync(file, "utf8");
|
|
if (!html.includes("data-api-view=")) continue;
|
|
const article = html.match(
|
|
/<article\b[^>]*class=["']?api-reference[\s\S]*?<\/article>/,
|
|
)?.[0];
|
|
if (!article) {
|
|
problems.push(`Missing API article: ${path.relative(base, file)}`);
|
|
continue;
|
|
}
|
|
for (const href of attributes(article, "href")) {
|
|
let url;
|
|
try {
|
|
url = new URL(href, "http://localhost:1314/" + path.relative(base, file));
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (
|
|
!["localhost", "docs.docker.com"].includes(url.hostname) ||
|
|
!["http:", "https:"].includes(url.protocol)
|
|
)
|
|
continue;
|
|
const target = path.join(
|
|
base,
|
|
decodeURIComponent(url.pathname),
|
|
url.pathname.endsWith("/") ? "index.html" : "",
|
|
);
|
|
if (!fs.existsSync(target)) {
|
|
problems.push(`Broken API link: ${path.relative(base, file)} -> ${href}`);
|
|
} else if (url.hash && target.endsWith(".html")) {
|
|
const targetHTML = fs.readFileSync(target, "utf8");
|
|
const anchors = [
|
|
...attributes(targetHTML, "id"),
|
|
...attributes(targetHTML, "name"),
|
|
];
|
|
if (!anchors.includes(decodeURIComponent(url.hash.slice(1))))
|
|
problems.push(
|
|
`Broken API anchor: ${path.relative(base, file)} -> ${href}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const governance = fs.readFileSync(
|
|
path.join(base, "reference/api/ai-governance/index.html"),
|
|
"utf8",
|
|
);
|
|
if (governance.includes("data-api-view="))
|
|
problems.push("Governance renderer changed");
|
|
if (fs.existsSync(path.join(base, "api-prototype")))
|
|
problems.push("Unexpected prototype routes");
|
|
// Historical Engine pages must keep the original renderer at their existing URLs.
|
|
for (let minor = 40; minor <= 56; minor++) {
|
|
const url = `/reference/api/engine/version/v1.${minor}/`;
|
|
const html = fs.readFileSync(path.join(base, url, "index.html"), "utf8");
|
|
if (!html.includes("<redoc") || html.includes("data-api-view="))
|
|
problems.push(`Legacy Engine renderer changed: ${url}`);
|
|
}
|
|
if (problems.length)
|
|
throw Error(
|
|
problems.slice(0, 25).join("\n") + `\n${problems.length} output failures`,
|
|
);
|
|
const bytes = files(path.join(base, "reference/api")).reduce(
|
|
(n, p) => n + fs.statSync(p).size,
|
|
0,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(root, "tmp/api-reference/reports/output.json"),
|
|
JSON.stringify(
|
|
{ htmlMarkdownPagePairs: count, referenceBytes: bytes, result: "pass" },
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
console.log(
|
|
`Verified ${count} HTML/Markdown page pairs and API reference links.`,
|
|
);
|