Routing
Ada provides a flexible HTTP routing system that supports static routes, parameterized routes, and wildcard matching.
The routing system doesn't use http Method, so you can use any method you want.
Route Patterns
Within a single path segment, the router prefers the most specific alternative:
- Static routes - Exact matches are tried first
- Parameterized routes - Routes with path parameters
- Wildcard routes - Routes with
*or{name...}captures
server.GET("/users/new", newUserForm) // tried first
server.GET("/users/{id}", getUser) // then this
server.GET("/users/*", catchAllUsers) // then thisA request to /users/new matches the static route, not the parameterized one.
How a path is matched
The ordering is a preference, not an irrevocable choice. The router follows the most specific branch first. If that branch fails at a later segment, it backtracks to the nearest untried parameterized or wildcard alternative:
server.GET("/foo/bar", handlerA)
server.GET("/{x}/baz", handlerB)
// GET /foo/bar -> handlerA
// GET /foo/baz -> handlerB with x = "foo"This also lets a static alias coexist with a parameterized subtree:
server.GET("/users/me", currentUser)
server.GET("/users/{id}/posts", userPosts)
// GET /users/me -> currentUser
// GET /users/42/posts -> userPosts, id = "42"
// GET /users/me/posts -> userPosts, id = "me"The result does not depend on registration order. A trailing * or {name...} is also remembered as a fallback while more specific branches are tried, which makes SPA and proxy catch-alls work as expected.
Static Routes
Static routes match exact path segments:
server.GET("/", homeHandler)
server.GET("/about", aboutHandler)
server.GET("/api/v1/status", statusHandler)Parameterized Routes
Parameterized routes capture a single path segment and make it available via r.PathValue():
server.GET("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
fmt.Fprintf(w, "User ID: %s", userID)
})
server.GET("/posts/{postID}/comments/{commentID}", func(w http.ResponseWriter, r *http.Request) {
postID := r.PathValue("postID")
commentID := r.PathValue("commentID")
fmt.Fprintf(w, "Post: %s, Comment: %s", postID, commentID)
}){name} matches exactly one segment. It does not cross / boundaries — use the greedy {name...} form below when you need that.
Wildcard Routes
Ada has two wildcard forms:
| Form | Position | Matches | Access via |
|---|---|---|---|
* | Middle or trailing (1 per route) | One segment if middle; the rest if trailing, including an empty segment after / | r.PathValue("*") |
{name...} | Trailing only | The rest of the path (including /), or an empty segment after / | r.PathValue("name") |
{name...} is just a named alias for a trailing * — same matching, only the PathValue key differs. Use it when a descriptive name reads better than "*", especially in routes that already have another capture.
// Trailing `*` — anonymous greedy capture.
server.GET("/files/*", func(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("*")
// GET /files/a/b/c.txt → rest == "a/b/c.txt"
})
// Middle `*` — exactly one segment, does not cross `/`.
server.POST("/api/v1/external/*/test", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("*")
// POST /api/v1/external/myname/test → name == "myname"
// POST /api/v1/external/a/b/test → 404
})
// Trailing `{name...}` — named greedy capture.
server.GET("/files/{path...}", func(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
// GET /files/a/b/c.txt → path == "a/b/c.txt"
// GET /files/ → path == ""
// GET /files → 404 (separator required)
// GET /files// → 404 (see below)
// GET /files/a//b → path == "a//b" (raw tail, kept verbatim)
})Empty segments never start a capture
Ada applies no path cleaning, and an empty segment matches nothing — not a {name} param, not a wildcard. The only exception is the empty final segment a trailing slash supplies (/files/ → path == ""). A remainder that begins with another / (/files//, /files//a) is therefore a 404, where net/http.ServeMux would have cleaned or matched it. Once the capture has started, though, the tail is taken verbatim — /files/a//b → "a//b".
HandleFuncWildcard and HandleWildcard add the trailing wildcard for you. Passing "/assets" registers "/assets/*"; "/assets/" matches with an empty wildcard value, while "/assets" does not because it has no separator. Add a separate exact handler when the slashless base path should also resolve:
server.HandleFunc("/assets", assetsHandler) // slashless base path
server.HandleFuncWildcard("/assets", assetsHandler) // /assets/ and descendantsCombining captures
Stack any number of {name} params; the wildcard rules only apply to * and {name...}. Each capture lives under its own key.
// Middle `{name}` + named greedy trailing.
server.GET("/users/{name}/files/{path...}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") // e.g. "alice"
path := r.PathValue("path") // e.g. "docs/note.md"
})
// Multiple single-segment params + greedy trailing.
server.GET("/orgs/{org}/users/{user}/files/{path...}", h)Validation rules
Ada panics at registration time on ambiguous patterns — bad routes fail loud at boot:
- At most one
*per route. Both would map toPathValue("*"). Use{name}for the other capture. - At most one
{name...}, and it must be the last raw segment. A greedy match consumes the rest of the path by definition, so it can't sit in the middle (the matcher would have nothing to stop against), can't appear twice (the first one already ate everything), and can't be followed by a trailing slash (which would silently demote it to a single-segment match).
server.GET("/a/*/b/*", h) // panics: more than one '*'
server.GET("/a/{x...}/b", h) // panics: greedy must be trailing
server.GET("/a/{x...}/", h) // panics: no trailing slash after a greedyA trailing slash after a middle * stays valid — "/a/*/" is "one segment, then /", exactly like "/a/*/x" is "one segment, then /x".
Path Handling And Captured Values
Captured values are attacker-controlled
Ada matches on the decoded r.URL.Path exactly as received. It does not run path.Clean and it does not redirect, unlike net/http.ServeMux. Every value returned by r.PathValue — and greedy captures especially — is raw attacker input. Validate or clean it before using it as a filesystem path, a key, a redirect target, or anything else with authority.
net/http.ServeMux cleans the request path and answers with a 301 to the canonical form. Ada deliberately does neither: the path you registered is the path that is matched, so proxies, signed URLs, and pass-through handlers see byte-identical paths. The cost is that normalisation is your responsibility.
Traversal segments reach your handler
. and .. are ordinary path characters to the router. They are not resolved, not rejected, and — because r.URL.Path is already percent-decoded — an encoded ..%2f is indistinguishable from a literal ../ by the time matching happens:
server.GET("/static/{path...}", func(w http.ResponseWriter, r *http.Request) {
p := r.PathValue("path")
// GET /static/../../etc/passwd → p == "../../etc/passwd"
// GET /static/..%2f..%2fetc/passwd → p == "../../etc/passwd"
})Both requests reach the handler with a 200. Handing p to os.Open, filepath.Join, or http.ServeFile without checking is a directory traversal.
Clean and confine the value before it touches the filesystem:
server.GET("/static/{path...}", func(w http.ResponseWriter, r *http.Request) {
// Anchor at "/" so ".." can never climb above the root, then trim it.
clean := strings.TrimPrefix(path.Clean("/"+r.PathValue("path")), "/")
f, err := root.Open(clean) // root is an *os.Root or fs.FS
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
// ...
})Prefer os.OpenRoot (Go 1.24+) or an fs.FS rooted at the directory you intend to serve: they enforce the boundary in the kernel/VFS layer instead of relying on string hygiene. Ada's own handler/folder already does this — reach for it before hand-rolling a file server.
%2F cannot be captured by a {name} param
Because matching runs on the decoded path, a percent-encoded slash becomes a real separator before the router sees it. A single-segment param can therefore never contain a /, encoded or not:
server.GET("/users/{id}", getUser)
// GET /users/a%2Fb → decoded to /users/a/b → 404, not id == "a/b"If an identifier can legitimately contain /, do not put it in a path segment. Use a query parameter, a request body, or an encoding without / (base64url, hex) instead. The same applies to %2E%2E, which decodes to .. and is matched as such.
Note also that empty segments are preserved: /users//a is not folded to /users/a and will 404 against /users/{id}.
Route Groups
Route groups allow you to organize routes with common prefixes and middleware:
server := ada.New()
// Create an API v1 group
apiV1 := server.Group("/api/v1")
apiV1.GET("/users", getUsers)
apiV1.POST("/users", createUser)
apiV1.DELETE("/users/{id}", deleteUser)
// Create an admin group with audit middleware
admin := server.Group("/admin", auditMiddleware)
admin.GET("/dashboard", adminDashboard)
admin.GET("/users", adminUsers)
admin.POST("/users/{id}/ban", banUser)Groups can be nested for more complex organization:
api := server.Group("/api")
v1 := api.Group("/v1")
v1.GET("/users", getUsersV1)
v2 := api.Group("/v2")
v2.GET("/users", getUsersV2)HTTP Methods
Ada supports all standard HTTP methods through dedicated methods:
server := ada.New()
server.GET("/users", getUsers)
server.POST("/users", createUser)
server.PUT("/users/{id}", updateUser)
server.PATCH("/users/{id}", patchUser)
server.DELETE("/users/{id}", deleteUser)
server.HEAD("/users/{id}", headUser)
server.OPTIONS("/users", optionsUsers)
server.TRACE("/trace", traceHandler)
server.CONNECT("/connect", connectHandler)
server.QUERY("/search", searchHandler)The QUERY method is a safe and idempotent HTTP method that carries the query in the request body (RFC 10008 - The HTTP QUERY Method). Since net/http does not define a constant for it yet, ada exposes ada.MethodQuery.
Other not standard HTTP methods can be added with HandleWithMethod
server.HandleWithMethod("FOO", "/foo", fooHandler)Method-Agnostic Routing
You can also register handlers that respond to any HTTP method:
server.HandleFunc("/health", healthCheck)
server.Handle("/static", http.FileServer(http.Dir("./static")))Automatic HEAD and OPTIONS
Ada automatically handles HEAD and OPTIONS requests:
- HEAD: If a GET handler is registered, HEAD requests are served by the same handler. Go's
http.ResponseWriterautomatically suppresses the response body. Explicit HEAD handlers take priority. - OPTIONS: Returns
204 No Contentwith anAllowheader listing available methods (e.g.Allow: GET, HEAD, OPTIONS, POST). Explicit OPTIONS handlers take priority.
405 Method Not Allowed
When a request matches a path but not any registered method, Ada returns 405 Method Not Allowed with an Allow header listing the available methods:
GET /users → 200 OK
POST /users → 405 Method Not Allowed (Allow: GET, HEAD, OPTIONS)
GET /nonexistent → 404 Not FoundCustom 404 Handler
You can set a custom handler for routes that don't match:
server := ada.New()
server.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "Page not found: %s", r.URL.Path)
})Custom 405 Handler
You can set a custom handler for method-not-allowed responses. The Allow header is always set before the handler is called:
server := ada.New()
server.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte(`{"error":"method not allowed","allow":"` + w.Header().Get("Allow") + `"}`))
})