From 2dfd966a070bef32defc3b3d182d04d2faf265fc Mon Sep 17 00:00:00 2001 From: Crt Ahlin Date: Thu, 27 Aug 2026 14:19:15 +0200 Subject: [PATCH] Publish SwarmCommon.yaml alongside the OpenAPI spec The prebuild step copies openapi/Swarm.yaml to static/openapi.yaml but not its sibling openapi/SwarmCommon.yaml, which holds every schema, parameter, header and response. All 446 $refs in the published spec point at that one file, so the spec served from docs.ethswarm.org/openapi.yaml cannot be dereferenced by any OpenAPI client: 99 schemas, 23 parameters, 7 headers and 8 responses are unreachable. The Redoc page at /api/ is unaffected because redocusaurus resolves the refs at build time from the openapi/ directory, where both files sit together. That is why the failure is invisible: the human page renders and the published file still returns HTTP 200. Also publishes .well-known/agent.json as a copy of agent-card.json, so clients probing the spec-canonical A2A filename find the card, and adds scripts/validate-openapi-spec.mjs to the prebuild chain. The validator resolves every $ref in the published spec and exits non-zero if any fails, so this regression cannot return silently. Verified against both states: it fails with 446 unresolvable refs before the fix and passes after. Both generated files are gitignored, keeping openapi/ and agent-card.json the single sources of truth. --- .gitignore | 2 + package.json | 2 +- scripts/validate-openapi-spec.mjs | 113 ++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 scripts/validate-openapi-spec.mjs diff --git a/.gitignore b/.gitignore index 942a52497..016e70f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ resources .netlify /src/pages/awesome-swarm.mdx /static/openapi.yaml +/static/SwarmCommon.yaml +/static/.well-known/agent.json /static/cheatsheets test docs/references/awesome-list.mdx diff --git a/package.json b/package.json index 8379aaff9..57a60cf9e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "prebuild": "node -e \"require('fs').copyFileSync('openapi/Swarm.yaml', 'static/openapi.yaml')\" && node scripts/fetch-awesome-swarm.mjs && node scripts/fetch-cheatsheets.mjs && node scripts/validate-llms-txt.mjs", + "prebuild": "node -e \"const f=require('fs');f.copyFileSync('openapi/Swarm.yaml','static/openapi.yaml');f.copyFileSync('openapi/SwarmCommon.yaml','static/SwarmCommon.yaml');f.copyFileSync('static/.well-known/agent-card.json','static/.well-known/agent.json')\" && node scripts/fetch-awesome-swarm.mjs && node scripts/fetch-cheatsheets.mjs && node scripts/validate-llms-txt.mjs && node scripts/validate-openapi-spec.mjs", "build": "docusaurus build", "build:quiet": "cross-env NODE_OPTIONS=\"--disable-warning=DEP0040 --disable-warning=DEP0169\" docusaurus build", "swizzle": "docusaurus swizzle", diff --git a/scripts/validate-openapi-spec.mjs b/scripts/validate-openapi-spec.mjs new file mode 100644 index 000000000..f7dbfb935 --- /dev/null +++ b/scripts/validate-openapi-spec.mjs @@ -0,0 +1,113 @@ +// Validate the OpenAPI spec published to static/ — the file agents actually fetch +// from https://docs.ethswarm.org/openapi.yaml. +// +// Why this exists: Swarm.yaml keeps every schema, parameter, header and response +// in a sibling file (SwarmCommon.yaml) and references it with relative $refs. The +// Redoc page at /api/ dereferences those at build time from the openapi/ directory, +// so it renders correctly even when the *published* file cannot be dereferenced at +// all. That failure is invisible over HTTP — the spec still returns 200 — so only a +// build-time check catches it. +// +// Exits 1 on an unresolvable $ref. Unlike validate-llms-txt.mjs this blocks the +// build, because a spec that no OpenAPI client can dereference is broken output, +// not a documentation warning. + +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const SPEC = join(ROOT, 'static', 'openapi.yaml'); + +const docCache = new Map(); + +/** Load and parse a YAML document, memoised by absolute path. */ +function loadDoc(absPath) { + if (!docCache.has(absPath)) { + if (!existsSync(absPath)) { + docCache.set(absPath, null); + } else { + docCache.set(absPath, parse(readFileSync(absPath, 'utf8'))); + } + } + return docCache.get(absPath); +} + +/** Walk a JSON Pointer (RFC 6901) into a parsed document. */ +function resolvePointer(doc, pointer) { + let node = doc; + for (const rawPart of pointer.replace(/^#\/?/, '').split('/')) { + if (rawPart === '') continue; + const part = rawPart.replace(/~1/g, '/').replace(/~0/g, '~'); + if (node && typeof node === 'object' && part in node) { + node = node[part]; + } else { + return undefined; + } + } + return node; +} + +/** Collect every $ref value in a document, with the path where it was found. */ +function collectRefs(node, trail = '', found = []) { + if (Array.isArray(node)) { + node.forEach((item, i) => collectRefs(item, `${trail}/${i}`, found)); + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') { + found.push({ ref: value, at: trail || '/' }); + } else { + collectRefs(value, `${trail}/${key}`, found); + } + } + } + return found; +} + +if (!existsSync(SPEC)) { + console.error(`✗ ${SPEC} not found — the prebuild copy step did not run.`); + process.exit(1); +} + +const spec = loadDoc(SPEC); +const refs = collectRefs(spec); +const failures = []; +const externalFiles = new Set(); + +for (const { ref, at } of refs) { + const [filePart, pointer = ''] = ref.split('#'); + // A ref with no file part is internal to this document. + const targetPath = filePart ? resolvePath(dirname(SPEC), filePart) : SPEC; + if (filePart) externalFiles.add(filePart); + + const targetDoc = loadDoc(targetPath); + if (targetDoc === null) { + failures.push(`${ref} (referenced at ${at}) — file not published alongside the spec`); + continue; + } + if (pointer && resolvePointer(targetDoc, pointer) === undefined) { + failures.push(`${ref} (referenced at ${at}) — pointer does not resolve`); + } +} + +const distinct = new Set(refs.map((r) => r.ref)); +console.log( + `OpenAPI spec check: ${refs.length} $refs (${distinct.size} distinct) across ` + + `${externalFiles.size} external file(s): ${[...externalFiles].join(', ') || 'none'}` +); + +if (failures.length) { + const unique = [...new Set(failures)]; + console.error(`\n✗ ${failures.length} unresolvable $ref(s), ${unique.length} distinct:\n`); + for (const f of unique.slice(0, 20)) console.error(` ${f}`); + if (unique.length > 20) console.error(` … and ${unique.length - 20} more`); + console.error( + `\nEvery file referenced by static/openapi.yaml must be copied into static/ ` + + `by the prebuild step, or the published spec cannot be dereferenced.` + ); + process.exit(1); +} + +console.log('✓ All $refs resolve in the published spec.');