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.

RequestAnswered by
A path a route is registered at, with a method the route registersthe route
HEAD on a path with a GET routethe route, as without the mount
A path a route is registered at, with any other method, OPTIONS includedthe mount
A path beneath the prefix that no route hasthe mount
A path beneath a longer mount inside this onethe longer mount
A path beneath a Frontend or Static mounted inside this onethe 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 Frontend or Static mount;
  • 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 from WithPrefix("/{tenant}")), an empty segment, a . or .. segment, or a character a request carries only percent-encoded;
  • a nil handler;
  • a Timeout given to Mount, 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 JWTBearer or APIKeyVerifier, 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())
RequestPath 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:

SpellingReaches the mount
/admin, /admin/, /admin/x, /admin//xyes, and runs its guards
/%61dmin/x, /adm%69nyes, and runs its guards
/admin/./x, /admin/../xyes, and runs its guards
//admin/x, /ADMIN/x, /Adminno
/./admin/x, /x/../admin/x, /%2e/admin/xno
/admin%2Fx, /admin;x, /admin%00, /admin\xno

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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.