Assign a name to any route with .Name(), then generate its URL path later
using URL() or MustURL().
r.GET("/users/{id}", showUser).Name("users.show")
// MustURL panics on error — suited to handler code
path := r.MustURL("users.show", "id", "42")
// Returns: "/users/42"
// URL returns an error — suited to startup / config code
path, err := r.URL("users.show", "id", "42")Parameters are passed as alternating key-value pairs. Each key must match a placeholder name in the route pattern.
Wildcard segments ({key:.*}, which match slashes) are substituted the same way:
r.GET("/{bucket}/{key:.*}", getObject).Name("object.get")
path := r.MustURL("object.get", "bucket", "photos", "key", "2024/vacation.jpg")
// Returns: "/photos/2024/vacation.jpg"URL() returns an error when:
- the route name was never registered (check that
.Name()was called) - an odd number of
paramsarguments is provided (must be key-value pairs) - any placeholder remains unreplaced after substitution (a required param was omitted)
Combine with the urlbuilder package to turn a path into a full URL:
import "github.com/mallardduck/teapot-router/pkg/urlbuilder"
urls := urlbuilder.New("s3.example.com")
path := r.MustURL("object.get", "bucket", "photos", "key", "2024/vacation.jpg")
fullURL := urls.BuildURL(r, path)
// Returns: "https://s3.example.com/photos/2024/vacation.jpg"See URLBUILDER.md for the full guide.
Route requests to different handlers based on query parameters — essential for S3-style APIs:
// Same path, different handlers based on query params
r.GET("/{bucket}", listObjects).
Name("bucket.list").
Action("s3:ListBucket")
r.GET("/{bucket}", getBucketAcl).
Name("bucket.acl").
Action("s3:GetBucketAcl").
Query("acl") // Matches when ?acl is present
r.GET("/{bucket}", getBucketVersioning).
Name("bucket.versioning").
Action("s3:GetBucketVersioning").
Query("versioning") // Matches when ?versioning is presentQuery matching options:
.Query("acl")— matches if query param exists (any value).QueryValue("type", "full")— matches if query param has exact value
More specific matchers take priority (2 query params beats 1).
For paths with many query-parameter variants, Dispatch groups them into a
single block — clearer than scattering individual calls across many lines:
r.Dispatch("GET", "/{bucket}", func(d *teapot.DispatchBuilder, m teapot.Matchers) {
d.Default(listObjects).Name("bucket.list").Action("s3:ListBucket")
d.When(m.QueryEquals("list-type", "2")).Do(listObjectsV2).Name("bucket.list-v2").Action("s3:ListObjectsV2")
d.When(m.QueryExists("acl")).Do(getBucketAcl).Name("bucket.acl").Action("s3:GetBucketAcl")
d.When(m.QueryExists("versioning")).Do(getVersioning).Name("bucket.versioning").Action("s3:GetBucketVersioning")
})Default(handler)— the fallback, matches when no other route's conditions matchWhen(matchers...).Do(handler)— a conditional route; all matchers must match (AND).Name(),.Action(),.With()— same fluent chain as the scattered API, available on bothDefaultandWhenroutes
The m parameter (type teapot.Matchers) exposes all built-in matcher constructors
so that the dispatch package does not need to be imported separately:
m.QueryExists("key")— matches if the query param is present (any value)m.QueryEquals("key", "value")— matches if the query param equals a specific valuem.HeaderExists("key")— matches if the header is present with a non-empty valuem.HeaderEquals("key", "value")— matches if the header equals a specific value- Multiple matchers in one
Whenare ANDed:When(m.QueryExists("partNumber"), m.QueryExists("uploadId"))
Both styles coexist in the same router — use Dispatch where you have a dense
cluster of variants on one path, and the fluent style elsewhere.
The dispatch package works independently of teapot with any Go HTTP router:
import "github.com/mallardduck/teapot-router/pkg/dispatch"
d := dispatch.New(func(b *dispatch.Builder) {
b.Default(listHandler)
b.When(dispatch.QueryEquals("format", "xml")).Do(xmlHandler)
b.When(dispatch.QueryExists("search")).Do(searchHandler)
})
// d implements http.Handler — works with stdlib, chi, gorilla, or anything else
http.Handle("/api/items", d)Same dispatching logic that r.Dispatch uses internally, without the
teapot-specific features (named routes, action context, URL generation).
Each route can define an S3 action that's injected into the request context:
r.GET("/{bucket}/{key:.*}", getObject).
Name("object.get").
Action("s3:GetObject")
func getObject(w http.ResponseWriter, r *http.Request) {
action := teapot.GetAction(r) // "s3:GetObject"
name := teapot.GetRouteName(r) // "object.get"
bucket := teapot.URLParam(r, "bucket")
key := teapot.URLParam(r, "key")
// Use action for authorization, logging, metrics...
}Group routes with path and name prefixes:
// Path prefix only
r.Group("/api/v1", func (r *teapot.Router) {
r.GET("/users", listUsers).Name("users.list")
})
// Path + name prefix
r.NamedGroup("/{bucket}", "bucket", func (r *teapot.Router) {
r.GET("", listObjects).Name("list") // name: "bucket.list"
r.GET("", getBucketAcl).Name("acl").Query("acl") // name: "bucket.acl"
r.NamedGroup("/{key:.*}", "object", func (r *teapot.Router) {
r.GET("", getObject).Name("get") // name: "bucket.object.get"
})
})SubRouter(prefix) creates a new child router whose routes are automatically visible in the parent router with the prefix prepended.
Unlike Group(), SubRouter() returns a separate Router instance that can be used independently (e.g., as its own HTTP server) while still reporting its routes to the parent for unified listing and URL generation.
adminRouter := r.SubRouter("/admin")
adminRouter.GET("/dashboard", dashHandler).Name("dashboard")
// "dashboard" route is now visible in both adminRouter and r (as "/admin/dashboard")Use Mount(prefix, handler) to attach any http.Handler to a path prefix:
r.Mount("/static", http.FileServer(http.Dir("./static")))If the handler being mounted is also a *teapot.Router, all its existing routes are automatically propagated to the parent router with the prefix prepended.
Furthermore, "Homing" is established: any future routes added to the sub-router after it has been mounted will also be automatically propagated to the parent. This enables flexible router composition where routers can be developed independently or even after mounting.
apiRouter := teapot.New()
apiRouter.GET("/users", listUsers).Name("users.list")
// Mount propagates all apiRouter's current routes to r
r.Mount("/api/v1", apiRouter)
// Homing: even routes added LATER are visible in the parent
apiRouter.GET("/profile", profileHandler).Name("users.profile")
// r now knows about both:
// "/api/v1/users" (name: "users.list")
// "/api/v1/profile" (name: "users.profile")When mounting a router, you can control the name prefix applied to its routes using MountNamed or the fluent .Name() method on the mount builder:
// Using MountNamed
r.MountNamed("/api/v1", "v1", apiRouter)
// apiRouter route "users.list" becomes "v1.users.list" in r
// Using fluent API
r.Mount("/api/v2", apiRouter).Name("v2")
// apiRouter route "users.list" becomes "v2.users.list" in rIf a name is assigned via the fluent .Name() after mounting, any already-propagated routes will be renamed throughout the hierarchy automatically.
Assign standard http.Handler (including http.HandlerFunc via http.HandlerFunc(fn)) to a method and pattern:
r.Handle("GET", "/health", healthHandler).Name("health")This is a more flexible alternative to GET(), POST(), etc. when you already have an http.Handler instance.
Use RegisterExternal(method, pattern, name, action) to add "phantom" routes to the router. These routes appear in documentation (route lists) and work with the URL builder, but do not dispatch requests locally. This is useful for documenting external services or third-party handlers that you cannot convert to teapot.
r.RegisterExternal("GET", "https://auth.example.com/login", "auth.login", "auth:Login")
// Use for URL generation:
loginURL := r.MustURL("auth.login") // "https://auth.example.com/login"Works with all standard chi middleware:
// Global middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Route-specific middleware
r.GET("/admin", adminHandler).
Name("admin").
With(authMiddleware)
// Group middleware
r.Group("/api", func (r *teapot.Router) {
r.Use(apiKeyMiddleware)
r.GET("/data", dataHandler).Name("api.data")
})List all registered routes programmatically or via HTTP:
// Programmatic access
for _, route := range r.Routes() {
fmt.Printf("%s %s -> %s (%s)\n",
route.Method,
route.Pattern,
route.Name,
route.Action)
}
// HTTP debug endpoint (development only)
if debug {
r.RegisterDebugRoute("/.internal/routes", "debug.routes")
}
// Visit http://localhost:8080/.internal/routes for JSON or HTML route listingSee ROUTES-LISTING.md for CLI helpers and formatters.
For production deployments, call Finalize() before serving to optimize route handlers:
r := teapot.New()
// Register all routes
r.GET("/users", listUsers).Name("users.list")
r.POST("/users", createUser).Name("users.create")
// Optimize for production
r.Finalize()
http.ListenAndServe(":8080", r)Finalize is optional but recommended — it pre-computes handler chains for direct routes and eagerly builds all dispatchers, avoiding any per-request setup on the first call.
r := teapot.New()
// Service endpoint
r.GET("/", listBuckets).Name("service.list").Action("s3:ListAllMyBuckets")
// Bucket operations with query multiplexing
r.NamedGroup("/{bucket}", "bucket", func (r *teapot.Router) {
r.PUT("", createBucket).Name("create").Action("s3:CreateBucket")
r.DELETE("", deleteBucket).Name("delete").Action("s3:DeleteBucket")
r.HEAD("", headBucket).Name("head").Action("s3:HeadBucket")
r.GET("", listObjects).Name("list").Action("s3:ListBucket")
// Query-based bucket operations
r.GET("", getBucketAcl).Name("acl.get").Action("s3:GetBucketAcl").Query("acl")
r.PUT("", putBucketAcl).Name("acl.put").Action("s3:PutBucketAcl").Query("acl")
r.GET("", listObjectVersions).Name("versions").Action("s3:ListBucketVersions").Query("versions")
// Object operations
r.NamedGroup("/{key:.*}", "object", func (r *teapot.Router) {
r.GET("", getObject).Name("get").Action("s3:GetObject")
r.PUT("", putObject).Name("put").Action("s3:PutObject")
r.DELETE("", deleteObject).Name("delete").Action("s3:DeleteObject")
r.HEAD("", headObject).Name("head").Action("s3:HeadObject")
r.GET("", getObjectAcl).Name("acl.get").Action("s3:GetObjectAcl").Query("acl")
r.POST("", createMultipartUpload).Name("upload.create").Action("s3:CreateMultipartUpload").Query("uploads")
})
})
http.ListenAndServe(":8080", r)The GET bucket operations above could equivalently use Dispatch to group all
the query variants explicitly:
// Inside the NamedGroup("/{bucket}", ...) callback:
r.Dispatch("GET", "", func(d *teapot.DispatchBuilder, m teapot.Matchers) {
d.Default(listObjects).Name("list").Action("s3:ListBucket")
d.When(m.QueryExists("acl")).Do(getBucketAcl).Name("acl.get").Action("s3:GetBucketAcl")
d.When(m.QueryExists("versions")).Do(listObjectVersions).Name("versions").Action("s3:ListBucketVersions")
})RESTful Resource Scaffolding:
r.Resource("photos", "/photos", "photo", teapot.ResourceHandlers{
Index: listPhotos, // GET /photos
Store: createPhoto, // POST /photos
Show: showPhoto, // GET /photos/{photo}
Update: updatePhoto, // PUT /photos/{photo}
Destroy: deletePhoto, // DELETE /photos/{photo}
})URL Builder Package:
For generating full URLs in responses (especially useful for S3 APIs):
import "github.com/mallardduck/teapot-router/pkg/urlbuilder"
urls := urlbuilder.New("s3.example.com")
fullURL := urls.ObjectURL(r, "bucket", "path/to/file.txt")
// Returns: https://s3.example.com/bucket/path/to/file.txtSee URLBUILDER.md for complete guide.