feat: register API routes and expose Routes and MatchRoute

What does this MR do and why?

Adds a route registry to the client, so callers can recover the route a concrete API path belongs to:

gitlab.Routes()                                       // every route this client can call
gitlab.MatchRoute("projects/278964/merge_requests")   // -> "/projects/:id/merge_requests", true

The templates already exist in the client, but withPath interpolates and discards them, so nothing survives to runtime. Every template is now assigned to a package-level variable through route(), which registers it as the package loads. Registering at load time rather than at call time is what lets Routes() describe the whole client rather than only the calls that have been made.

Matching prefers a literal segment over a parameter at each position, mirroring how the API itself routes, so users/some-username/keys resolves to /users/:id/keys rather than the username being mistaken for a route noun. Unknown paths don't match, which includes endpoints the API serves that this client hasn't implemented.

Where this came from

@timofurrer proposed it in gitlab-org/cli!3824 (merged). The GitLab CLI wants to report which endpoints glab api is used against, without recording the identifiers in the paths, and had resorted to generating a route table by scraping withPath literals out of the vendored source. A registry in the client removes the need for that entirely, and is useful to anyone labelling or grouping requests by endpoint.

The conversion

Mechanical: 702 templates across 168 files, zero name collisions. Names are derived from the path, with each parameter as ID:

routeProjectsIDMergeRequestsIDNotes = route("projects/%s/merge_requests/%d/notes")

Three call sites were normalised so they could share a route with their siblings:

  • AddSSHKeyForUser now passes UserID{user} like ListSSHKeysForUser, so both use users/%s/keys. parseID accepts int64, so this is behaviour-preserving.
  • DeleteRegisteredRunnerByID now passes RunnerID{rid} like the other three calls in runners.go, replacing a pre-formatted fmt.Sprintf("runners/%d", rid).
  • Archive keeps its optional format suffix (archive%s), which normalises to /projects/:id/repository/archive.

TestWithPathUsesRegisteredRoutes parses the package and fails if withPath is ever given a string literal, so a new endpoint can't be added without registering it. I verified it bites by reintroducing one.

Experimental

Route, Route.String, Routes and MatchRoute all carry the same Note: This API is experimental... the Orbit service uses, so the contract isn't treated as stable while the shape is still being agreed.

Three decisions worth your view

1. MatchRoute(path) rather than MatchRoute(method, path). You suggested including the method. A route variable is shared across HTTP methods — projects/%s/issues/%d is used by GET, PUT and DELETE — so the method isn't a property of the route and can't come from the registry as designed. Supporting it would mean registering (method, path) pairs at each call site instead of one variable per path. Happy to change it, but it's a different shape.

2. All variables in one routes.go rather than beside each service. Four routes are used from two files each, so per-file placement needs an ownership rule. A single sorted list also doubles as a manifest of the API surface. Straightforward to switch if you'd prefer locality.

3. Enforcement is a test, not the type system. route() returns string so withPath is unchanged. A named string type wouldn't help, since an untyped constant would still satisfy it — hence the AST test.

Known gap

Seven paths bypass withPath and so aren't registered: orbit/schema/dsl, orbit/query, groups/import, the two GraphQL calls (empty path), and the generic_packages and repositories archive URLs built with fmt.Sprintf. I left them rather than widen the diff. Worth either converting them or extending the enforcement test to NewRequest, depending on how complete you want Routes() to be.

How has this been tested?

New tests cover normalizeTemplate (every verb form, embedded verbs, slashes), Routes() (non-empty, normalised, sorted, unique), and MatchRoute (numeric and encoded identifiers, nested parameters, case-insensitivity, literal-beats-parameter, unknown and empty paths).

mise exec -- make lint clean, mise exec -- make test passes on all 6 packages with race detection, mise exec -- make generate produces no drift.

Worth noting the existing suite earned its keep here: it caught invalid URL escape "%!s" when the runner call site was first pointed at a %s route while still passing a raw int64.

Edited by Kai Armstrong

Merge request reports

Loading
Loading