Skip to content

Middleware

wiki edited this page Sep 4, 2026 · 1 revision

Middleware

func LoggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
	})
}

app.Use(LoggingMiddleware)

The standard Go signature, unchanged. What rex adds is a defined composition order and a way to attach middleware to a subset of routes.

The priority scale

Lower runs further out. The lowest priority sees the request first and the response last.

Constant Value
rextension.PriorityRecovery 100 outermost — catches panics from everything inside
rextension.PriorityCORS 200 preflights, and headers on error responses too
rextension.PriorityRateLimit 300 rejects floods before anything expensive
rextension.PriorityAuth 400 authenticates, establishes the principal
rextension.PriorityCSRF 450 needs the session auth established
rextension.PriorityValidation 500 parses the body only for requests that survived
rextension.PriorityHealthGate 600 refuses when this route's dependencies are down
rextension.PriorityDefault 1000 innermost — metrics, tracing, access logging

Equal priorities keep registration order.

The scale is fixed because the order is semantic. Rate limiting after authentication means every flooded request costs a password hash before being rejected — the limiter protects nothing and adds work. CSRF before authentication has no session to check. Recovery anywhere but outermost lets a panic escape to net/http and kill the connection with no response written. Leaving that to whichever order New() received its extensions in guarantees a subtle production bug eventually, and one that reproduces only on the machine where the arguments were reordered.

app.Use(mw) registers at PriorityDefault. For anything else, name the priority.

The five registrations

// Every router, PriorityDefault.
app.Use(mw)

// One named router, explicit priority. The router need not exist yet.
app.UseOnRouter("admin", basicAuth, rextension.PriorityAuth)

// A factory consulted once per route, on every router.
app.UsePerRoute(factory, rextension.PriorityHealthGate)

// The same, limited to one router.
app.UsePerRouteOn("api", factory, rextension.PriorityValidation)

// A factory consulted once per router.
app.UsePerRouter(factory, rextension.PriorityDefault)

Per-route middleware

app.UsePerRoute(func(info rextension.RouteInfo) rextension.Middleware {
	gated, ok := info.Route.(DependencyGatedRoute)
	if !ok {
		return nil // not applicable — nothing is attached to this route
	}
	deps := gated.Dependencies() // read ONCE, at build time

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if !available(deps) {
				rextension.WriteProblem(w, r, http.StatusServiceUnavailable,
					rextension.ProblemDependencyUnavailable, "a dependency is unavailable")
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}, rextension.PriorityHealthGate)

The factory is called once per route, when the table is built and every route is known. Returning nil means not applicable: nothing is attached, so the route pays nothing at request time rather than paying for a no-op call.

Capture configuration in the factory; never look it up per request. The closure is built once, so per-request work disappears and configuration cannot change under a request already running.

RouteInfo carries the route, the router name, and that router's normalised BaseURL — because some middleware needs to know which listener serves a route (the rate limiter's precedence chain is endpoint → router → global).

Per-router middleware

app.UsePerRouter(func(routerName string) rextension.Middleware {
	gauge := inFlight.WithLabel(routerName) // resolved once, here
	return func(next http.Handler) http.Handler { … }
}, rextension.PriorityDefault)

For middleware whose configuration depends on the router rather than the route. The alternative with only Use available is to hardcode one router's name and mislabel every other router's traffic, or to look the name up per request and pay a map lookup and a lock for a value that never changes.

Composition happens once

Chains are composed when the route table is built, not per request. Within one route's chain, entries are sorted by priority ascending, ties broken by registration order, then wrapped from the inside out.

Rebuilding the chain per request cost one closure allocation per middleware per request, for a result that never varied.

Writing middleware that fits

Answer with a problem document. Every extension does, so a client parses one error format.

rextension.WriteProblem(w, r, http.StatusTooManyRequests,
	rextension.ProblemRateLimitExceeded, "rate limit exceeded")

Reach the matched route from the context, not by re-parsing the URL:

rt, ok := rxroute.GetMatchedRoute(r)

Do not write after calling next unless you have wrapped the ResponseWriter — the handler has probably already committed the status.

Recovery belongs at PriorityRecovery. rex does not install one for you; if you want panics turned into a 500 rather than a dropped connection, add one:

app.UseOnRouter(rex.DefaultRouterName, func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if v := recover(); v != nil {
				log.Printf("panic: %v\n%s", v, debug.Stack())
				rextension.WriteProblem(w, r, 500,
					rextension.ProblemInternal, "the request could not be completed")
			}
		}()
		next.ServeHTTP(w, r)
	})
}, rextension.PriorityRecovery)

Clone this wiki locally