Routers
A Router groups related routes under a shared prefix, tag set and dependency chain.
Routers are built independently and composed with Router.Include, which is the Go
counterpart of FastAPI's APIRouter and include_router: 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.
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 before the handler runs, so %2F reaches you as /
rather than as an escape you have to remember to decode.
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 form body limit for a route that binds form or file fields |
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 |
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, 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. Calls made afterwards have no effect, because the routing tree and the dependency chains are resolved once.
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. - 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 and the standard error envelope. - 404. A path that matches nothing answers
404, unless a frontend is mounted and claims it.
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, Description, OperationID, Tags,
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
- 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
- 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.