Commit 9ac3ddad authored by Doug Barrett's avatar Doug Barrett
Browse files

feat(v2/httpserver): Switch to chi as the default router

Replace the stdlib net/http.ServeMux default with go-chi/chi, exposing
Chi's key features through the Router interface while keeping Chi types
out of the public API (design requirement from team-tasks#4283).

Router interface changes:
- Add method-specific registration: Get, Post, Put, Delete, Patch
- Add route grouping: Group (scoped middleware), Route (path prefix)
- Add sub-router mounting: Mount
- Add per-route middleware: With
- Add error handler customization: NotFound, MethodNotAllowed
- Add URLParam helper for path parameter extraction

Architecture changes:
- Default Router backed by chi.Mux (wrapped in unexported chiRouter)
- Built-in middleware (tracing, logging) registered via Use before user
  routes, running inside chi's routing context for access to route
  patterns
- Health endpoints (/-/liveness, /-/readiness) handled by Server.ServeHTTP
  directly, outside the user's route tree
- Tracing middleware uses chi.RouteContext for low-cardinality span names
  (e.g. "GET /users/{id}" instead of "GET /users/42")
- Compile-time app.Component assertion added

Removed:
- routerbench standalone benchmark module (Chi/Gorilla/stdlib comparison
  served its purpose during the investigation phase)
parent a1da0c7a
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ module gitlab.com/gitlab-org/labkit/v2
go 1.24.0

require (
	github.com/go-chi/chi/v5 v5.2.5
	github.com/google/uuid v1.6.0
	github.com/prometheus/client_golang v1.23.2
	github.com/stretchr/testify v1.11.1
+2 −0
Original line number Diff line number Diff line
@@ -6,6 +6,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+33 −13
Original line number Diff line number Diff line
@@ -4,15 +4,14 @@ allowing it to be plugged into an [app.App] and have its lifecycle managed
alongside the logger and tracer.

The server exposes a [Router] interface for route and middleware registration.
The default implementation uses Go's standard [net/http.ServeMux] (Go 1.22+
pattern matching), but a custom [Router] can be injected via [Config.Router]
to swap in a different framework (Chi, Gorilla, etc.) without changing
consumer code.
The default implementation uses [chi] internally, but a custom [Router] can be
injected via [Config.Router] to swap in a different framework without changing
consumer code. Chi types never appear in the public API.

Two built-in middleware layers are included:

  - Tracing: extracts incoming W3C trace context (traceparent / tracestate),
    creates a server span named "METHOD /path", and records the response
    creates a server span named "METHOD /pattern", and records the response
    status.
  - Logging: emits a structured log line per request with method, path, and
    status code.
@@ -28,7 +27,7 @@ Two built-in middleware layers are included:
		Tracer: a.Tracer(),
	})

	srv.Router().HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
	srv.Router().Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

@@ -37,13 +36,32 @@ Two built-in middleware layers are included:
	if err := a.Start(ctx); err != nil { log.Fatal(err) }
	defer a.Shutdown(ctx)

# Adding middleware
# Route groups and scoped middleware

Additional middleware can be registered on the router before Start is called.
Middleware uses the standard net/http signature so it works regardless of the
underlying router implementation:
Use [Router.Group] to share middleware across a set of routes without
affecting the parent router:

	srv.Router().Use(myAuthMiddleware)
	srv.Router().Group(func(r httpserver.Router) {
		r.Use(authMiddleware)
		r.Get("/api/users", listUsers)
		r.Post("/api/users", createUser)
	})

Use [Router.Route] to mount a sub-router at a path prefix:

	srv.Router().Route("/api/v2", func(r httpserver.Router) {
		r.Get("/projects", listProjects)
		r.Get("/projects/{id}", getProject)
	})

# Path parameters

Use [URLParam] to extract named path parameters from the request:

	srv.Router().Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
		id := httpserver.URLParam(r, "id")
		// ...
	})

# Custom router

@@ -51,7 +69,7 @@ To use a different router framework, implement the [Router] interface and
pass it via Config:

	srv := httpserver.NewWithConfig(&httpserver.Config{
		Router: myChiRouter,
		Router: myCustomRouter,
	})

# Testing
@@ -60,12 +78,14 @@ In tests, call Router().ServeHTTP directly to exercise handlers without
binding a real port:

	srv := httpserver.New()
	srv.Router().HandleFunc("/ping", pingHandler)
	srv.Router().Get("/ping", pingHandler)

	req := httptest.NewRequest(http.MethodGet, "/ping", nil)
	rec := httptest.NewRecorder()
	srv.Router().ServeHTTP(rec, req)

	assert.Equal(t, http.StatusOK, rec.Code)

[chi]: https://github.com/go-chi/chi
*/
package httpserver
+0 −10
Original line number Diff line number Diff line
@@ -34,16 +34,6 @@ func (s *Server) AddReadinessCheck(name string, fn CheckFunc) *Server {
	return s
}

// registerHealthRoutes attaches the default liveness and readiness endpoints
// to the router. Called once by NewWithConfig.
//
// The "GET " prefix in the pattern is Go 1.22+ method-matching syntax,
// restricting the handler to GET requests only.
func (s *Server) registerHealthRoutes() {
	s.router.HandleFunc("GET /-/liveness", s.livenessHandler)
	s.router.HandleFunc("GET /-/readiness", s.readinessHandler)
}

type healthResponse struct {
	Status string            `json:"status"`
	Checks map[string]string `json:"checks,omitempty"`
+11 −3
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import (
	"log/slog"
	"net/http"

	"github.com/go-chi/chi/v5"
	"go.opentelemetry.io/otel/propagation"

	"gitlab.com/gitlab-org/labkit/v2/trace"
@@ -38,8 +39,8 @@ func (rw *responseWriter) Unwrap() http.ResponseWriter {

// tracingMiddleware creates a server span for each request. It extracts any
// incoming W3C trace context so that the new span is correctly parented to the
// upstream caller's trace. The span name follows the convention
// "METHOD /path" using the request URL path.
// upstream caller's trace. The span name uses the route pattern (e.g.
// "GET /users/{id}") when available, falling back to "METHOD /path".
func tracingMiddleware(tracer *trace.Tracer) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -47,7 +48,8 @@ func tracingMiddleware(tracer *trace.Tracer) func(http.Handler) http.Handler {
			// that the new span is a child of the upstream caller's span.
			ctx := httpPropagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

			// Derive span name from the request method and path.
			// Start with a preliminary span name; we refine it after the
			// handler runs when the route pattern is known.
			spanName := r.Method + " " + r.URL.Path

			ctx, span := tracer.Start(ctx, spanName)
@@ -60,6 +62,12 @@ func tracingMiddleware(tracer *trace.Tracer) func(http.Handler) http.Handler {
			rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
			next.ServeHTTP(rw, r.WithContext(ctx))

			// Use the route pattern for a low-cardinality span name when the
			// chi route context is available.
			if rctx := chi.RouteContext(ctx); rctx != nil && rctx.RoutePattern() != "" {
				span.SetName(r.Method + " " + rctx.RoutePattern())
			}

			span.SetAttribute("http.status_code", rw.statusCode)
			if rw.statusCode >= 500 {
				span.RecordError(fmt.Errorf("HTTP %d %s", rw.statusCode, http.StatusText(rw.statusCode)))
Loading