On this page

Versioning example

Two complementary API-versioning strategies on the same router: path-based (/api/v1/..., /api/v2/...) and header-based (Accept: application/vnd.muxmaster+json;v=N). The example pairs both strategies with nested groups for an admin section and the opt-in PoolRequestBundle so every dispatch — including the three-level-deep /api/v2/admin/users/:id/audit — runs at ~45 ns / 0 B / 0 allocs.

Step 1 — Construct the router and enable maximum performance

Group composition has zero runtime cost in MuxMaster: middleware is wrapped at route-registration time, not on every request. A three-level-deep group dispatches at the same cost as a flat route. Enabling PoolRequestBundle on top recycles the per-request bundle through sync.Pools, eliminating the routing allocation entirely.

mux := mm.New()

// Maximum performance: every parameterised route under /v1, /v2, /admin
// dispatches at ~45 ns / 0 B / 0 allocs.
mux.PoolRequestBundle = true

The lifetime contract for PoolRequestBundle (handlers must not retain *http.Request past return) is documented in Maximum performance. This example never spawns goroutines from its handlers, so the contract holds trivially.

Step 2 — Wire cross-cutting Pre middleware

Pre middleware wraps the whole dispatch — it runs once per request, before routing, so any work it does (request-ID generation, panic recovery, version negotiation) is free in the per-route cost. The version-dispatch hook rewrites the URL path in place before the radix tree gets to look at it.

mux.Pre(
    mw.RequestID(),
    mw.RecovererWithLogger(log),
    acceptHeaderVersionDispatch, // header-based routing for /api/...
)

The order matters: RequestID first, so every downstream log line and panic trace carries the correlation header; RecovererWithLogger next, so the version-dispatch hook itself is protected; the version-negotiation last, so it never runs on a request that has already panicked.

Step 3 — Path-based versioning with Group

Two top-level groups expose /api/v1/... and /api/v2/... directly. Each route in a group dispatches at the same cost as a top-level route — the prefix is folded into the radix tree at registration time.

v1 := mux.Group("/api/v1")
v1.GET("/users/:id", v1GetUser)
v1.GET("/users", v1ListUsers)
v1.GET("/health", v1Health)

v2 := mux.Group("/api/v2")
v2.GET("/users/:id", v2GetUser)
v2.GET("/users", v2ListUsers)
v2.GET("/health", v2Health)

The v1* and v2* handlers are independent — v2GetUser returns a HATEOAS shape with _links, while v1GetUser returns the flat shape v1 consumers expect. Schema diverges; routing does not.

Step 4 — Nested groups for an admin section

Group returns a *Mux, so any group can spawn its own sub-groups with their own middleware stack. Stdlib middleware applied at the group level wraps every route under it at registration time, so the cost is paid once on boot and never on a request.

v1Admin := v1.Group("/admin")
v1Admin.Use(adminMiddleware) // applied once at reg, free at runtime
v1Admin.GET("/dashboard", v1AdminDashboard)
v1Admin.GET("/users/:id/audit", v1AdminAudit)

v2Admin := v2.Group("/admin")
v2Admin.Use(adminMiddleware)
v2Admin.GET("/dashboard", v2AdminDashboard)
v2Admin.GET("/users/:id/audit", v2AdminAudit)

adminMiddleware is a toy X-Admin-Token: letmein gate — replace with real authentication (mw.JWTAuth, mw.OAuth2Introspect, or mw.APIKey) in production.

Step 5 — Header-based versioning via a Pre-positioned URL rewrite

The header-based pattern is a Pre middleware that rewrites /api/... to /api/vN/... based on the request's Accept header before the radix tree dispatches it. The rewrite happens once per request, in Pre, so it does not appear on the per-route hot path at all.

func acceptHeaderVersionDispatch(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Only apply to /api/ paths that DON'T already have a /vN/ prefix.
        if !strings.HasPrefix(r.URL.Path, "/api/") {
            next.ServeHTTP(w, r)
            return
        }
        rest := r.URL.Path[len("/api/"):]
        if strings.HasPrefix(rest, "v1/") || strings.HasPrefix(rest, "v2/") {
            next.ServeHTTP(w, r)
            return
        }

        // Parse `Accept: ...;v=N` — default to v1 if not specified.
        v := "1"
        if a := r.Header.Get("Accept"); strings.Contains(a, "v=2") {
            v = "2"
        }
        // Rewrite r.URL.Path in place. The bundle copy is mutable; the
        // original request is never modified.
        r.URL.Path = "/api/v" + v + "/" + rest
        next.ServeHTTP(w, r)
    })
}

The two strategies coexist on the same router. A client that pins /api/v1/users/42 gets v1; a client that calls /api/users/42 with Accept: application/vnd.muxmaster+json;v=2 gets v2; a client that calls /api/users/42 with no Accept header gets v1.

Try it

# Path-based
curl http://localhost:8080/api/v1/users/42
curl http://localhost:8080/api/v2/users/42

# Header-based (rewrites to /api/v2/users/42 in Pre)
curl -H 'Accept: application/vnd.muxmaster+json;v=2' http://localhost:8080/api/users/42

# Admin (gated by X-Admin-Token)
curl -H 'X-Admin-Token: letmein' http://localhost:8080/api/v1/admin/dashboard
curl -H 'X-Admin-Token: letmein' http://localhost:8080/api/v2/admin/dashboard

Frequently asked questions

How do I add a v3 alongside v1 and v2?

Create a new top-level group: v3 := mux.Group("/api/v3") and register the v3 handlers on it. Extend the acceptHeaderVersionDispatch Pre middleware so v=3 rewrites to /api/v3/.... The radix tree absorbs the third version at zero per-request cost — group composition is wrapped at registration, not on dispatch.

What happens if a request has both a /v2/ path and Accept: ...;v=1?

The path wins. acceptHeaderVersionDispatch short-circuits when r.URL.Path already starts with /api/v1/ or /api/v2/ (Step 5), so a request to /api/v2/users/42 with Accept: application/vnd.muxmaster+json;v=1 dispatches to the v2 handler. Path-pinned URLs are always authoritative; the Accept header only acts when the client did not pin a version.

Is the in-place r.URL.Path rewrite safe with PoolRequestBundle?

Yes. Under PoolRequestBundle = true, r is the bundle's copy of the request — mutating r.URL.Path modifies the bundle, not the caller's original *http.Request. The mutation is local to the dispatch and discarded when the bundle is returned to the pool. The lifetime contract still applies: the handler must not retain r past return.

Upstream source

Every code excerpt above is lifted verbatim from examples/versioning/main.go at the v1.1.0 tag. The upstream file also contains the full handler set (v1GetUser, v1ListUsers, v2GetUser with HATEOAS links, the two admin dashboards, two audit endpoints) and the graceful-shutdown wiring — follow the link for the full program.

See also