feat(api): aggregate statistics contract (S17 Phase 8 plan: 1/13)
Why
S17 Phase 8 adds four read-only statistics endpoints. Nothing can be built
against them until the contract declares them: each handler step drains its arm
out of the contract sweep's pending set, and the sweep reads its arms out of
api/openapi/v1.yaml. This is Step 1 of the merged plan, carrying the three
aggregate scopes. Step 2 carries the version scope.
Plan: docs/plans/2026-08-12-s17-phase8-statistics.md,
### Step 1: Contract: aggregate statistics. Spec:
docs/specs/S17-rest-management-api.md, Statistics (Phase 8), AC #111 (closed).
What (the non-obvious parts)
The format scope is four literal paths, not one {format} parameter. A
wildcard there also matches /api/v1/:slug/repositories/statistics, which the
repository-detail pattern matches too. Neither is the more specific, and
net/http.ServeMux panics at registration on exactly that pair (reproduced
against the live pattern set: Go names /api/v1/slug/repositories/statistics as
the overlap). The four operations share one FormatStatistics schema. The cost
is on the client side, worth knowing before an SDK is generated: four literal
paths give four methods rather than one taking a format, and a fifth format
arrives as a new endpoint rather than a new enum value.
That shape also leaves the format vocabulary in path strings, where no parameter
enum constrains it, so a fifth format would widen FormatStatistics.format with
no path able to produce the value.
TestContract_FormatStatisticsPaths_CoverEveryFormat closes it both ways.
The three RepositoryStatistics branches close additionalProperties.
npm's field set is Maven's plus dist_tags_count, so an open Maven branch
matches every npm payload and no npm response validates. Settings is the same
case in this document and closes for the same reason. dist_tags_count is
required for the other direction: optional, it would leave every Maven payload
matching both package branches. The branch is decided by the field set alone,
which is what the payload test pins.
Every figure carries minimum: 0. None can be negative, so a negative value
is a counter underflow and the response schema is where it gets refused.
Version.size is the precedent for the byte figures. The repository resource's
own counters carry no floor, which the schema comment records as an omission
rather than a decision.
One transitional window opens here and Step 3 closes it. No statistics route
is registered yet, so all six paths reach no route. A client sees an S01 envelope
carrying not_found, with the interceptor's generic message rather than the slug
miss's, because server.ResponseInterceptorMiddleware rewrites the mux's
text/plain 404. Only the bare handler the contract sweep drives sees text/plain,
which is how the sweep spots a declared-but-unserved operation. Step 3 registers
the routes as 501 placeholders.
That correction is a deliberate departure from the spec, which asserts the
text/plain reply in five places (prose, API Contracts, AC #118 (closed), AC #121 (closed), and the
Error Cases row) and builds the no-envelope-fallback rationale on it. The
rationale survives. The claim about what a client sees does not.
docs/dev/api-style.md makes this document authoritative on conflict, so the
tree is right and the spec needs its own correction MR. A fix that adds an
envelope fallback to the /api/v1/ subtree would break the contract sweep for
every pending operation.
The step added a test file the plan does not list.
internal/managementapi/contract_statistics_test.go is new scope: the plan's
Step 1 Tests line asks only for the amended pending set, but all five prior
contract-declaration steps on this surface shipped document-level tests, and
nothing else can catch a field added to one branch that leaves another format's
payload matching no branch at all. Step 2 will want to extend it with the
version scope, and the plan's Shared seams entry does not record it yet.
Diff size
1,161 reviewable LOC, past the 500 guideline, in two roughly equal halves that resist splitting for different reasons.
| File group | LOC |
|---|---|
api/openapi/v1.yaml (6 operations, 9 schemas, 1 tag) |
517 |
internal/managementapi/contract_statistics_test.go |
484 |
api/bruno/management-api/statistics/ (7 new files) |
139 |
docs/dev/bruno.md |
12 |
internal/managementapi/contract_routes_test.go |
9 |
The contract cannot be split from its schemas: the paths $ref them, and
no-unresolved-refs is an error in api/openapi/redocly.yaml, so a paths-only
MR fails lint:openapi (measured: removing the nine schemas produces six
errors, one per ref site). The Bruno requests have to land with the contract per
AGENTS.md, and the version scope is already Step 2. The test half could ship
separately, but it would then guard a contract that had already merged
unguarded, which is the window it exists to close.
Test plan
go test ./internal/managementapi/ -count=1is green, and green under-race. The full suite passes on the rebased tree.- The operation sweep skips the six new arms while they answer the mux's
404, and fails if one starts serving without its entry dropped, or if an entry names an arm the document does not declare. The second direction was observed: the entries were committed ahead of the declarations and failed six times. - Every new assertion was checked against a mutated document rather than
trusted: renaming a format path fails the enum coverage both ways, inlining
FormatStatistics.formatfails the$refpin, adding a query parameter fails the no-parameter assertion, dropping oneminimum: 0fails the floor walk, and opening the Maven branch makes the npm payload match two branches. npx @redocly/cli@2.34.0 lint --config api/openapi/redocly.yamlreports both documents valid, at the versionlint:openapipins.
No e2e catalog scenario is added or affected. The statistics consumers are
monolith UI slices, and nothing under docs/testing/ mentions statistics.
Related to #316