From abf57c954e838b549dcdc5d34a5b070d2ce4b4b2 Mon Sep 17 00:00:00 2001 From: Buffly Date: Tue, 20 Oct 2026 16:58:35 +0000 Subject: [PATCH] docs(api): generate the README endpoint table from openapi.yaml The REST table was twenty-five hand-typed rows kept in step with the code by memory. Render it from the spec between markers as part of openapi:gen, add `npm run openapi:check` to CI so the build fails on drift, and assert the committed README matches a fresh rendering in tests/openapi.test.ts. Regenerating corrected the table: paths now use OpenAPI {param} syntax, the example query strings the spec cannot encode are gone, the missing POST /pairs row appears, and GET/POST /graphql are separate rows. Co-Authored-By: Codebuff --- .github/workflows/ci.yml | 3 + README.md | 59 ++++++++++-------- package.json | 1 + scripts/generate-openapi.ts | 121 ++++++++++++++++++++++++++++++++++-- tests/openapi.test.ts | 25 +++++++- 5 files changed, 178 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ecf75e..e17cefc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: node-version: '20' cache: npm - run: npm ci || npm install + # Fails when openapi.json or the README's generated endpoint table has + # drifted from openapi.yaml. Run `npm run openapi:gen` to fix. + - run: npm run openapi:check - run: node node_modules/prisma/build/index.js generate - run: npx tsc --noEmit - run: npm run build diff --git a/README.md b/README.md index 8351617e..64adeb42 100644 --- a/README.md +++ b/README.md @@ -12,34 +12,43 @@ Aggregates price data from Stellar's Classic Order Book (SDEX) and AMM Liquidity ## Endpoints ### REST + | Method | Path | Description | |---|---|---| -| GET | `/price/:assetA/:assetB` | Current VWAP, 24h volume, best route | -| GET | `/price/:assetA/:assetB/route?amount=1000` | Best execution route for a given amount | -| GET | `/price/:assetA/:assetB/depth?amount=1000` | Simulated order-book depth and execution slippage | -| GET | `/book/:assetA/:assetB?depth=0.1&step=0.01&network=testnet` | Aggregated SDEX book and synthetic AMM price ladder with cumulative size and venue provenance | -| GET | `/price/:assetA/:assetB/history?window=1h` | OHLCV history (`1m`, `5m`, `1h`, `24h`) | -| GET | `/price/twap/:assetA/:assetB?window=60&sampleInterval=60&method=iqr` | Manipulation-resistant time-weighted average price | -| GET | `/price/vwap/:assetA/:assetB?window=60&source=SDEX&method=iqr` | Volume-weighted average price | -| GET | `/candles/:assetA/:assetB?interval=1h&from=…&to=…` | OHLCV candles (`1m`, `5m`, `15m`, `1h`, `4h`, `1d`) | -| GET | `/prices/history?pair=XLM/USDC&from=…&to=…&interval=1m` | Historical 1-minute price snapshots, optionally aggregated (`1m`, `5m`, `1h`); honours `?network=`; `?format=csv` serves the points as `text/csv` (`ts,price,volume`); ~30-day retention | -| GET | `/pools` | Active AMM pools being watched | -| GET | `/pairs` | Watched trading pairs | -| GET | `/volumes/:asset?window=24h` | Cross-venue traded volume (`24h`, `7d`, `30d`) with a per-venue breakdown | -| GET | `/spreads/:asset?window=5m` | Per-venue bid/ask spread, tightest first (`5m`, `15m`, `1h`, `24h`) | -| GET | `/compare/:asset` | Lens vs configured oracle prices, with per-oracle deviation and availability | -| GET | `/screener?sortBy=volume&order=desc&limit=20` | Screen pairs by volume, 24h change, liquidity and price | -| GET | `/benchmark/:asset?target=USD` | Peg-deviation statistics versus a target | -| GET | `/basket?asset=XLM&asset=USDC&weight=0.6&weight=0.4` | Weighted price of a basket of assets | -| GET | `/status` | Indexer health | -| GET | `/discovery/resources?type=&payTo=&network=&extensions=&limit=&offset=` | Bazaar catalog of x402-discoverable resources (spec: [`bazaar`](https://github.com/x402-foundation/x402/blob/main/specs/extensions/bazaar.md)) | -| GET | `/usage/me` | Usage and quota for the calling API key (requires `Authorization: Bearer `) | -| GET | `/supported` | Payment kinds and extensions this facilitator supports | -| POST | `/verify` | Verify an x402 payment payload | -| POST | `/settle` | Settle an x402 payment (idempotent — retries replay the stored result) | +| GET | `/status` | Get API status and indexer state | +| GET | `/price/{assetA}/{assetB}` | Get aggregated price for a pair | +| GET | `/price/{assetA}/{assetB}/route` | Get best swap route for a pair and amount | +| GET | `/price/{assetA}/{assetB}/history` | Get price history buckets | +| GET | `/pools` | Get AMM liquidity pool reserves and spot prices | +| GET | `/candles/{assetA}/{assetB}` | Get OHLCV candle data | +| GET | `/prices/history` | Get historical 1-minute price snapshots | +| GET | `/pairs` | List all active trading pairs | +| POST | `/pairs` | Add a new trading pair (Admin only) | +| GET | `/price/{assetA}/{assetB}/depth` | Get order-book depth and execution slippage for a pair | +| GET | `/book/{assetA}/{assetB}` | Get the aggregated SDEX and AMM price ladder for a pair | +| GET | `/price/twap/{assetA}/{assetB}` | Get a manipulation-resistant time-weighted average price | +| GET | `/price/vwap/{assetA}/{assetB}` | Get a volume-weighted average price | +| GET | `/volumes/{asset}` | Get cross-venue traded volume for an asset | +| GET | `/spreads/{asset}` | Get the tightest per-venue bid/ask spread for an asset | +| GET | `/compare/{asset}` | Compare Lens prices with every configured external oracle | +| GET | `/screener` | Screen pairs by volume, change, liquidity and price | +| GET | `/benchmark/{asset}` | Get peg-deviation statistics for an asset against a target | +| GET | `/basket` | Compute the weighted price of a basket of assets | +| GET | `/discovery/resources` | Browse the x402 Bazaar catalog of discoverable resources | | POST | `/webhooks` | Subscribe to a price-threshold webhook | -| DELETE | `/webhooks/:id` | Delete a webhook subscription | -| GET/POST | `/graphql` | GraphQL query/mutation endpoint (GraphiQL IDE at `/graphiql`, docs: [`docs/graphql.md`](docs/graphql.md)) | +| DELETE | `/webhooks/{id}` | Delete a webhook subscription | +| GET | `/usage/me` | Get usage and quota for the calling API key | +| GET | `/supported` | List the payment kinds and extensions this facilitator supports | +| POST | `/verify` | Verify an x402 payment payload | +| POST | `/settle` | Settle an x402 payment (idempotent) | +| GET | `/graphql` | Execute a GraphQL query over HTTP GET | +| POST | `/graphql` | Execute a GraphQL query, mutation or subscription handshake | + + +The REST table above is generated from [`openapi.yaml`](openapi.yaml) by +`npm run openapi:gen` — edit the spec, not the table. `npm run openapi:check` +(and `tests/openapi.test.ts`) fails when the committed README and a fresh +generation differ. Every non-internal route above has a matching entry in [`openapi.yaml`](openapi.yaml); `tests/openapi.test.ts` boots the server and fails if a registered route is diff --git a/package.json b/package.json index c25cfd9e..ca266833 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "node dist/index.js", "test": "vitest run", "openapi:gen": "tsx scripts/generate-openapi.ts", + "openapi:check": "tsx scripts/generate-openapi.ts --check", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset tag", diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 96b450f3..12817d68 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -4,6 +4,23 @@ import YAML from 'js-yaml' const sourcePath = fileURLToPath(new URL('../openapi.yaml', import.meta.url)) const destinationPath = fileURLToPath(new URL('../openapi.json', import.meta.url)) +const readmePath = fileURLToPath(new URL('../README.md', import.meta.url)) + +/** Markers around the generated endpoint table in README.md. */ +export const README_TABLE_START = '' +export const README_TABLE_END = '' + +/** HTTP methods that may appear as operations under an OpenAPI path item. */ +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'] as const + +interface Operation { + summary?: string + description?: string +} + +interface SpecDocument { + paths?: Record> +} /** * Renders the committed `openapi.json` from `openapi.yaml`. @@ -23,14 +40,108 @@ export function renderOpenApiJson(yamlSource?: string): string { return JSON.stringify(document, null, 2) + '\n' } -/** Regenerates `openapi.json` from `openapi.yaml`, in place. */ +function escapeCell(value: string): string { + // `|` would split the markdown cell; newlines would end the row. + return value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ').trim() +} + +/** + * Renders the REST endpoint table from `openapi.yaml`. + * + * One row per operation, in the order the spec declares them. Paths use the + * OpenAPI `{param}` form — the spec is the source of truth for this table, and + * it cannot encode the example query strings the hand-written table used. The + * description is the operation's `summary`, falling back to `description`. + */ +export function renderEndpointTable(yamlSource?: string): string { + const source = yamlSource ?? readFileSync(sourcePath, 'utf8') + const document = (YAML.load(source) ?? {}) as SpecDocument + + const rows = ['| Method | Path | Description |', '|---|---|---|'] + for (const [path, pathItem] of Object.entries(document.paths ?? {})) { + if (!pathItem || typeof pathItem !== 'object') continue + for (const method of HTTP_METHODS) { + const operation = pathItem[method] as Operation | undefined + if (!operation || typeof operation !== 'object') continue + const description = operation.summary ?? operation.description ?? '' + rows.push(`| ${method.toUpperCase()} | \`${path}\` | ${escapeCell(description)} |`) + } + } + + return rows.join('\n') +} + +/** + * Returns the table currently committed between the README markers. + * + * Throws when either marker is missing or reversed, so a mangled README fails + * the generator loudly instead of silently appending a second table. + */ +export function extractEndpointTable(readmeSource: string): string { + const start = readmeSource.indexOf(README_TABLE_START) + const end = readmeSource.indexOf(README_TABLE_END) + if (start === -1 || end === -1 || end < start) { + throw new Error( + `README.md is missing the generated endpoint table markers (${README_TABLE_START} … ${README_TABLE_END})`, + ) + } + return readmeSource.slice(start + README_TABLE_START.length, end).trim() +} + +/** Replaces the README's generated table with a fresh rendering. */ +export function renderReadme(readmeSource: string, yamlSource?: string): string { + const table = renderEndpointTable(yamlSource) + extractEndpointTable(readmeSource) // validate the markers before rewriting + const start = readmeSource.indexOf(README_TABLE_START) + README_TABLE_START.length + const end = readmeSource.indexOf(README_TABLE_END) + return `${readmeSource.slice(0, start)}\n${table}\n${readmeSource.slice(end)}` +} + +/** Regenerates `openapi.json` and the README endpoint table, in place. */ export function generateOpenApi(): void { writeFileSync(destinationPath, renderOpenApiJson()) + const readme = readFileSync(readmePath, 'utf8') + writeFileSync(readmePath, renderReadme(readme)) +} + +/** + * Lists committed artifacts that differ from a fresh generation. + * + * Empty means the tree is up to date. Used by `npm run openapi:check` in CI so + * a hand-edited spec or README fails the build instead of drifting. + */ +export function checkGeneratedArtifacts(): string[] { + const problems: string[] = [] + + if (renderOpenApiJson() !== readFileSync(destinationPath, 'utf8')) { + problems.push('openapi.json does not match openapi.yaml') + } + + const readme = readFileSync(readmePath, 'utf8') + try { + if (extractEndpointTable(readme) !== renderEndpointTable()) { + problems.push('README endpoint table does not match openapi.yaml') + } + } catch (error) { + problems.push(error instanceof Error ? error.message : String(error)) + } + + return problems } -// Run only when invoked directly (`npm run openapi:gen`), not when imported by -// the test. +// Run only when invoked directly (`npm run openapi:gen` / `openapi:check`), +// not when imported by the test. if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - generateOpenApi() - console.log('Generated openapi.json') + if (process.argv.includes('--check')) { + const problems = checkGeneratedArtifacts() + if (problems.length > 0) { + console.error(`Generated API artifacts are stale:\n - ${problems.join('\n - ')}`) + console.error('Run `npm run openapi:gen` and commit the result.') + process.exit(1) + } + console.log('openapi.json and the README endpoint table are up to date') + } else { + generateOpenApi() + console.log('Generated openapi.json and the README endpoint table') + } } diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index 39f8918f..bcae7657 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -5,10 +5,11 @@ import type { FastifyInstance, RouteOptions } from 'fastify' import YAML from 'js-yaml' import { buildApp } from '../src/app' import { INTERNAL_ROUTES, isInternalRoute, toOpenApiPath } from '../src/openapi/coverage' -import { renderOpenApiJson } from '../scripts/generate-openapi' +import { extractEndpointTable, renderEndpointTable, renderOpenApiJson } from '../scripts/generate-openapi' const specPath = fileURLToPath(new URL('../openapi.yaml', import.meta.url)) const jsonPath = fileURLToPath(new URL('../openapi.json', import.meta.url)) +const readmePath = fileURLToPath(new URL('../README.md', import.meta.url)) interface RegisteredRoute { method: string @@ -100,3 +101,25 @@ describe('generated openapi.json', () => { expect(renderOpenApiJson()).toBe(renderOpenApiJson()) }) }) + +describe('generated README endpoint table', () => { + it('matches the committed README', () => { + const readme = readFileSync(readmePath, 'utf8') + expect(extractEndpointTable(readme)).toBe(renderEndpointTable()) + }) + + it('lists every operation in the spec exactly once', () => { + const readme = readFileSync(readmePath, 'utf8') + const rows = extractEndpointTable(readme) + .split('\n') + .filter(line => /^\| (GET|POST|PUT|PATCH|DELETE) \|/.test(line)) + + const specOperations = Object.entries(spec.paths).flatMap(([path, operations]) => + Object.keys(operations) + .filter(method => method !== 'parameters') + .map(method => `${method.toUpperCase()} ${path}`), + ) + + expect(rows).toHaveLength(specOperations.length) + }) +})