Mounting net/http Handlers
Not everything an application serves is a typed route. Profiling, metrics, an RPC service
generated by another tool, or the application you are migrating away from are all plain
http.Handlers. Router.Mount serves one at a prefix, for the prefix itself and everything
beneath it, for every method.
app := muzak.New(muzak.AppOptions{Title: settings.AppName})
// Operators only: the same guard every admin route uses.
app.Mount("/metrics", promhttp.Handler(), muzak.Needs(auth.RequireOperator))
// net/http/pprof reads the full path, /debug/pprof/heap say, to choose a
// profile, so it is mounted at the path it was written for, without
// StripPrefix.
pprofMux := http.NewServeMux()
pprofMux.HandleFunc("/debug/pprof/", pprof.Index)
pprofMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
pprofMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
pprofMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
pprofMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
app.Mount("/debug/pprof", pprofMux, muzak.Needs(auth.RequireOperator))
// Whatever no route answers yet.
app.Mount("/", legacy.Handler())
The prefix is relative to the router the mount is registered on, under whatever prefixes that
router is included with, exactly as a route's path is. A single trailing slash is ignored, so
"/debug/pprof/" and "/debug/pprof" are one mount, and "/" mounts at the root of the router.
What wins
The most specific answer wins, as it does everywhere in the routing tree.
| Request | Answered by |
|---|---|
| A path a route is registered at, with a method the route registers | the route |
HEAD on a path with a GET route | the route, as without the mount |
A path a route is registered at, with any other method, OPTIONS included | the mount |
| A path beneath the prefix that no route has | the mount |
| A path beneath a longer mount inside this one | the longer mount |
A path beneath a Frontend or Static mounted inside this one | the file mount |
That is what a migration needs. With the legacy application mounted at / and GET /users
moved to a route, GET /users is answered by the route and POST /users still reaches the legacy
handler, until it moves too. Beneath a mount no 405 is ever answered, since the mount takes
every method the routes there do not.
app.Get("/users", handlers.ListUsers) // migrated
app.Mount("/", legacy.Handler()) // POST /users, /orders, ... still here
These are build errors, all reported by one Build:
- two mounts at one prefix, including
"/x"and"/x/"; - a mount at the prefix of a
FrontendorStaticmount; - a mount at the documentation's own path, since the documentation is answered ahead of routing;
- a route whose wildcard sits directly beneath the prefix, such as
"/legacy/{rest...}"beside a mount at/legacy, because the two would answer exactly the same paths; - a prefix that does not begin with
/, or that holds a parameter or wildcard (including one inherited fromWithPrefix("/{tenant}")), an empty segment, a.or..segment, or a character a request carries only percent-encoded; - a nil handler;
- a
Timeoutgiven toMount, described below.
Health Checks are answered ahead of routing, so a handler
mounted at the root leaves /livez and /readyz to them. A health path beneath any other mount,
or a health path of / beside a handler mounted at the root, would take a path the mount was
given, and is a build error too.
It runs inside the application
A mounted handler is not a side door. Everything a route runs inside, it runs inside:
- the middleware installed with
App.Use, the CORS policy, the cross-origin protection, the security headers, request identification, the access log and panic recovery; - before it runs, the rate limit, the guards and the providers of every router it is
registered under and of the options given to
Mount, in the order a route runs them. A refusal is rendered by the error renderer exactly as a route's is; - a security requirement it inherits that names a verifying scheme, such as one built by
JWTBearerorAPIKeyVerifier, ahead of the guards, exactly as for a route.Public()given to the mount exempts it. See Authentication.
The mount is counted by its routers' rate limit as a route of
theirs is: a quota shared with routes is spent by the mount too, and every spelling of the path
that reaches it counts against the one quota. SkipRateLimit() given to Mount leaves it out.
A provider's value is not handed on, since the handler has no *muzak.Context to read it from; a
provider runs for its verdict. A mount behind any guard, request-scoped provider or verifying
security scheme answers with Cache-Control: private, no-cache unless the handler sets a
Cache-Control of its own, so a shared cache does not hand one client's metrics to another.
App.Override and testclient.Override reach the providers given to Mount as they reach a
route's, and a provider given to it with a nil function is a build error.
A provider declared with Acquire is released once the handler returns, and is told the request
succeeded, whatever status the handler wrote: the handler decided what to answer, so nothing
failed on the framework's account. A panic releases it with the failure, and a release that fails
is handled as a handler's failure would be, which aborts a response already started. A Frontend
or Static mounted beneath the prefix releases what it acquired once the file is served. So a
Transaction declared on a router commits behind a mounted handler that answered 500; declare
it on the routers whose routes need it rather than on one a mount sits under. See
Dependencies.
The handler receives the original *http.Request (with StripPrefix, a
shallow copy of it carrying the shorter path), never the pooled muzak.Context. Its context
carries the framework's values:
func handler(w http.ResponseWriter, r *http.Request) {
id, _ := muzak.RequestIDFromContext(r.Context()) // same as X-Request-Id
route, _ := muzak.RouteFromContext(r.Context()) // the mount prefix, "/metrics"
}
The access log records the prefix as the request's route, so a mount is one time series rather
than one per path, and so do tracing and the request observer.
Since a mount answers whatever method a client sends, a method no standard defines is recorded
there as _OTHER, so a client cannot mint a span name or a metric series per request.
A panic in the handler is recovered as a route's is: before anything was written it becomes the
standard 500, with the stack in the log and nothing of it in the response; after the response
started, the connection is aborted so a client cannot mistake a truncated body for a complete one.
http.ErrAbortHandler still aborts. The writer flushes and hijacks, so a streaming or WebSocket
endpoint of the legacy application keeps working.
Body limits
A mounted handler reads its own body, and the body is still bounded by the MaxBodySize the
mount inherits (one mebibyte by default). A body that declares a length over it is refused with
413 before the handler runs; one of unknown length is read through http.MaxBytesReader, so the
read that goes over fails with *http.MaxBytesError and the handler answers as it would.
app.Mount("/upload", legacyUploads, muzak.MaxBodySize(64<<20))
// A client-streaming RPC is one long body. Remove the limit, and bound the
// messages in the RPC framework instead.
app.Mount("/rpc", connectHandler, muzak.MaxBodySize(-1))
On a mount, unlike a route, a negative MaxBodySize given to Mount removes the limit.
MaxUploadSize plays no part, since nothing tells a mount which bodies are uploads, so a mount
that accepts files is given a MaxBodySize large enough for them, as above.
A multipart form the handler parses has its temporary files removed when it returns or panics, as
a route's are, with or without StripPrefix.
StripPrefix
muzak.StripPrefix() gives the handler paths relative to the mount, the way
http.StripPrefix does, so a handler written to be served at the root can be served beneath a
prefix unchanged.
// The old application was written to be served at the root.
app.Mount("/legacy", legacy.Handler(), muzak.StripPrefix())
| Request | Path the handler sees |
|---|---|
/legacy/users | /users |
/legacy | / |
/legacy/ | / |
/legacy/a%2Fb | /a/b, escaped /a%2Fb |
/%6Cegacy/users | /users |
Two differences from http.StripPrefix are deliberate. The prefix itself becomes / rather than
the empty path, which a ServeMux would answer with a redirect to the root of the site. And
the prefix is removed segment by segment from the escaped path, so a prefix the client spelled
with escapes is stripped too, and what remains keeps its own. The handler is given a shallow copy
of the request carrying the new URL; RequestURI, the context and everything else are the
original request's.
A handler that redirects to an absolute path of its own, as ServeMux does to add a trailing
slash, writes a Location relative to the root of the site rather than to the mount; serve such
a handler without StripPrefix, at the path it expects. The same goes for a handler that reads
the full path to decide what to serve, as net/http/pprof does, which is why the example at the
top of this page mounts it without StripPrefix. StripPrefix given to NewRouter, Include or
App.Options is a build error rather than an option that silently does nothing.
What the guards cover
The router never normalises a path: no case folding, no collapsing of //, no resolving of .
or ... A request reaches the mount only through the tree's exact match of the prefix, segment
by segment, with a percent-escape decoded only where it does not encode a /. For a mount at
/admin:
| Spelling | Reaches the mount |
|---|---|
/admin, /admin/, /admin/x, /admin//x | yes, and runs its guards |
/%61dmin/x, /adm%69n | yes, and runs its guards |
/admin/./x, /admin/../x | yes, and runs its guards |
//admin/x, /ADMIN/x, /Admin | no |
/./admin/x, /x/../admin/x, /%2e/admin/x | no |
/admin%2Fx, /admin;x, /admin%00, /admin\x | no |
So whatever the mounted handler serves has passed its guards: the framework's tests send every
spelling above as a raw request line through a real server and check it. What the handler then
does with the path it is given is its own affair. A handler that cleans, decodes or case-folds
paths, a file server on a case-insensitive disk say, may serve for /admin/../x something that a
guarded route beside the mount was meant to protect. Give such a handler guards that cover
everything it can serve, and do not rely on the guards of a route the request did not reach. The
same warning applies to a {name...} wildcard route; see Routers.
A spelling of a path beneath a Frontend or Static that a case-insensitive filesystem would
open, such as /APP/x beneath a frontend at /app, is refused with 404 rather than handed to a
less specific mount, exactly as it is refused rather than handed to a less specific frontend.
What it does not do
Mounts are not described in the OpenAPI document, which describes the routes Muzak can see
into, and an MCP endpoint never offers one as a tool. Options that
describe or bind a route, such as WithTags or CaptureBody, have no effect on a mount. A mount
answers every API version when versioning is on, because it has no version of its own to choose
by. Mount panics once the application has been built, as registering a route does.
A Timeout is not applied to a mounted handler, which writes its own
response and may be streaming one. One inherited from a router is left out, as it is for an event
stream, and one given to Mount is a build error rather than a bound nothing enforces. Give the
handler a deadline of its own, with http.TimeoutHandler for one.
Where to go next
Static Files and Frontends covers the file mounts a handler mount sits beside, and Rate Limiting the budget it shares with routes. Allowed Hosts and HTTPS covers the checks that run ahead of every mount.