Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ALIGNMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
```

Expand All @@ -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

Expand Down
17 changes: 4 additions & 13 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions redocly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
150 changes: 78 additions & 72 deletions scripts/sync-from-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,118 +8,124 @@
// 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");
// 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, { 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);

let spec;
const problems = [];

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)]))));
// Merge one entry, recording a problem if a different definition already exists.
function put(target, key, value, label) {
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;
}

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 ?? {})) {
if (UNSAFE_KEYS.has(section)) {
problems.push(`unsafe key: components.${section} (${url})`);
continue;
}
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))))
);
}
Loading