OpenAPI and /api/v2
How the described API surface is generated from the Go handlers, what it recovers, and where it deliberately says nothing.
The StatsHub API has never had a version. Every route mounts under a bare
/api, no route carries a /v1, /api/health reports no build or API
version, and the repository has no release tags. That was not an oversight: the
Go API's contract is to be byte-compatible with the 218 Next.js routes in
apps/legacy/src/pages/api, and those had no version either.
That same contract is why the API could not be described. Faithfulness to the
originals means handlers write untyped bodies — 209 of the 254 explicit
httpx.JSON(w, http.StatusOK, ...) calls pass an anonymous map[string]any
literal. A generator reflecting over those types emits {"type": "object"}
with no properties, which is not a description of anything.
So the described surface is a second one. /api is unchanged and stays
byte-compatible. /api/v2 serves the same 298 operations through typed Go
structs, and the OpenAPI document is generated from those structs.
Where it lives
| Path | What it is |
|---|---|
/api/v2/... | The described surface. Same handlers, typed request and response. |
/api/v2/openapi.json | The document. Also served as .yaml. |
/api/v2/docs | Rendered reference. |
apps/statshub-api/docs/openapi.json | The same document, checked in, so a change to the API shows up as a diff in review. |
/docs/api/reference | This site's rendered reference, generated from that file. |
How the types are recovered
cmd/openapigen reads internal/router and internal/api with full
go/types information and writes internal/api/v2_gen.go. It is not a
comment scanner: nothing has to be annotated, and nothing can be annotated
wrongly.
It walks the router the way a reader does — r.Route pushes a path prefix,
mountEntities(r, h) is a jump, a verb call is a leaf — to recover all 298
routes. r.HandleFunc registers every method in chi, but these handlers were
ported from Next.js route files that rejected unwanted methods themselves, so
the generator reads the switch r.Method or != chain in the body to find the
methods each one actually serves. Otherwise v2 would advertise a DELETE that
returns 405.
For each handler it then resolves:
- Responses. Every
httpx.JSON,Message,Error,Fail,FailTextandTextcall, its status constant, and the Go type of its body. Amap[string]anyliteral becomes a named struct with the same keys. Two branches writing the same status are merged, and a key missing from either becomes optional. - Parameters. The
httpx.Query*andhttpx.*Paramaccessor a handler calls is the only evidence of what it accepts, so the declared type follows the accessor.IntParammeans an integer; theJSNumberfamily means a string, because it reproduces JavaScript'sNumber()including the literal"NaN". - Request bodies. The struct each
httpx.DecodeBodydecodes into.
It follows one-line handlers into their helpers, including generic ones, with
the type arguments substituted — which is what recovers the reference tables
routed through handleList[T] and handleFindOne[T], the sitemaps through
h.renderSitemap, and the favourites through h.listFavorites.
What it recovers, and what it does not
Of 298 operations:
| Count | |
|---|---|
| Named struct or concrete type | 262 |
| Raw bytes (XML sitemaps, CSV, event streams) | 15 |
Open map[string]any | 6 |
json.RawMessage | 15 |
The document has 218 paths and 391 component schemas.
The last two rows are deliberate. A handler that assembles a body across
branches the analyser cannot reconcile, or hands back a map built from computed
keys, has no single shape; writing a guess into the document would be worse
than leaving it open, because a wrong schema is believed. json.RawMessage
passes the bytes through untouched and describes them as unconstrained.
What stops the document drifting
Nothing in the compiler connects a declared response type to what a handler actually writes. A handler that starts returning a key its type does not have would drop that key silently, and the document would quietly become a lie.
packages/statshub-api-contract has recorded the real response shape of 156 requests
against the implementation these handlers were ported from.
internal/api/v2_conformance_test.go checks the declared type of every
operation against those recordings — 94 of them match a closed shape — and
fails on any key that has nowhere to land. Operations whose body is open are
skipped, because there is nothing to check them against.
That is the guarantee: the document cannot claim a field the handler stopped writing, and it cannot omit one the handler started writing, without a test going red.
How this site renders it
apps/statshub-docs reads a copy of the document at public/openapi.json and generates
one page per operation under content/docs/api/reference, grouped by tag. The
pages are not committed; scripts/openapi.ts rewrites them before dev and
build, and syncs the copy from apps/statshub-api/docs/openapi.json whenever that file
is reachable. The copy exists because the docs image is built from
turbo prune statshub-docs, which does not carry apps/statshub-api into the build
context.
Two differences between the document and the rendered reference are worth knowing:
- 293 pages, 298 operations. The renderer covers GET, POST, PUT, PATCH,
DELETE and HEAD. The five operations without a page are all
OPTIONS— the CORS preflights on/api/v2/mcp,/api/v2/mcp/sse,/api/v2/value-bets,/api/v2/value-bets-propsand/api/v2/value-bets-v2. The generator script prints them at the end of each run rather than dropping them silently. - Titles and tag names are presentation.
apps/statshub-docs/src/lib/openapi-document.tsturns each operation's summary — a Go doc comment, "AdminBotRoutingGet serves GET /api/admin/bot-routing." — into the route, and declares the top-leveltagsthe document does not carry. No schema, parameter or status code is touched, and/openapi.jsonis served as the API's own bytes.
Regenerating
cd apps/statshub-api
bun run openapi # regenerate v2_gen.go and docs/openapi.json
bun run openapi:check # the same, then fail if either changedopenapi:check is what belongs in CI. A route added without regenerating shows
up as a diff.
The generator analyses the package it writes into, so it swaps its own output
for a stub before loading and restores the previous file if the run fails —
a failed generation never leaves the package missing RegisterV2.
How v2 differs from /api
The two surfaces share one implementation: every v2 operation runs the same handler and decodes its output into the declared type. Three differences follow from that, and all of them are confined to v2.
Malformed typed parameters are rejected. GET /api/player/abc/performance
reaches the handler on /api, which coerces the way JavaScript did. On
/api/v2 it is a 422 before the handler runs, because the parameter is
declared an integer.
Errors use one model. /api writes four different envelopes — {message},
{error}, and {message, error} with error as either an object or a string,
a distinction internal/httpx documents as load-bearing for existing clients.
/api/v2 reports every failure as application/problem+json, carrying the
same text, so a client has one error shape to handle instead of four.
Bodies are re-serialised. v2 decodes into a struct and re-encodes, so key
order and formatting are its own. /api remains byte-for-byte what it was;
that is the whole reason the described surface is a separate one.