From 4088bcd7dcd035b96f19b78170b43766de22ad10 Mon Sep 17 00:00:00 2001 From: samaxbytez Date: Tue, 3 Nov 2026 21:52:24 +0000 Subject: [PATCH 1/2] refactor: harden the docs sync and apply review fixes --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 5 ++ ALIGNMENT.md | 2 +- CHANGELOG.md | 2 +- README.md | 12 +-- openapi/openapi.yaml | 17 +---- package.json | 8 +- redocly.yaml | 16 ++++ scripts/sync-from-docs.mjs | 140 +++++++++++++++++----------------- 9 files changed, 107 insertions(+), 97 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae686e8..1e6b1eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 + cache: npm - run: npm ci --ignore-scripts - run: npm run lint - run: npm run bundle @@ -38,5 +39,6 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 + cache: npm - run: npm ci --ignore-scripts - run: npm run sync -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f09fb17..9bc27a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 + cache: npm + - name: Tag matches package.json + env: + TAG: ${{ github.ref_name }} + run: test "$TAG" = "v$(node -p "require('./package.json').version")" - run: npm ci --ignore-scripts - run: npm run lint - run: npm run bundle diff --git a/ALIGNMENT.md b/ALIGNMENT.md index d841174..1d5388c 100644 --- a/ALIGNMENT.md +++ b/ALIGNMENT.md @@ -27,7 +27,7 @@ mv .redocly.lint-ignore.yaml /tmp/ && npm run lint; mv /tmp/.redocly.lint-ignore | Tag used but not declared | `Cable & Bill Payments` (1 operation) | | No tag descriptions | All 13 tags | | No webhooks | `payment_success` and other events are documented in prose only. Add a `webhooks` section (needs OpenAPI 3.1) or `x-webhooks` | -| Info block | Docs title is `Vendor API` with an empty description. The sync script overrides both | +| Info block | Docs title is `Vendor API` with an empty description and no license. `redocly.yaml` overrides it in the release files | ## Wrong examples or schemas diff --git a/CHANGELOG.md b/CHANGELOG.md index c4165da..df44b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,4 +3,4 @@ ## 0.1.0 (unreleased) - First release. 100 operations across 94 paths, generated from the developer.nomba.com API reference. -- Adds global payout, global collection, direct debits, betting, saved-card charges, terminal payment requests and the v2 bills and transfers endpoints, none of which are in `kudi-inc/vendor-openapi-spec`. +- Adds global payout, global collection, direct debits, betting, terminal payment requests and the v2 bills and transfers endpoints, none of which are in `kudi-inc/vendor-openapi-spec`. diff --git a/README.md b/README.md index 12af92f..d744523 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@ npx @openapitools/openapi-generator-cli generate -i openapi.json -g typescript-f Import into Postman: **Import → Link** and paste the JSON URL above. -Browse it locally: +Render it as a single HTML page: ```bash -npm ci && npm run preview +npm ci && npm run docs # writes dist/index.html ``` ## Where it comes from The source of truth is the API reference at [developer.nomba.com](https://developer.nomba.com/nomba-api-reference/introduction). Each reference page embeds the OpenAPI definition for its endpoint; `npm run sync` fetches every page, merges them, and splits the result into `openapi/` (one file per path and per schema). -The content is copied as-is with two exceptions, both in [`scripts/sync-from-docs.mjs`](scripts/sync-from-docs.mjs): the `info` block is filled in, and a query string is removed from one path key. Known issues inherited from the docs are tracked in [ALIGNMENT.md](ALIGNMENT.md). +`openapi/` is a copy of the docs with one change: a query string is removed from one path key, because it isn't valid OpenAPI or a valid file name. The sync stops if two pages define the same thing differently, or if an endpoint page has no OpenAPI block. The docs title the API "Vendor API" with no description, so the bundled release files replace the `info` block (see `redocly.yaml`). Known issues inherited from the docs are tracked in [ALIGNMENT.md](ALIGNMENT.md). This replaces the older `kudi-inc/vendor-openapi-spec` file, which covers 67 operations and was last updated in December 2024. @@ -48,7 +48,7 @@ openapi/ paths/ one file per path components/schemas/ one file per schema scripts/sync-from-docs.mjs -redocly.yaml lint rules (Redocly recommended) +redocly.yaml lint rules and the release `info` block .redocly.lint-ignore.yaml baseline of known issues; shrink it, never grow it ``` @@ -60,14 +60,14 @@ redocly.yaml lint rules (Redocly recommended) | `npm run bundle` | Write `dist/openapi.yaml` and `dist/openapi.json` | | `npm run sync` | Rebuild `openapi/` from the docs | | `npm run sync -- --check` | Exit 1 if `openapi/` and the docs differ | -| `npm run preview` | Serve rendered docs locally | +| `npm run docs` | Render `dist/index.html` | | `npm run stats` | Count paths, operations, schemas | CI lints and bundles every pull request and checks for drift against the docs every Monday. ## Releasing -Bump `version` in `package.json`, add a `CHANGELOG.md` entry, merge, then tag `vX.Y.Z` on main. The release workflow bundles the spec and attaches both files to a GitHub release. +Bump `version` in `package.json`, add a `CHANGELOG.md` entry, merge, then tag `vX.Y.Z` on main. The release workflow checks the tag matches `package.json`, bundles the spec and attaches both files to a GitHub release. ## License diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 72aaab1..f6a5ecd 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,17 +1,8 @@ openapi: 3.0.1 info: - title: Nomba API + description: '' + title: Vendor API version: 1.0.0 - description: >- - Accounts, virtual accounts, checkout, transfers, direct debits, terminals, - transactions and bill payments. Generated from https://developer.nomba.com. - contact: - name: Nomba Developers - url: https://developer.nomba.com - email: docs@nomba.com - license: - name: MIT - url: https://opensource.org/licenses/MIT servers: - description: Production url: https://api.nomba.com @@ -129,6 +120,8 @@ paths: $ref: paths/v1_checkout_checkout-card-otp.yaml /v1/checkout/user-card: $ref: paths/v1_checkout_user-card.yaml + /v1/direct-debits/status: + $ref: paths/v1_direct-debits_status.yaml /v1/direct-debits: $ref: paths/v1_direct-debits.yaml /v1/direct-debits/debit-mandate: @@ -219,8 +212,6 @@ paths: $ref: paths/v1_accounts_virtual_{identifier}.yaml /v1/accounts/virtual/list: $ref: paths/v1_accounts_virtual_list.yaml - /v1/direct-debits/status: - $ref: paths/v1_direct-debits_status.yaml components: securitySchemes: BearerAuth: diff --git a/package.json b/package.json index 508a74d..1f27f11 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,10 @@ }, "scripts": { "sync": "node scripts/sync-from-docs.mjs", - "lint": "redocly lint openapi/openapi.yaml", - "bundle": "redocly bundle openapi/openapi.yaml -o dist/openapi.yaml && redocly bundle openapi/openapi.yaml -o dist/openapi.json", - "preview": "redocly preview-docs openapi/openapi.yaml", - "stats": "redocly stats openapi/openapi.yaml" + "lint": "redocly lint nomba", + "bundle": "redocly bundle nomba -o dist/openapi.yaml && redocly bundle nomba -o dist/openapi.json", + "docs": "redocly build-docs nomba -o dist/index.html", + "stats": "redocly stats nomba" }, "devDependencies": { "@redocly/cli": "2.73.0", diff --git a/redocly.yaml b/redocly.yaml index 5eb21b8..077dda9 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -7,3 +7,19 @@ extends: apis: nomba: root: openapi/openapi.yaml + # Applied to the bundled spec only, so openapi/ stays a copy of the docs. + # The docs title this "Vendor API" with no description. + decorators: + info-override: + title: Nomba API + description: >- + Accounts, virtual accounts, checkout, transfers, direct debits, + terminals, transactions and bill payments. Generated from + https://developer.nomba.com. + contact: + name: Nomba Developers + url: https://developer.nomba.com + email: docs@nomba.com + license: + name: MIT + url: https://opensource.org/licenses/MIT diff --git a/scripts/sync-from-docs.mjs b/scripts/sync-from-docs.mjs index 3aac7a7..4be22da 100644 --- a/scripts/sync-from-docs.mjs +++ b/scripts/sync-from-docs.mjs @@ -8,118 +8,114 @@ // npm run sync -- --check (exits 1 if the docs differ from openapi/) import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempDisposableSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; import { parse, stringify } from "yaml"; const DOCS = "https://developer.nomba.com"; const PAGE = /^````yaml ([a-z]+) (\S+)\n([\s\S]*?)^````/m; +// Reference pages that are prose, not endpoints. +const NOT_ENDPOINTS = new Set(["introduction.md", "get-api-keys.md"]); +const REDOCLY = fileURLToPath(new URL("../node_modules/.bin/redocly", import.meta.url)); const check = process.argv.includes("--check"); async function get(url) { - const res = await fetch(url, { redirect: "follow" }); + const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); if (!res.ok) throw new Error(`${res.status} ${url}`); return res.text(); } +async function getAll(urls, concurrency = 10) { + const out = new Array(urls.length); + let next = 0; + const worker = async () => { + while (next < urls.length) { + const i = next++; + out[i] = await get(urls[i]); + } + }; + await Promise.all(Array.from({ length: concurrency }, worker)); + return out; +} + const index = await get(`${DOCS}/llms.txt`); const urls = [ ...new Set(index.match(/https:\/\/developer\.nomba\.com\/nomba-api-reference\/[^)\s]+\.md/g)), ].sort(); +const pages = await getAll(urls); -const pages = []; -for (let i = 0; i < urls.length; i += 10) { - pages.push(...(await Promise.all(urls.slice(i, i + 10).map(async (u) => [u, await get(u)])))); +let spec; +const problems = []; + +// Merge one entry, recording a problem if a different definition already exists. +function put(target, key, value, label) { + if (key in target && !isDeepStrictEqual(target[key], value)) { + problems.push(`conflicting definition: ${label}`); + } + target[key] = value; } -let spec; -const conflicts = []; -let ops = 0; -for (const [url, text] of pages) { - const m = text.match(PAGE); - if (!m) continue; - ops++; +urls.forEach((url, i) => { + const m = pages[i].match(PAGE); + if (!m) { + if (!NOT_ENDPOINTS.has(url.split("/").pop())) problems.push(`no OpenAPI block: ${url}`); + return; + } const frag = parse(m[3]); spec ??= { ...frag, tags: [], paths: {}, components: {} }; for (const tag of frag.tags ?? []) { if (!spec.tags.some((t) => t.name === tag.name)) spec.tags.push(tag); } - for (const [path, item] of Object.entries(frag.paths ?? {})) { + for (const [rawPath, item] of Object.entries(frag.paths ?? {})) { + // A query string in a path key is invalid OpenAPI and an invalid file + // name on Windows. Its parameters are declared on the operation. + const path = rawPath.split("?")[0]; spec.paths[path] ??= {}; for (const [method, op] of Object.entries(item)) { - const prev = spec.paths[path][method]; - if (prev && JSON.stringify(prev) !== JSON.stringify(op)) { - conflicts.push(`${method.toUpperCase()} ${path} (${url})`); - } - spec.paths[path][method] = op; + put(spec.paths[path], method, op, `${method.toUpperCase()} ${rawPath} (${url})`); } } for (const [section, entries] of Object.entries(frag.components ?? {})) { spec.components[section] ??= {}; for (const [name, value] of Object.entries(entries)) { - const prev = spec.components[section][name]; - if (prev && JSON.stringify(prev) !== JSON.stringify(value)) { - conflicts.push(`components.${section}.${name} (${url})`); - } - spec.components[section][name] = value; + put(spec.components[section], name, value, `components.${section}.${name} (${url})`); } } -} +}); -// The only edits made to the docs content. Everything else is copied as-is; -// content fixes belong upstream in the docs (see ALIGNMENT.md). -spec.info = { - title: "Nomba API", - version: spec.info?.version ?? "1.0.0", - description: - "Accounts, virtual accounts, checkout, transfers, direct debits, terminals, " + - "transactions and bill payments. Generated from https://developer.nomba.com.", - contact: { name: "Nomba Developers", url: DOCS, email: "docs@nomba.com" }, - license: { name: "MIT", url: "https://opensource.org/licenses/MIT" }, -}; -for (const path of Object.keys(spec.paths)) { - // A query string in a path key is invalid OpenAPI and an invalid file name - // on Windows. The query parameter is already declared on the operation. - if (path.includes("?")) { - const clean = path.slice(0, path.indexOf("?")); - spec.paths[clean] = { ...spec.paths[clean], ...spec.paths[path] }; - delete spec.paths[path]; - } +if (!spec) throw new Error("no OpenAPI blocks found; has the docs page format changed?"); +console.log(`${urls.length} pages, ${Object.keys(spec.paths).length} paths`); +if (problems.length) { + for (const p of problems) console.error(p); + process.exit(1); } -console.log(`${ops} endpoint pages, ${Object.keys(spec.paths).length} paths`); -for (const c of conflicts) console.warn(`conflicting definition: ${c}`); - -const dir = mkdtempSync(join(tmpdir(), "nomba-openapi-")); -const merged = join(dir, "merged.yaml"); +using tmp = mkdtempDisposableSync(join(tmpdir(), "nomba-openapi-")); +const merged = join(tmp.path, "merged.yaml"); +const outDir = join(tmp.path, "openapi"); writeFileSync(merged, stringify(spec, { lineWidth: 0 })); +execFileSync(REDOCLY, ["split", merged, "--outDir", outDir], { stdio: ["ignore", "ignore", "inherit"] }); if (check) { - // Compare the docs against the bundled repo spec, ignoring file layout. - const local = join(dir, "local.yaml"); - execFileSync("npx", ["redocly", "bundle", "openapi/openapi.yaml", "-o", local], { - stdio: "ignore", - }); - const norm = (f) => JSON.stringify(sortKeys(parse(readFileSync(f, "utf8")))); - const same = norm(merged) === norm(local); - rmSync(dir, { recursive: true }); - console.log(same ? "openapi/ matches the docs" : "openapi/ differs from the docs"); - process.exit(same ? 0 : 1); + const same = sameTree(outDir, "openapi"); + console.log(same ? "openapi/ matches the docs" : "openapi/ differs from the docs; run npm run sync"); + process.exitCode = same ? 0 : 1; +} else { + rmSync("openapi", { recursive: true, force: true }); + renameSync(outDir, "openapi"); } -rmSync("openapi", { recursive: true, force: true }); -execFileSync("npx", ["redocly", "split", merged, "--outDir", "openapi"], { stdio: "inherit" }); -rmSync(dir, { recursive: true }); - -function sortKeys(v) { - if (Array.isArray(v)) return v.map(sortKeys); - if (v && typeof v === "object") { - return Object.fromEntries( - Object.keys(v) - .sort() - .map((k) => [k, sortKeys(v[k])]) - ); - } - return v; +function sameTree(a, b) { + const list = (d) => readdirSync(d, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => join(e.parentPath, e.name).slice(d.length)) + .sort(); + const files = list(a); + return ( + isDeepStrictEqual(files, list(b)) && + files.every((f) => readFileSync(join(a, f)).equals(readFileSync(join(b, f)))) + ); } From 5704ed87eb05ff918277f012c14f6369efad0a20 Mon Sep 17 00:00:00 2001 From: samaxbytez Date: Tue, 3 Nov 2026 21:54:25 +0000 Subject: [PATCH 2/2] fix: reject prototype keys when merging docs fragments --- scripts/sync-from-docs.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/sync-from-docs.mjs b/scripts/sync-from-docs.mjs index 4be22da..cfdf679 100644 --- a/scripts/sync-from-docs.mjs +++ b/scripts/sync-from-docs.mjs @@ -21,6 +21,8 @@ const PAGE = /^````yaml ([a-z]+) (\S+)\n([\s\S]*?)^````/m; const NOT_ENDPOINTS = new Set(["introduction.md", "get-api-keys.md"]); const REDOCLY = fileURLToPath(new URL("../node_modules/.bin/redocly", import.meta.url)); const check = process.argv.includes("--check"); +// Keys that would reach Object.prototype when used to index a plain object. +const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]); async function get(url) { const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); @@ -52,7 +54,11 @@ const problems = []; // Merge one entry, recording a problem if a different definition already exists. function put(target, key, value, label) { - if (key in target && !isDeepStrictEqual(target[key], value)) { + if (UNSAFE_KEYS.has(key)) { + problems.push(`unsafe key: ${label}`); + return; + } + if (Object.hasOwn(target, key) && !isDeepStrictEqual(target[key], value)) { problems.push(`conflicting definition: ${label}`); } target[key] = value; @@ -79,6 +85,10 @@ urls.forEach((url, i) => { } } for (const [section, entries] of Object.entries(frag.components ?? {})) { + if (UNSAFE_KEYS.has(section)) { + problems.push(`unsafe key: components.${section} (${url})`); + continue; + } spec.components[section] ??= {}; for (const [name, value] of Object.entries(entries)) { put(spec.components[section], name, value, `components.${section}.${name} (${url})`);