Routers
A Router groups related routes under a shared prefix, tag set and dependency chain.
Routers are built independently and composed with Router.Include: a package exports its
own routes and stays unaware of where they will be mounted.
App embeds *Router, so an application is a router that also knows how to serve.
Everything on this page works the same on both.
Registering a route
r := muzak.NewRouter(muzak.WithTags("items"))
r.Get("/items/{item_id}", handlers.ReadItem)
The registration methods are Get, Post, Put, Patch, Delete and Head, plus
Handle for any other method. Each takes a path template, a handler, and any number of
route options. Each returns the *Route it registered.
r.Handle(http.MethodOptions, "/things", handlers.DescribeThings)
There is no Router.Options method. App.Options applies configuration to an
application, and since App embeds *Router the two would shadow each other, so
OPTIONS is registered with Handle. In practice you rarely need to: OPTIONS is answered
automatically with an Allow header.
Router.WS, Router.SSE and Router.SSEHandle register real-time routes and are
covered in WebSockets and
Server-Sent Events. Router.Frontend and
Router.Static serve files and are covered in
Static Files and Frontends. Router.Mount serves any
http.Handler at a prefix and is covered in
Mounting Handlers, and Router.Implement registers an
operation declared once for a server and its Go callers, covered in
Typed Endpoints.
The handler shape
Every handler is func(ctx *muzak.Context, in In) (Out, error).
func ReadItem(ctx *muzak.Context, in schemas.ItemParams) (schemas.ItemOut, error) {
store := muzak.From[*core.ItemStore](ctx)
item, err := store.Get(in.ID)
if err != nil {
return schemas.ItemOut{}, muzak.NotFound("Item not found")
}
return schemas.ItemOut{ID: item.ID, Name: item.Name}, nil
}
In and Out are inferred from the handler literal or from the named function, so you
never write the type arguments at the call site. A route that reads nothing declares
muzak.Empty as its input:
r.Get("/healthz", func(ctx *muzak.Context, _ muzak.Empty) (schemas.HealthOut, error) {
return schemas.HealthOut{Status: "ok"}, nil
})
Path templates
A path may contain {name} parameters and one trailing {name...} wildcard. Matching
walks a segment-wise radix trie and allocates nothing.
r.Get("/users/{username}", handlers.ReadUser) // one segment
r.Get("/files/{path...}", handlers.ServeFile) // the rest of the path
Every parameter in the template must be read by a field of the input type. A template parameter nothing binds is reported when the application is built, because it is almost always a typo:
type ItemParams struct {
ID string `path:"item_id" doc:"The item to operate on"`
}
r.Get("/items/{item_id}", handlers.ReadItem)
Captured values are percent-decoded once before the handler runs, so %2F reaches you as
/ rather than as an escape you have to remember to decode. That also means a wildcard's
value can contain / and .., and any value can contain a NUL byte: /files/a%2F..%2Fb
binds a/../b. Treat one as text a client wrote, and validate it before using it as a file
name.
Static segments are compared percent-decoded too, as net/http.ServeMux does, for every kind
of route, so GET /users/%61dmin is the route /users/admin and cannot slip round the guards
it declares to be answered by a public /users/{id}. An encoded %2F never matches a static
segment, and a lookup path that does not begin with / matches nothing. Two consequences are
build errors:
- A static segment that is not validly encoded (
/50%off), or that encodes a/(/a%2Fb), could never match. Write%25for a literal percent sign, and use a parameter for a value that may contain/. - Two spellings of one path (
/aand/%61) conflict, as two routes at one path do.
A wildcard receives other spellings of the routes beside it. A path is matched as it
arrives, and nothing normalises it first. With a guarded /admin/panel and a public
/{rest...}, every one of //admin/panel, /admin//panel, /admin/panel/, /ADMIN/panel,
/./admin/panel, /x/../admin/panel, /%2e/admin/panel and /admin%2Fpanel reaches the
public wildcard, the last with rest set to admin/panel. The guarded route itself is never
served without its guard, as with net/http.ServeMux; the danger is in what the wildcard's
handler does with the value. One that serves a file, looks up a record or calls another service
by it must check that the caller is allowed what the value refers to, rather than rely on the
guards of a route the request did not match, and must never forward the value to a backend that
cleans, decodes or case-folds paths, which may resolve it to the very resource the guard was
protecting.
To hand everything beneath a prefix to a net/http handler, such as a legacy application or
net/http/pprof, use Router.Mount rather than a wildcard route that calls one. The same
rule holds there: nothing normalises the path, a request reaches the mount only through its
exact prefix, and a handler that cleans or case-folds the path it is given needs guards of its
own. See Mounting Handlers.
A template that declares the same parameter name twice is a build error as well.
/orgs/{id}/users/{id} used to build, and a handler binding path:"id" silently received the
first segment, so a check on the user's id was made against the organisation's. Give each
parameter its own name.
Paths are matched exactly. /items/ and /items are two different templates, and
nothing redirects between them, so register the one you mean.
Registering two routes for the same method and path is an error, with one exception: an application with versioning enabled may register several at one path as long as the versions they answer never overlap. See Versioning.
Route options
Options passed after the handler configure that route alone.
r.Post("/items/", handlers.CreateItem,
muzak.Summary("Create an item"),
muzak.Description("Creates an item under the identifier the caller chooses."),
muzak.Status(http.StatusCreated),
muzak.OperationID("createItem"),
muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists"))
| Option | What it does |
|---|---|
Summary(s) | The one-line description shown beside the operation |
Description(s) | The long-form description, rendered as CommonMark |
OperationID(id) | The operation's identifier, which client generators use to name methods |
Status(code) | The status written when the handler returns without an error |
Deprecated() | Marks the operation deprecated in the document. Changes nothing at runtime |
Hidden() | Leaves the route fully routable but out of the document and the docs page |
SkipValidation() | Stops the input model's Validate method from running |
AllowUnknownFields() | Accepts JSON members with no matching field instead of rejecting them |
MaxBodySize(n) | Overrides the request body limit for this route |
MaxUploadSize(n) | Overrides the body limit for a route that declares a file field. A route that binds only form values is bounded by MaxBodySize |
MaxFileSize(n) | Bounds any single uploaded file |
WithResponseDoc(code, desc) | Documents an outcome the handler produces through an error |
WithResponseModel[T](code, desc) | Documents an outcome and the model its body carries |
WithTags(...) | Adds OpenAPI tags |
WithCategory(name) | Files a router, or one route, under a single category in the documentation. See OpenAPI |
Title(s) | A short human name the documentation lists the route by, in place of its path |
WithSecurity(...) / Public() | Says which declared security scheme a route needs, or that it needs none. Enforced for a scheme built by JWTBearer or APIKeyVerifier; a descriptive scheme is documentation only. See Authentication |
WithDependencies(...) | Attaches guards |
Needs(provider) | Declares a request-scoped value dependency |
Singleton(provider) / WithSingleton(v) | Publishes a value shared by every request |
RateLimit(...) / SkipRateLimit() / WithRateLimit(...) | Narrow the rate limit policy |
WithWebSocket(...) / WithSSE(...) | Configure a real-time route's connection or stream |
WithVersion(...) | Declare the API version or versions the route answers |
Most of these are SharedOption, meaning they are equally valid on a router, where they
apply to every route beneath it. Summary, Title, Description, OperationID, Status and
SkipValidation belong to a single route; WithPrefix belongs to a router.
Grouping routes into a router
A router is created with the options every route beneath it should inherit.
package routers
import (
"net/http"
"awesome-api/core"
"awesome-api/handlers"
"muzak.dev/framework"
)
// Items returns the items router.
func Items() *muzak.Router {
r := muzak.NewRouter(muzak.WithTags("items"))
r.Get("/items/", handlers.ListItems,
muzak.Summary("List items"))
r.Get("/items/{item_id}", handlers.ReadItem,
muzak.Summary("Read an item"),
muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist"),
// Only this route resolves the caller, so only this route pays for it.
muzak.Needs(core.GetCurrentUser))
r.Post("/items/", handlers.CreateItem,
muzak.Summary("Create an item"),
muzak.Status(http.StatusCreated),
muzak.WithResponseDoc(http.StatusConflict, "An item with that identifier already exists"))
r.Put("/items/{item_id}", handlers.RenameItem,
muzak.Summary("Rename an item"),
muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist"))
return r
}
Notice what the router does not decide: it has no prefix, no authentication and no knowledge of the application it belongs to. Those are decisions made where it is included.
Including a router
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Version: "1.0.0",
Addr: settings.Addr,
},
muzak.WithDependencies(core.GetQueryToken),
muzak.WithSingleton(settings),
)
app.Include(routers.Users())
app.Include(routers.Items())
// The admin router is written without a prefix or a guard. Both are applied
// here, which keeps that router reusable and puts the security decision
// somewhere a reviewer will find it.
app.Include(routers.Admin(),
muzak.WithPrefix("/admin"),
muzak.WithTags("admin"),
muzak.WithDependencies(core.GetTokenHeader(settings)),
muzak.WithResponseDoc(http.StatusTeapot, "I'm a teapot"),
)
The child keeps its own configuration and inherits everything from the parent, with the include's options layered in between. Guards contributed by the parent run before those contributed at the include, which run before the child's own.
A router may be included only once. Including it twice is reported as an error when the application is built, because a route has a single resolved path.
Prefixes
WithPrefix must begin with / and must not end with one. Prefixes nest, so including a
router that is itself included concatenates both.
muzak.WithPrefix("/admin") // "/" becomes "/admin/", "/reports" becomes "/admin/reports"
v1 := muzak.NewRouter()
v1.Include(routers.Users())
v1.Include(routers.Items())
app.Include(v1, muzak.WithPrefix("/v1"))
Options applied after creation
App.Options adds router options to an application after New has returned, which is
how a dependency discovered later is published.
models := core.NewModelRegistry()
app.Options(muzak.WithSingleton(models))
It must be called before the application is built. A call made afterwards panics, naming
it, because the routing tree and the dependency chains are resolved once and the call would
have no effect. That is the loud failure the mistake needs: a guard added late used to be
dropped without a word and left routes open. The same holds for Use, Include, route
registration, WebSocket and event stream registration, and Frontend, Static and Mount
after Build, ServeHTTP, Document or a run method. Make every configuration call before
the first of those.
What the router answers on its own
- HEAD. A
GETroute answersHEADautomatically by running the handler and lettingnet/httpdiscard the body. Registering an explicitHeadroute takes precedence. Such a request is a call to theGETroute, with its guards and security, and is named after it: the access log'sroute,RouteFromContext, the span and the request observer all report theGETroute's template. - OPTIONS. A path with no OPTIONS route answers
204with anAllowheader listing every method it serves, including the automaticHEADandOPTIONS. - 405. A path that exists but does not serve the method answers
405with the sameAllowheader, rendered by the error renderer like any other error. - 404. A path that matches nothing answers
404, unless a frontend is mounted and claims it. A path that is not valid UTF-8 gets its404too, with the path quoted in its escaped form, and one longer than 1 KiB is cut in the message and in the log.
The OPTIONS and 405 answers come from the route table alone, as net/http.ServeMux
answers them, so they skip a route's guards and rate limit. An unauthenticated client can
therefore learn which paths exist and which methods they take, and never a route's data.
Middleware installed with App.Use still covers them: put a guard or a limit there when the
existence of a path is itself a secret.
curl -i -X OPTIONS http://localhost:8080/items/
HTTP/1.1 204 No Content
Allow: GET, POST, HEAD, OPTIONS
Inspecting registered routes
Router.Routes returns the routes registered directly on a router, excluding those of
any included router. Before the application is built the paths are the ones supplied at
registration, without inherited prefixes.
if err := app.Build(); err != nil {
log.Fatal(err)
}
for _, route := range app.Routes() {
log.Printf("%s %s (%s)", route.Method, route.Path, route.OperationID)
}
A *Route carries Method, Path, Summary, Title, Description, OperationID, Tags,
Category, Deprecated, Hidden and Status. Its fields are filled in when the application is
built and are read-only from that point on. The route being executed is available inside
a handler as ctx.Route().
Errors reported at build time
These are collected while the application is built and reported together, so a first run in a new environment lists everything wrong at once instead of one thing per attempt:
- two routes registered for the same method and path, unless versioning tells them apart
- an input or output type that cannot be bound or described
- a path parameter no field binds, or a template that declares one name twice
- a static segment that is not validly encoded, or two spellings of one path
- a duplicate operation identifier
- a prefix that does not begin with
/, or that ends with one - a router included more than once
- a
MaxConnectionsorMaxStreamsset anywhere but on the application - a static or frontend mount whose directory does not exist, or two mounts at one path
(a second
StaticorFrontendused to lose to the first without a word, guards included) - a
path,query,header,cookie,formorfiletag on a field the binder cannot reach, and aNestedthat would validate nothing (see Request Data and Validation) - a certificate without a key, or a key without a certificate
- a category, a title or a security declaration that cannot be written to the document
(see OpenAPI), and, unless
DisableDocsis set, a document that cannot be encoded as JSON, which adoc, summary, description or example that is not valid UTF-8 causes (it used to be logged, and/openapi.jsonsilently left unserved) - a version declared while versioning is off, or a versioning policy missing the field its type depends on
Where to go next
Request Data covers how the input type is filled in from the request, and Responses covers what happens to the value you return.