Skip to content

Routing

wiki edited this page Sep 4, 2026 · 1 revision

Routing

app.RegisterRoute(route.New("GET", "/users/{id}", getUser))

A route is a method, a path pattern and a handler. Registration returns an error — check it.

Path patterns

Paths are split on / and matched segment by segment through a trie.

Segment Matches Read with
users exactly users
{id} any single segment ctx.Param("id")
* this segment and everything after it ctx.Param("*")
route.New("GET", "/users", listUsers)            // /users
route.New("GET", "/users/{id}", getUser)         // /users/42        → id=42
route.New("GET", "/users/{id}/posts/{postID}", …) // two parameters
route.New("GET", "/static/*", serveFiles)        // /static/css/a.css → *=css/a.css

Precedence and backtracking

At each segment the router tries static, then parameter, then wildcard — and a branch that matches the current segment but fails deeper unwinds so the next kind is tried.

That matters more than it sounds:

registered:  /a/b/c   and   /a/{x}/d
request:     /a/b/d

The static child b matches at segment two. Without backtracking the parameter branch is never considered and the request 404s, even though /a/{x}/d matches it exactly. The failure depends on which routes are registered rather than on the request, which is why this class of bug survives tests that register only one of the two routes.

Parameters are captured only on the successful path, so a branch that is tried and abandoned leaves nothing behind.

Duplicate routes are rejected

Registering the same method and path twice is an error, reported from Run:

duplicate route GET /metrics (already registered)

Silent overwrite meant the second registration won and the first vanished with no diagnostic — including when two extensions both claimed /metrics or /healthz.

The handler context

func getUser(ctx route.Context) {
	var svc *UserService
	if err := ctx.Resolver().Resolve(&svc); err != nil { … }

	user, err := svc.Find(ctx, ctx.Param("id"))
	if err != nil {
		rextension.WriteProblem(ctx.ResponseWriter(), ctx.Request(),
			404, rextension.ProblemNotFound, "no such user")
		return
	}
	_ = ctx.JSON(200, user)
}

route.Context is a context.Context, so it can be passed straight to anything that takes one — and it carries the request's deadline and cancellation.

Method
Param(name) a captured path parameter, "" when absent
Resolver() the request scope — see Dependency Injection
Request() / ResponseWriter() the raw pair, for anything the helpers do not cover
JSON / Text / OpenMetrics / Respond write a response
SetValue / GetValue stash a value without rebuilding the request

What the router answers on its own

404 vs 405 — the distinction the router owes a client

  • The path exists under other methods405, with an Allow header naming them, so the client can correct itself.
  • The path exists nowhere404.

Both are RFC 9457 problem documents. The 405 also carries the allowed methods as an extension member, so a client parsing the body does not have to read headers to correct itself:

{
  "type": "urn:rex:problem:method-not-allowed",
  "title": "Method Not Allowed",
  "status": 405,
  "detail": "this method is not supported for this path",
  "allowed_methods": ["GET", "PUT", "OPTIONS"]
}

The previous implementation returned 405 whenever the router had no trie for the request's method at all — so a GET-only router answered 405 for every POST, to every path, including paths that do not exist. And a path that existed under another method got 404, which is exactly backwards.

The 404 detail is deliberately uninformative: "path not under base URL /api" tells a prober how the application is mounted. The real reason is logged.

OPTIONS

The router answers OPTIONS itself from the Allow set — 204 No Content with an Allow header — unless the application registered its own OPTIONS route, in which case that route wins.

rextension-cors decorates this response rather than registering competing OPTIONS routes, which is why preflights and CORS headers agree with the routes that actually exist.

Body limits

Two mechanisms, because one is not enough:

  1. A declared Content-Length over the cap is rejected without reading a byte — a clean 413, carrying max_bytes so a client that knows the limit can chunk or compress rather than guess.
  2. http.MaxBytesReader covers everything else — chunked bodies, and clients that under-declare. The read fails once the cap is passed, so a handler decoding the body gets an error instead of buffering whatever the client cared to send.

The router default is 4 MiB; a route overrides it by implementing BodyLimitedRoute. See TLS and Listener Limits.

Base paths

A router's BaseURL prefixes every route on it. It is normalised — no trailing slash, "" for the root — and stripped before matching, so route patterns are written without it:

app.CreateRouter("api", rex.RouterConfig{Addr: ":8080", BaseURL: "/api/v1"})
app.RegisterRouteToRouter(route.New("GET", "/users", listUsers), "api")
// served at /api/v1/users

A request outside the base path is a 404, answered through the middleware chain so CORS can still decorate it.

The matched route, from middleware

rt, ok := rxroute.GetMatchedRoute(req)

The router stores the matched route on the request context before invoking the handler, so middleware can reach it without re-parsing the URL. Prefer deciding at build time with per-route middleware; use this when the decision genuinely depends on the request.

Clone this wiki locally