Verified Commit ba606c78 authored by Kai Armstrong's avatar Kai Armstrong
Browse files

feat: add an API route registry with Routes and MatchRoute

Callers that want to label or group requests by endpoint have no way to
recover the route from a concrete path. The templates exist in the client
but withPath interpolates and discards them, so nothing survives to
runtime.

This adds the machinery only; converting the call sites is a separate
commit so this one stays reviewable.

route() registers a template and returns it unchanged, so it can be
assigned to a package-level variable and still used as a format string.
Registering as the package loads, rather than when a call is made, is
what lets Routes() describe the whole client instead of only the calls
that have happened.

  Routes() []Route             every route, parameters replaced by :id
  MatchRoute(path) (Route, ok) the route a concrete path belongs to

Matching prefers a literal segment over a parameter at each position,
mirroring how the API routes, so "users/some-username/keys" resolves to
/users/:id/keys rather than the username bein...
parent 9d478f8e
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -127,6 +127,9 @@ func withMethod(method string) doOption {
	}
}

// withPath sets the request path. The path must be a route variable declared
// with [route] rather than a string literal, so that every route this client
// can call is registered and reported by [Routes] and [MatchRoute].
func withPath(path string, args ...any) doOption {
	return func(c *doConfig) error {
		as := make([]any, len(args))

route.go

0 → 100644
+196 −0
Original line number Diff line number Diff line
package gitlab

import (
	"regexp"
	"slices"
	"strings"
	"sync"
)

// paramPlaceholder stands in for every path parameter. The client does not
// know the documented names for them, so they all share one placeholder.
const paramPlaceholder = ":id"

var verbPattern = regexp.MustCompile(`%[sdv]`)

// Route is an API route this client knows how to call, with its parameters
// replaced by a placeholder, for example "/projects/:id/merge_requests/:id".
//
// Routes are registered when the package is loaded, so [Routes] and
// [MatchRoute] describe the whole client, not just the calls made so far.
//
// Note: This API is experimental and may change or be removed in
// future versions.
type Route struct {
	path string
}

// String returns the route with its parameters replaced by a placeholder.
//
// Note: This API is experimental and may change or be removed in
// future versions.
func (r Route) String() string {
	return r.path
}

var (
	registryMu sync.Mutex

	// Keyed by normalized path, so templates differing only in their verbs
	// register as one route.
	registry = map[string]Route{}

	// Built on first use, once every route has registered.
	matcher = sync.OnceValue(buildMatcher)
)

// route registers a template and returns it unchanged, so it can be assigned to
// a package-level variable and still used as a format string:
//
//	var routeProjectsIDIssues = route("projects/%s/issues")
//
// Registering at package level rather than at call time is what lets [Routes]
// report routes that have never been called.
func route(template string) string {
	normalized := normalizeTemplate(template)

	registryMu.Lock()
	defer registryMu.Unlock()
	registry[normalized] = Route{path: normalized}

	return template
}

// normalizeTemplate replaces each parameter with paramPlaceholder. A verb
// embedded in a segment, as in "archive%s", is dropped so the literal part
// still identifies the route.
func normalizeTemplate(template string) string {
	segments := strings.Split(strings.Trim(template, "/"), "/")
	for i, segment := range segments {
		if verbPattern.MatchString(segment) {
			if verbPattern.FindString(segment) == segment {
				segments[i] = paramPlaceholder
			} else {
				segments[i] = verbPattern.ReplaceAllString(segment, "")
			}
		}
	}

	return "/" + strings.Join(segments, "/")
}

// Routes returns every API route this client knows how to call, sorted by path.
//
// Parameters are replaced by a placeholder, so the result is a bounded set of
// route shapes rather than concrete paths. It is suitable for grouping or
// labeling requests, such as reporting which endpoints an application uses
// without recording the identifiers in them.
//
// Note: This API is experimental and may change or be removed in
// future versions.
func Routes() []Route {
	registryMu.Lock()
	defer registryMu.Unlock()

	routes := make([]Route, 0, len(registry))
	for _, r := range registry {
		routes = append(routes, r)
	}

	slices.SortFunc(routes, func(a, b Route) int {
		return strings.Compare(a.path, b.path)
	})
	return routes
}

// MatchRoute reports the route a concrete API path belongs to, for example
// "projects/278964/merge_requests/1/notes" matches
// "/projects/:id/merge_requests/:id/notes".
//
// A literal segment is preferred over a parameter at every position, mirroring
// how the API itself routes. The path is matched case-insensitively and may be
// given with or without a leading slash. Query strings and fragments are not
// accepted; trim them first.
//
// The second return value reports whether a route matched. Paths this client
// cannot call do not match, which includes endpoints the API serves but the
// client has not implemented.
//
// Note: This API is experimental and may change or be removed in
// future versions.
func MatchRoute(path string) (Route, bool) {
	return matcher().match(splitPath(path))
}

func splitPath(path string) []string {
	path = strings.Trim(path, "/")
	if path == "" {
		return nil
	}
	return strings.Split(path, "/")
}

// routeNode indexes routes by path segment, preferring literal children over
// the parameter child.
type routeNode struct {
	children map[string]*routeNode
	param    *routeNode
	route    Route
	terminal bool
}

func buildMatcher() *routeNode {
	registryMu.Lock()
	defer registryMu.Unlock()

	root := &routeNode{}
	for _, r := range registry {
		root.insert(r)
	}
	return root
}

func (n *routeNode) insert(r Route) {
	node := n
	for segment := range strings.SplitSeq(strings.Trim(r.path, "/"), "/") {
		if segment == paramPlaceholder {
			if node.param == nil {
				node.param = &routeNode{}
			}
			node = node.param
			continue
		}

		if node.children == nil {
			node.children = map[string]*routeNode{}
		}
		child, ok := node.children[segment]
		if !ok {
			child = &routeNode{}
			node.children[segment] = child
		}
		node = child
	}

	node.route = r
	node.terminal = true
}

func (n *routeNode) match(segments []string) (Route, bool) {
	if len(segments) == 0 {
		return n.route, n.terminal
	}

	head, rest := strings.ToLower(segments[0]), segments[1:]

	if child, ok := n.children[head]; ok {
		if r, matched := child.match(rest); matched {
			return r, true
		}
	}
	if n.param != nil {
		return n.param.match(rest)
	}

	return Route{}, false
}

route_test.go

0 → 100644
+158 −0
Original line number Diff line number Diff line
package gitlab

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestNormalizeTemplate(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		template string
		want     string
	}{
		{name: "no parameters", template: "merge_requests", want: "/merge_requests"},
		{name: "leading slash is optional", template: "/user", want: "/user"},
		{name: "string parameter", template: "projects/%s/issues", want: "/projects/:id/issues"},
		{name: "integer parameter", template: "runners/%d", want: "/runners/:id"},
		{name: "value parameter", template: "projects/%v/foo", want: "/projects/:id/foo"},
		{
			name:     "several parameters",
			template: "projects/%s/merge_requests/%d/notes/%d",
			want:     "/projects/:id/merge_requests/:id/notes/:id",
		},
		{
			// The suffix is an optional archive format, so the literal part
			// still identifies the route.
			name:     "parameter embedded in a segment",
			template: "projects/%s/repository/archive%s",
			want:     "/projects/:id/repository/archive",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got := normalizeTemplate(tt.template)

			assert.Equal(t, tt.want, got)
			assert.NotContains(t, got, "%", "no formatting verb may survive normalization")
		})
	}
}

// newTestMatcher builds a matcher over the given templates, so matching can be
// exercised independently of which routes the package happens to register.
func newTestMatcher(t *testing.T, templates ...string) *routeNode {
	t.Helper()

	root := &routeNode{}
	for _, tmpl := range templates {
		root.insert(Route{path: normalizeTemplate(tmpl)})
	}
	return root
}

func TestRouteNodeMatch(t *testing.T) {
	t.Parallel()

	templates := []string{
		"merge_requests",
		"projects/%s/issues",
		"projects/%s/merge_requests/%d/notes",
		"projects/%s/repository/branches",
		"users/%s/keys",
	}

	tests := []struct {
		name string
		path string
		want string
		ok   bool
	}{
		{name: "no parameters", path: "merge_requests", want: "/merge_requests", ok: true},
		{
			name: "numeric identifier",
			path: "projects/278964/issues",
			want: "/projects/:id/issues",
			ok:   true,
		},
		{
			name: "encoded project path",
			path: "projects/gitlab-org%2Fcli/issues",
			want: "/projects/:id/issues",
			ok:   true,
		},
		{
			name: "several parameters",
			path: "projects/1/merge_requests/2/notes",
			want: "/projects/:id/merge_requests/:id/notes",
			ok:   true,
		},
		{
			// A literal segment wins over the parameter branch, so a username
			// cannot be mistaken for a route noun.
			name: "identifier that looks like a noun",
			path: "users/some-username/keys",
			want: "/users/:id/keys",
			ok:   true,
		},
		{
			name: "literal preferred over parameter",
			path: "projects/1/repository/branches",
			want: "/projects/:id/repository/branches",
			ok:   true,
		},
		{name: "case insensitive", path: "Projects/1/Issues", want: "/projects/:id/issues", ok: true},
		{name: "unknown endpoint", path: "projects/1/not_a_real_resource", ok: false},
		{name: "prefix of a known route", path: "projects/1", ok: false},
	}

	matcher := newTestMatcher(t, templates...)

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got, ok := matcher.match(splitPath(tt.path))

			assert.Equal(t, tt.ok, ok)
			assert.Equal(t, tt.want, got.String())
		})
	}
}

func TestRouteRegistersAndReturnsTemplateUnchanged(t *testing.T) {
	t.Parallel()

	// GIVEN a template registered through route
	template := route("test_only/%s/registration_check")

	// THEN it is returned unchanged, so it can still be used as a format string
	require.Equal(t, "test_only/%s/registration_check", template)

	// AND it is reported by the registry in its normalized form. Routes reads
	// the registry directly, so this does not freeze the matcher before the
	// package's own routes have registered.
	var registered bool
	for _, r := range Routes() {
		if r.String() == "/test_only/:id/registration_check" {
			registered = true
		}
	}
	assert.True(t, registered, "route must register the template")
}

func TestMatchRouteRejectsEmptyPaths(t *testing.T) {
	t.Parallel()

	for _, path := range []string{"", "/"} {
		_, ok := MatchRoute(path)
		assert.False(t, ok, "path %q must not match", path)
	}
}