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=1 is 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.format fails the $ref pin, adding a query parameter fails the no-parameter assertion, dropping one minimum: 0 fails 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.yaml reports both documents valid, at the version lint:openapi pins.

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

Merge request reports

Loading
Loading