StatsHub Docs

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

PathWhat it is
/api/v2/...The described surface. Same handlers, typed request and response.
/api/v2/openapi.jsonThe document. Also served as .yaml.
/api/v2/docsRendered reference.
apps/statshub-api/docs/openapi.jsonThe same document, checked in, so a change to the API shows up as a diff in review.
/docs/api/referenceThis 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, FailText and Text call, its status constant, and the Go type of its body. A map[string]any literal 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* and httpx.*Param accessor a handler calls is the only evidence of what it accepts, so the declared type follows the accessor. IntParam means an integer; the JSNumber family means a string, because it reproduces JavaScript's Number() including the literal "NaN".
  • Request bodies. The struct each httpx.DecodeBody decodes 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 type262
Raw bytes (XML sitemaps, CSV, event streams)15
Open map[string]any6
json.RawMessage15

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-props and /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.ts turns each operation's summary — a Go doc comment, "AdminBotRoutingGet serves GET /api/admin/bot-routing." — into the route, and declares the top-level tags the document does not carry. No schema, parameter or status code is touched, and /openapi.json is 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 changed

openapi: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.

On this page