Semantic HTTP routing for Go.
dispatch provides named, reversible routes built on URI templates, deterministic multi-candidate selection, post-match constraints, canonical URL handling, route scoping, and full net/http compatibility.
r := dispatch.New()
r.GET("users.show", "/users/{id}",
http.HandlerFunc(showUser),
dispatch.WithConstraint(dispatch.Int("id")),
)
http.ListenAndServe(":8080", r)go get github.com/dhamidi/dispatchRequires Go 1.26+. The only external dependency is github.com/dhamidi/uritemplate.
Register routes by name, template, and handler. The router implements http.Handler.
package main
import (
"fmt"
"log"
"net/http"
"github.com/dhamidi/dispatch"
)
func main() {
r := dispatch.New()
err := r.GET("users.show", "/users/{id}",
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
m, _ := dispatch.MatchFromContext(req.Context())
fmt.Fprintf(w, "user=%s", m.Params["id"])
}),
dispatch.WithConstraint(dispatch.Int("id")),
)
if err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", r))
}Use the convenience methods for common HTTP methods:
r.GET("users.list", "/users", listHandler)
r.POST("users.create", "/users", createHandler)
r.PUT("users.update", "/users/{id}", updateHandler)
r.PATCH("users.patch", "/users/{id}", patchHandler)
r.DELETE("users.delete", "/users/{id}", deleteHandler)
r.OPTIONS("cors.preflight", "/users", preflightHandler)Each method accepts optional RouteOption values:
r.GET("users.show", "/users/{id}", showHandler,
dispatch.WithConstraint(dispatch.Int("id")),
dispatch.WithDefaults(dispatch.Params{"format": "html"}),
dispatch.WithCanonicalPolicy(dispatch.CanonicalRedirect),
dispatch.WithMetadata("section", "users"),
)For full control, register a Route struct directly:
r.Handle(dispatch.Route{
Name: "users.show",
Methods: dispatch.GET | dispatch.HEAD,
Template: tmpl,
Handler: showHandler,
})Constraints validate extracted parameters after matching. The first failing constraint eliminates a candidate route.
Built-in constraints:
| Constraint | Description |
|---|---|
Int(key) |
Parameter parses as a base-10 integer |
Exact(key, value) |
Parameter equals a specific value |
OneOf(key, values...) |
Parameter is one of the listed values |
Regexp(key, re) |
Parameter matches a compiled regular expression |
Host(host) |
Request host matches (case-insensitive) |
Methods(ms) |
Request method is in the given MethodSet |
Custom(fn) |
Arbitrary predicate function |
r.GET("articles.show", "/articles/{slug}",
showArticle,
dispatch.WithConstraint(dispatch.Regexp("slug", regexp.MustCompile(`^[a-z0-9-]+$`))),
)Scopes share name prefixes, template prefixes, defaults, constraints, and policies across a group of routes.
r.Scope(func(s *dispatch.Scope) {
s.GET("list", "/users", listHandler)
s.GET("show", "/users/{id}", showHandler)
s.POST("create", "/users", createHandler)
},
dispatch.WithNamePrefix("users"),
dispatch.WithTemplatePrefix("/api/v1"),
)
// Registers: users.list -> /api/v1/users
// users.show -> /api/v1/users/{id}
// users.create -> /api/v1/usersScopes nest. Inner values override outer values for defaults and metadata; constraints append outer-first.
r.Scope(func(api *dispatch.Scope) {
api.Scope(func(admin *dispatch.Scope) {
admin.GET("dashboard", "/dashboard", dashHandler)
// Registered as: api.admin.dashboard -> /api/admin/dashboard
}, dispatch.WithNamePrefix("admin"), dispatch.WithTemplatePrefix("/admin"))
}, dispatch.WithNamePrefix("api"), dispatch.WithTemplatePrefix("/api"))You can also use WithScope for a detached scope:
api := r.WithScope(
dispatch.WithNamePrefix("api"),
dispatch.WithTemplatePrefix("/api/v2"),
)
api.GET("health", "/health", healthHandler)Resource registers standard plural resource routes following Rails conventions. Only non-nil handlers are registered:
r.Resource("posts", dispatch.ResourceHandlers{
Index: http.HandlerFunc(listPosts),
Show: http.HandlerFunc(showPost),
Create: http.HandlerFunc(createPost),
Update: http.HandlerFunc(updatePost),
Destroy: http.HandlerFunc(deletePost),
})This registers the following routes:
| Method | Path | Route Name | Handler |
|---|---|---|---|
| GET | /posts | posts.index | Index |
| GET | /posts/new | posts.new | New |
| POST | /posts | posts.create | Create |
| GET | /posts/{id} | posts.show | Show |
| GET | /posts/{id}/edit | posts.edit | Edit |
| PUT, PATCH | /posts/{id} | posts.update | Update |
| DELETE | /posts/{id} | posts.destroy | Destroy |
Member routes (show, edit, update, destroy) automatically include an Int constraint on the ID parameter.
For a resource without a collection (no Index, no ID parameter), use SingularResource:
r.SingularResource("account", dispatch.ResourceHandlers{
Show: http.HandlerFunc(showAccount),
Update: http.HandlerFunc(updateAccount),
})This registers:
| Method | Path | Route Name | Handler |
|---|---|---|---|
| GET | /account/new | account.new | New |
| POST | /account | account.create | Create |
| GET | /account | account.show | Show |
| GET | /account/edit | account.edit | Edit |
| PUT, PATCH | /account | account.update | Update |
| DELETE | /account | account.destroy | Destroy |
Customize resource registration with ResourceOption values:
r.Resource("posts", handlers,
dispatch.WithParamName("post_id"), // use {post_id} instead of {id}
dispatch.WithExcludePATCH(), // Update matches PUT only
)Every route is reversible. Generate URLs from route names and parameters:
u, err := r.URL("users.show", dispatch.Params{"id": "42"})
// u.String() == "/users/42"
path, err := r.Path("search", dispatch.Params{"q": "golang", "page": "2"})
// path == "/search?q=golang&page=2"BindHelpers generates type-safe URL helper functions by binding struct fields to named routes. Define a struct with func fields tagged with route:"<name>", then call BindHelpers once at startup:
var urls struct {
UsersShow func(id int64) string `route:"users.show"`
Search func(q string, page int) string `route:"search"`
PostsIndex func() string `route:"posts.index"`
}
r.BindHelpers(&urls)
urls.UsersShow(42) // "/users/42"
urls.Search("golang", 1) // "/search?q=golang&page=1"
urls.PostsIndex() // "/posts"Function arguments are matched positionally to the route's template variables (path variables first, then query variables, in declaration order). Supported argument types: string, int, int64, int32, uint, uint64, uint32, float64, bool, and any type implementing fmt.Stringer.
Return type must be string or (string, error). Functions returning only string panic on generation failure; (string, error) functions return the error instead.
BindHelpers panics if the destination is not a struct pointer, a route tag references an unknown route, the argument count doesn't match the template variables, or an argument type is unsupported. Fields without a route tag and unexported fields are silently skipped.
After dispatch, route metadata is available through the request context:
func showUser(w http.ResponseWriter, req *http.Request) {
// Full match (route, params, canonical info)
m, ok := dispatch.MatchFromContext(req.Context())
// Just the params
params, ok := dispatch.ParamsFromContext(req.Context())
// Just the route name
name, ok := dispatch.RouteNameFromContext(req.Context())
}Use the Param* helpers to extract and convert route parameters directly from the request, instead of manually reading from the match context:
func showUser(w http.ResponseWriter, req *http.Request) {
id, err := dispatch.ParamInt(req, "id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "user=%d", id)
}Available extraction functions:
| Function | Return type | Description |
|---|---|---|
ParamString(r, name) |
(string, bool) |
Raw string value; false if missing |
ParamInt(r, name) |
(int, error) |
Base-10 integer |
ParamInt64(r, name) |
(int64, error) |
Base-10 int64 |
ParamFloat64(r, name) |
(float64, error) |
64-bit float |
ParamBool(r, name) |
(bool, error) |
Accepts true/false, 1/0, yes/no (case-insensitive) |
MustParamInt(r, name) |
int |
Panics on error |
MustParamInt64(r, name) |
int64 |
Panics on error |
ParamAs(r, name, dest) |
error |
Custom parsing via ParamValue interface |
When a route has a constraint that guarantees the parameter is valid (e.g. dispatch.Int("id")), use the Must variants to skip error handling:
r.GET("users.show", "/users/{id}",
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
id := dispatch.MustParamInt(req, "id") // safe — Int constraint already validated
fmt.Fprintf(w, "user=%d", id)
}),
dispatch.WithConstraint(dispatch.Int("id")),
)For custom types, implement ParamValue (String() string and Set(string) error, mirroring flag.Value) and use ParamAs. The String() method enables reverse routing / URL generation via BindHelpers:
type UserRole int
const (
RoleAdmin UserRole = iota
RoleEditor
)
func (r UserRole) String() string {
switch r {
case RoleAdmin:
return "admin"
case RoleEditor:
return "editor"
default:
return fmt.Sprintf("unknown(%d)", int(r))
}
}
func (r *UserRole) Set(raw string) error {
switch raw {
case "admin":
*r = RoleAdmin
case "editor":
*r = RoleEditor
default:
return fmt.Errorf("unknown role %q", raw)
}
return nil
}
func handleRole(w http.ResponseWriter, req *http.Request) {
var role UserRole
if err := dispatch.ParamAs(req, "role", &role); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "role=%d", role)
}Parse errors are returned as *ParamError, which wraps the underlying error and includes the parameter name and raw value. Missing parameters return ErrParamNotFound.
Set the query mode per route or as a router default:
// Ignore undeclared query params (default)
r.GET("search", "/search{?q}", handler, dispatch.WithQueryMode(dispatch.QueryLoose))
// Normalize canonical form for declared params
r.GET("search", "/search{?q,page}", handler, dispatch.WithQueryMode(dispatch.QueryCanonical))
// Reject requests with undeclared query params
r.GET("search", "/search{?q}", handler, dispatch.WithQueryMode(dispatch.QueryStrict))Control what happens when the request URL differs from the canonical form:
// Ignore differences (default)
dispatch.WithCanonicalPolicy(dispatch.CanonicalIgnore)
// Expose canonical data in Match but don't redirect
dispatch.WithCanonicalPolicy(dispatch.CanonicalAnnotate)
// Redirect to canonical URL (301 by default)
dispatch.WithCanonicalPolicy(dispatch.CanonicalRedirect)
// Reject non-canonical requests
dispatch.WithCanonicalPolicy(dispatch.CanonicalReject)Set the redirect status code:
r.GET("page", "/pages/{slug}", handler,
dispatch.WithCanonicalPolicy(dispatch.CanonicalRedirect),
dispatch.WithRedirectCode(http.StatusPermanentRedirect), // 308
)Control whether the router normalizes trailing slashes by redirecting to the alternate form when it matches a registered route:
r := dispatch.New(dispatch.WithDefaultSlashPolicy(dispatch.SlashRedirect))When SlashRedirect is enabled, requests to /users/ will 301-redirect to /users (or vice versa) if the alternate form matches a registered route. This is especially useful for parameterized routes — without normalization, a trailing slash can be absorbed into a path parameter (e.g., /posts/42/ matching {id} as "42/").
The default policy is SlashIgnore, which performs no trailing-slash normalization.
Pass these to dispatch.New():
| Option | Description | Default |
|---|---|---|
WithNotFoundHandler(h) |
Handler for unmatched requests | 404 text response |
WithMethodNotAllowedHandler(h) |
Handler for method mismatches | 405 text response |
WithErrorHandler(h) |
Handler for internal dispatch errors | nil |
WithDefaultQueryMode(m) |
Default QueryMode for all routes |
QueryLoose |
WithDefaultCanonicalPolicy(p) |
Default CanonicalPolicy for all routes |
CanonicalIgnore |
WithDefaultRedirectCode(code) |
Default redirect status code | 301 |
WithDefaultSlashPolicy(p) |
Trailing-slash normalization policy | SlashIgnore |
WithImplicitHEAD(bool) |
GET routes also match HEAD | true |
Pass these to r.GET(...) and other convenience methods:
| Option | Description |
|---|---|
WithDefaults(Params) |
Fallback values for absent template variables |
WithConstraint(Constraint) |
Append a single constraint |
WithConstraints(...Constraint) |
Append multiple constraints |
WithQueryMode(QueryMode) |
Override query handling for this route |
WithCanonicalPolicy(CanonicalPolicy) |
Override canonical behavior for this route |
WithRedirectCode(int) |
HTTP status for canonical redirects |
WithPriority(int) |
Explicit tie-breaker (higher wins) |
WithMetadata(key, value) |
Attach opaque metadata |
Pass these to r.Scope(...) or r.WithScope(...):
| Option | Description |
|---|---|
WithNamePrefix(prefix) |
Prepend prefix to route names (separated by .) |
WithTemplatePrefix(prefix) |
Prepend prefix to URI templates |
WithScopeDefaults(params) |
Merge default parameters into scoped routes |
WithScopeConstraint(c) |
Append constraint to all scoped routes |
WithScopeQueryMode(qm) |
Override query mode for scoped routes |
WithScopeCanonicalPolicy(cp) |
Override canonical policy for scoped routes |
WithScopeMetadata(key, value) |
Attach metadata to all scoped routes |
Scopes expose the same convenience methods as Router:
s.GET(name, tmpl, handler, opts...),s.POST(...),s.PUT(...),s.PATCH(...),s.DELETE(...)s.Handle(route Route) errors.Scope(fn func(*Scope), opts ...ScopeOption)— nested scoping
Pass these to r.Resource(...) and r.SingularResource(...):
| Option | Description | Default |
|---|---|---|
WithParamName(name) |
Change the ID parameter name in member routes | "id" |
WithExcludePATCH() |
Exclude PATCH from the Update action (PUT only) | both PUT and PATCH |
MethodSet is a bitfield representing a set of HTTP methods. Individual methods (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, CONNECT) are constants that can be combined with bitwise OR.
| Method / Constructor | Signature | Description |
|---|---|---|
Has |
(ms MethodSet) Has(other MethodSet) bool |
Reports whether ms includes every method in other |
String |
(ms MethodSet) String() string |
Pipe-separated list of methods (e.g. "GET|HEAD"); empty set returns "<none>" |
MethodFromString |
MethodFromString(method string) (MethodSet, error) |
Convert a standard HTTP method string to its MethodSet bit (case-sensitive) |
MethodSetFrom |
MethodSetFrom(methods ...string) (MethodSet, error) |
Convert one or more method name strings to a combined MethodSet (case-insensitive) |
Params is a map[string]string holding route parameters.
| Method | Signature | Description |
|---|---|---|
Get |
(p Params) Get(key string) string |
Returns the value for key, or "" if missing |
Lookup |
(p Params) Lookup(key string) (string, bool) |
Returns value and whether the key was present |
Clone |
(p Params) Clone() Params |
Returns a shallow copy; mutations do not affect the original |
Registration errors:
ErrEmptyRouteName— route name is emptyErrDuplicateRoute— route name already registeredErrNilTemplate— template is nilErrNilHandler— handler is nil
Matching errors:
ErrNotFound— no route matchesErrMethodNotAllowed— URL matches but method does not
Method parsing errors:
*MethodError— unrecognised HTTP method name (returned byMethodSetFrom)
Parameter extraction errors:
ErrParamNotFound— named parameter not present in the match context*ParamError— parameter value could not be parsed (wraps the underlying error, includes parameter name and raw value)
Generation errors:
ErrUnknownRoute— route name not foundErrMissingParam— required template variable not provided
// Look up a single route by name
route, ok := r.Route("users.show")
// List all registered routes
routes := r.Routes()
// Match a request programmatically without dispatching
m, err := r.Match(req)
if err != nil {
// handle ErrNotFound or ErrMethodNotAllowed
}
fmt.Println(m.Name, m.Params)When multiple routes match a request, the router selects the best candidate deterministically using these criteria (in order):
- More literal path segments
- More constrained parameters
- Fewer broad/wildcard expansions
- More declared query matches
- Higher explicit priority
- Earlier registration order (final tie-breaker)
Every route has a unique, stable name (e.g. users.show). Names decouple URL generation from URL structure. Change a template from /users/{id} to /u/{id} and all generated URLs update automatically — no grep-and-replace needed.
RFC 6570 URI templates provide a single representation that works for both matching inbound requests and generating outbound URLs. This eliminates the class of bugs where match patterns and URL builders diverge.
Constraints are evaluated after template extraction, keeping the template clean and the validation composable. You can combine Int("id") with Host("api.example.com") without encoding either concern into the URL pattern.
Register all routes during startup, then treat the router as immutable. ServeHTTP is safe for concurrent use. Concurrent registration during serving is not supported.
go test ./...The package includes Go native fuzz tests targeting parsing, matching, parameter extraction, URL generation, and HTTP serving code paths. Fuzz tests help discover panics, crashes, and edge cases with arbitrary inputs.
Run a specific fuzz target for 30 seconds:
go test -fuzz='^FuzzRouteMatch$' -fuzztime=30s ./...Run only the seed corpus (useful in CI):
go test -run='Fuzz' ./...Available fuzz targets:
| Target | File | What it exercises |
|---|---|---|
FuzzRouteMatch |
fuzz_match_test.go |
Router.Match() with arbitrary methods and paths |
FuzzRouteMatchRawPath |
fuzz_match_test.go |
Matching with encoded/raw URL paths |
FuzzRouteMatchRecorder |
fuzz_match_test.go |
Full ServeHTTP dispatch via match |
FuzzParamExtraction |
fuzz_param_test.go |
ParamInt, ParamInt64, ParamFloat64, ParamBool, ParamString |
FuzzParamExtractionMissing |
fuzz_param_test.go |
Param extraction with missing context |
FuzzURLGeneration |
fuzz_urlgen_test.go |
Router.URL() and Router.Path() with arbitrary param values |
FuzzURLGenerationUnknownRoute |
fuzz_urlgen_test.go |
URL generation for unknown route names |
FuzzRouteRegistration |
fuzz_template_test.go |
Route registration with arbitrary template strings |
FuzzMethodSetFrom |
fuzz_methodset_test.go |
MethodSetFrom and MethodFromString with arbitrary input |
FuzzCanonicalMatch |
fuzz_canonical_test.go |
Canonical URL matching with arbitrary query strings |
FuzzCanonicalRedirect |
fuzz_canonical_test.go |
Canonical redirect via ServeHTTP |
FuzzServeHTTP |
fuzz_serve_test.go |
Full dispatch with slash redirect policy |
FuzzServeHTTPComplex |
fuzz_serve_test.go |
Dispatch with slash redirect and canonical annotate policies |
The package includes comprehensive benchmarks covering all critical hot paths. Run them with:
go test -bench=. -benchmem ./...Compare performance before and after changes using benchstat:
go test -bench=. -benchmem -count=6 ./... > old.txt
# make changes
go test -bench=. -benchmem -count=6 ./... > new.txt
benchstat old.txt new.txtProfile a specific benchmark:
go test -bench=BenchmarkMatch -cpuprofile=cpu.out ./...
go tool pprof cpu.outAvailable benchmark groups:
| Benchmark group | What it measures |
|---|---|
BenchmarkMatch_Static_* |
Static route matching at 5, 50, 200 route scales |
BenchmarkMatch_Parameterized_* |
Parameterized route matching at various scales |
BenchmarkMatch_Scaling |
O(n) scaling of route matching with route count |
BenchmarkMatch_*Constraint* |
Constraint evaluation overhead (none, Int, multiple, Regexp, Host) |
BenchmarkMatch_Query* |
Query parameter handling (loose, strict, many params) |
BenchmarkSelectBest_* |
Candidate scoring and selection at 2, 10, 50 candidates |
BenchmarkURL_* / BenchmarkPath_* |
URL and path generation (static, params, query) |
BenchmarkParam* |
Parameter extraction (String, Int, Int64, Float64, Bool, custom) |
BenchmarkServeHTTP_* |
Full request dispatch (static, parameterized, 404, 405, slash redirect, canonical redirect) |
BenchmarkHandle_* / BenchmarkResource_* |
Route registration (simple, with options, full resource) |
BenchmarkBindHelpers_* |
Reflection-based URL helper setup and invocation |
BenchmarkComputeCanonicalURL / BenchmarkIsCanonicalURL_* |
Canonical URL computation and comparison |
BenchmarkNormalizeQuery_* |
Query string normalization at 2 and 20 params |
MIT. See LICENSE.