-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.cssAt 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.
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.
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 |
- The path exists under other methods → 405, with an
Allowheader naming them, so the client can correct itself. - The path exists nowhere → 404.
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.
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.
Two mechanisms, because one is not enough:
- A declared
Content-Lengthover the cap is rejected without reading a byte — a clean413, carryingmax_bytesso a client that knows the limit can chunk or compress rather than guess. -
http.MaxBytesReadercovers 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.
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/usersA request outside the base path is a 404, answered through the middleware chain so CORS can still decorate it.
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.
rex — Restful Extended eXperience · MIT · © 2026 Kryovyx · pre-1.0 (alpha), interfaces may change
Getting started
Core
Operating
Ecosystem