Repository navigation
test(openapi): enforce that the published spec covers every public route - #179
Conversation
openapi.yaml documented only a handful of the routes the server registers but is republished to GitHub Pages on every push, so the gap silently widened into a wrong public contract. Extract the Fastify wiring into buildApp() so a test can boot the real route surface, document the missing public routes with params, the shared ?network= query and response schemas, and route the operator-only and non-HTTP endpoints through an explicit, commented allow-list. The generator is now importable and deterministic, and the committed openapi.json is asserted against it.
|
@Emmo00 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
Hey @Miracle656 This is ready. Would you like me to make any changes? |
Resolve the README and OpenAPI spec conflicts: keep the branch's fuller route documentation (unique operationIds, the shared ?network= query, response schemas) and fold main's ?network= note into the endpoint table.
|
Hi @Miracle656 I fixed the merge conflict |
Miracle656
left a comment
There was a problem hiding this comment.
The architecture here is right and I want to say that up front: extracting buildApp() so the coverage test enumerates the real route surface instead of a hand-maintained list is the correct call, the INTERNAL_ROUTES allow-list with a reason per entry (plus the stale-entry assertion) is exactly the shape that stops rot, and making renderOpenApiJson() importable so a test pins the committed JSON to the YAML closes the generated/source drift that would otherwise reappear on the next spec edit. Going from 7 documented paths to 25 on a spec that auto-publishes to Pages is a real improvement.
Diff size, split as requested
| lines | |
|---|---|
Generated — openapi.json (+2115/-332) |
2,447 |
Human — openapi.yaml (+1293/-186) |
1,479 |
Human — code/tests/docs (src/app.ts 150, src/index.ts 121, tests/openapi.test.ts 81, src/openapi/coverage.ts 80, scripts/generate-openapi.ts 39, README.md 23, changeset 11) |
505 |
| Human total | 1,984 |
So ~55% of the +3791/-640 is the regenerated JSON. I reviewed the 1,984 human lines and diffed src/app.ts against the block it replaces in src/index.ts line by line: the plugin/hook/route order is identical, including the /status onRoute override landing after rateLimit, and the only addition is the optional onRoute hook registered first so it observes encapsulated plugins. No behaviour change — confirmed.
Verification
Merged onto current origin/main (clean, no conflict in openapi.json), then: npx tsc --noEmit clean, full suite 544 passed, 1 skipped, 57 files. The one failure was tests/openapi.test.ts:75 ("matches the committed file") and it is not your bug — my checkout has core.autocrlf=true, so readFileSync('openapi.json') returns 2,856 CRLFs while renderOpenApiJson() emits LF. Normalising line endings locally: npx vitest run tests/openapi.test.ts → 6 passed. CI is green. (Worth hardening anyway — see the last note.)
What needs changing: the spec documents things that aren't true
The whole value of this PR is that the published contract matches reality, so these matter more here than they would anywhere else.
1. /screener documents a parameter that is guaranteed to 400. openapi.yaml declares a market_cap query parameter and lists market_cap in the sortBy enum. On the merge-base you branched from (0995f8a), .changeset/screener-drop-market-cap.md is already present and src/routes/screener.ts:37-39 rejects both:
if (q.market_cap !== undefined || q.sortBy === 'market_cap') {
return reply.status(400).send({ error: MARKET_CAP_UNSUPPORTED })
}with the sort allowlist at :41 being ['volume', 'change_24h', 'price', 'liquidity']. It was removed precisely because it was liquidity under another name — publishing it back into the contract reintroduces the thing that changeset went out of its way to kill. Drop the parameter and the enum member. (Your ScreenerResponse/ScreenerRow schemas are correct and already omit it — nice catch there.)
2. ?network= is declared on five routes that ignore it, and missing from six that honour it. This is the one I'd most want fixed, because on a dual-network price API a documented-but-ignored network selector is how a caller ends up believing a mainnet number that came from testnet rows.
Declares NetworkQuery, handler never reads req.network:
/basket—src/routes/basket.ts:5-13,fetchAssetVWAPfilters only onasset_a/asset_band a 5-minute window, nonetworkpredicate/benchmark/{asset}—src/routes/benchmark.ts, nonetworkreference at all/compare/{asset}—src/routes/oracle.ts:6, same/price/twap/{assetA}/{assetB}and/price/vwap/{assetA}/{assetB}—src/routes/price.ts; the handlers parseTwapQuerySchema/VwapQuerySchema(non-strictz.object, sonetworkis silently stripped, not rejected) and buildpairKey = [assetA, assetB].sort().join('/')with no network and no issuer
Honours network but the spec omits NetworkQuery:
/price/{assetA}/{assetB}(src/api/rest.ts,req.network ?? activeNetwork→getAggregatedPrice(pairKey, network))/price/{assetA}/{assetB}/route(same, passed intogetBestRoute)/pools,/pairs(GET),/status— all three passnetworkas a query parameter/prices/history—src/api/history.ts:43,WHERE pair = AND network =
/price/{assetA}/{assetB}/depth, /screener, /spreads/{asset}, /volumes/{asset} are correct as documented.
Two legitimate ways to resolve: add NetworkQuery to the six that honour it and remove it from the five that don't, or keep it everywhere and open a follow-up issue for the five route-side gaps (they're real bugs — /basket pooling testnet and mainnet rows into one weighted total is the same class of problem #181 is addressing on that route). Either is fine; what I don't want to publish is the current mix, where a client cannot tell from the spec which endpoints are network-scoped. Please say in the PR body which you chose.
3. The coverage test is one-directional. It fails when a registered route is missing from the spec, but not when the spec documents a path or method that no longer exists — which is structurally the same hazard, and is how #1 above could sit in the YAML unnoticed. The inverse is about four lines on top of what you already have:
it('documents only routes that exist', () => {
const registeredPaths = new Set(registered.map(r => r.path))
const phantom = Object.keys(spec.paths).filter(p => !registeredPaths.has(p))
expect(phantom, `Documented but unregistered: ${JSON.stringify(phantom)}`).toEqual([])
})Worth adding while you're in here; it makes the test a real two-way contract instead of a coverage floor.
Smaller
README.md: the endpoint table now lists/price/twap/:assetA/:assetBand/price/vwap/:assetA/:assetBtwice — the original short rows are still there above your new rows with the query strings. Delete the originals.tests/openapi.test.ts:75compares raw bytes, so it fails for any contributor on Windows withcore.autocrlf=true(it did for me). Either normalise in the assertion (.replace(/\r\n/g, '\n')on both sides) or, better, add a.gitattributeswith*.json text eol=lf/*.yaml text eol=lf— the repo has none today. Your call which; a spec-equality test that depends on the reviewer's git config will waste someone an afternoon.
#1 and #2 are the blockers and both are confined to openapi.yaml plus a regenerate, so this should be a quick turnaround. The buildApp() extraction and the test harness I'd merge as-is.
/screener no longer documents market_cap — parameter, sortBy enum member
or response field — now that the route rejects it with a 400 and Lens has
no circulating-supply data to compute it from.
?network= is documented only on the routes that honour it: added to
/price/{assetA}/{assetB}, /price/{assetA}/{assetB}/route, /pools, /pairs
and /prices/history, removed from /basket, /benchmark/{asset},
/compare/{asset} and the TWAP/VWAP routes, which ignore the selector.
/status is deliberately left undocumented: it reports this instance's
active network, and documenting a selector it ignores would be the same
hazard as the market_cap entry. /prices/history also now documents the
network field it actually returns.
The coverage test checks both directions — a documented path or operation
with no registered route now fails too, so a stale entry can no longer sit
in the YAML unnoticed. Added .gitattributes forcing LF on json/yaml so the
openapi.json byte-equality test no longer depends on a contributor's git
config.
Smaller — removed the two duplicate twap / vwap rows in the README and corrected its "every route accepts ?network= " claim; added .gitattributes ( *.json / *.yaml / *.yml → eol=lf ) so the openapi.json byte-equality test no longer depends on a contributor's git config. |
#179 left /status out of the spec's network-scoped routes, correctly for the base it branched from: on 0995f8a the handler was `async () => {}` and read no network at all. #193 (61c1854) landed after that merge base and made it network-scoped — it now reads `req.network`, filters `indexer_state` on it and echoes the network back — so the parameter has to be documented or the spec understates what the route does. Also completes the README's per-request list, which had been missing /pairs, /spreads/:asset and /volumes/:asset as well as /status, and points it at openapi.yaml as the source of truth so the two cannot drift apart silently. openapi.json regenerated with `npm run openapi:gen`.
|
Re-reviewed at 1.
|
| route | where it reads it |
|---|---|
GET /price/{a}/{b} |
src/api/rest.ts:92 |
GET /price/{a}/{b}/route |
src/api/rest.ts:135 |
GET /price/{a}/{b}/depth |
src/api/rest.ts:236 |
GET /pools |
src/api/rest.ts:200 |
GET /pairs |
src/routes/pairs.ts:21 |
GET /prices/history |
src/api/history.ts:112 |
GET /screener |
src/routes/screener.ts:73 |
GET /spreads/{asset} |
src/routes/spreads.ts:89 |
GET /volumes/{asset} |
src/routes/volumes.ts:45 |
And the five I asked you to strip are stripped: /basket, /benchmark/{asset}, /compare/{asset}, /price/twap/{a}/{b}, /price/vwap/{a}/{b} all declare no network now. I also checked the two routes neither of us listed last time — /candles/{a}/{b} has no network reference anywhere in src/routes/candles.ts, and /price/{a}/{b}/history queries price_aggregates on pair_key alone (src/api/rest.ts:166) — and the spec correctly omits it from both. The network parameter on /discovery/resources is the Bazaar catalog filter rather than NetworkQuery, it has its own description explaining the CAIP-2 form, and parseDiscoveryFilters → resolveNetworkFilter genuinely honours it. Correct as documented.
Adding the network field to the /prices/history response schema was a good catch on your own account — the route returns it and the spec didn't say so.
3. The one-directional coverage test — addressed, more thoroughly than I suggested
You added both halves rather than the single path-level check I sketched: documents only paths that are actually registered and documents only operations that are actually registered. The second one is the version that would actually have caught the /screener problem's sibling — a documented POST on a path that only registers GET — and it's the one I'd have forgotten. With allow-lists only routes that are actually registered alongside it, the test is now a genuine two-way contract in all three directions.
Smaller notes — both addressed
- The duplicate
twap/vwaprows are gone from the README table, and the table gained the ten routes it had been missing entirely (/depth,/candles,/volumes,/spreads,/compare,/screener,/benchmark,/basket,/usage/me, the facilitator and webhook routes,/graphql). The "Every route accepts?network=" claim is replaced with an accurate one. .gitattributeswith*.json/*.yaml/*.yml→eol=lfis the right fix, and I verified it does the job: my checkout is stillcore.autocrlf=true, and after the mergeopenapi.jsonin the working tree has 0 CRLF sequences, sotests/openapi.test.ts's byte-equality assertion passes without any local normalisation. That is the first time this repo's spec test has been green on a Windows checkout.
Verification
Merged onto origin/main (clean, no conflict), then: npx tsc --noEmit clean, full suite 56 files passed / 1 skipped (57), 547 tests passed / 1 skipped (548) — up from 539 on main, which is your eight new tests. tests/openapi.test.ts on its own: 8 passed. CI on the branch is green.
The one thing I changed: /status (585c15b)
Your reasoning for leaving /status undocumented was right for the tree you branched from — on 0995f8a the handler is literally async () => {}, reads nothing from the request and filters indexer_state on nothing. But 61c1854 ("fix: scope /status to a network and record the real indexer ledger", #193) landed on main after your d308dba merge of main, and it rewrote that handler:
app.get('/status', { config: { public: true }, ... }, async (req) => {
const network = req.network ?? activeNetwork
const result = await pgPool.query(`… FROM indexer_state WHERE network = $1 …`, [network])
return { ok: true, network, watchedPairs: getNetworkConfig(network).pairs.map(…), … }So on current main /status does read req.network, filters on it, and echoes the chosen network back — and your base is five commits behind that. Your response schema for /status is already correct for the new shape (it documents network and ingestLagSeconds, which only #193 added), so it was just the parameter missing. I added the NetworkQuery $ref and regenerated openapi.json; tests/openapi.test.ts still passes 8/8.
I also finished the README's "Per-request today" list in the same commit — it predates this PR and was already missing /pairs, /spreads/:asset and /volumes/:asset, so it needed /status plus those three — and pointed it at openapi.yaml as the source of truth, since the whole point of this PR is that there now is one.
Worth noting what that near-miss says about the design you built: a parameter being declared on the wrong set of routes is still invisible to the coverage test, which checks paths and methods but not parameters. Not something to fix now, and for most parameters a test like that would be more trouble than it's worth — but network specifically is the one where a wrong answer is a wrong price, so if you ever want a fourth assertion, "every route whose handler reads req.network declares NetworkQuery, and no other route does" is the one I'd write. It would have caught this on its own.
Two real route-side bugs surfaced while I was checking, neither yours and neither in scope: /price/{a}/{b}/history reads price_aggregates filtered on pair_key alone, so on an instance with ENABLED_NETWORKS covering both chains its OHLCV buckets interleave testnet and mainnet rows — which is what your README note already says, and it is the same class as the /basket problem #181 is on. /candles looks the same. I'll open issues for both.
Thanks for this one. Going from 7 documented paths to 25 on an auto-published spec, with a test that keeps it honest in both directions, is the kind of change that stops a whole category of bug rather than one instance of it.
Summary
openapi.yamldocumented 7 paths while the server registers ~32, and the spec is auto-published to GitHub Pages on every push — so Lens was shipping a public contract that omitted two thirds of its surface, with nothing to stop the gap widening.This PR makes spec coverage enforceable and fills the gap:
src/index.tsintobuildApp()(src/app.ts) sotests/openapi.test.tscan register every plugin/hook/route exactly as production does — no duplicated route list to drift. The test enumerates registered routes and fails if any non-internal path or method is missing fromopenapi.yaml. It failed on 17 routes before the spec was filled in.openapi.yaml—/price/*/depth,/price/twap|vwap/...,/volumes/{asset},/spreads/{asset},/compare/{asset},/screener,/benchmark/{asset},/basket,/discovery/resources,/webhooks,/usage/me,/supported,/verify,/settle,/graphql— each with parameters, a sharedcomponents/parameters/NetworkQuery(?network=) where it applies, and an explicit response schema.src/openapi/coverage.ts(/admin/keys*,/admin/usage*,/metrics,/ws,/graphiql*) instead of silently skipping them. The test also asserts the allow-list has no stale entries.renderOpenApiJson()/generateOpenApi()), with a test asserting the committedopenapi.jsonmatches its output (and that two runs agree byte-for-byte).Related issue
Closes #176
Type of change
Checklist
npx tsc --noEmitpassesnpm run buildpassesNotes
npx tsc --noEmitexits 0;npx vitest rungives 406 passed / 1 skipped across 48 files (previously 400 passed / 1 skipped — +6 from the new suite)./discovery/resources'snetworkquery param is documented on its own rather than viaNetworkQuery: it filters catalog entries (accepting CAIP-2 ids) and has different semantics from the shared Stellar network selector.src/index.tskeeps the DB/Redis connections, migrations and ingesters, and only delegates app construction tobuildApp()