Documentation
¶
Overview ¶
Package router wraps the standard library's net/http.ServeMux with nestable groups and two cooperating handler/middleware layers: net/http transport middleware and typed rest application middleware.
Two layers, one boundary ¶
A skit service composes handlers at two layers. Pick the layer by what the code needs to see — raw bytes and the ResponseWriter, or the typed value a handler returns.
aspect transport layer (net/http) application layer (web/rest)
------ -------------------------- ----------------------------
handler http.Handler rest.HandlerFunc -> ResponseEncoder
middleware func(http.Handler) http.Handler rest.MidFunc
sees (ResponseWriter, *Request) (ctx, *Request) -> ResponseEncoder
output bytes on the wire a typed ResponseEncoder / *errs.Error
good for routing, infra, 3rd-party: business + app semantics:
otel, gzip, CORS, access-log, auth->principal, decode/
panic-recover, timeout, validate, error localization,
body size-limit (see web/mid) tx injection, translation
testing record an http response assert on the returned value
The single boundary between them is rest.HandlerFunc.ServeHTTP: it runs the typed chain and writes the returned ResponseEncoder via rest.Respond, exactly mirroring the stdlib http.HandlerFunc. Because a typed handler is therefore an http.Handler, transport middleware always wraps the boundary — transport is outermost, application innermost — and that nesting is correct by construction: anything that needs the ResponseEncoder is a rest.MidFunc (inside), anything that needs raw bytes or is third-party is transport middleware (outside). There is no lift from transport into the typed chain (it has no ResponseWriter); scope a transport concern to some routes with a sub-group instead.
Method map ¶
The router exposes both layers with parallel names — the bare verb is transport, the App suffix is the typed layer:
concern transport (net/http) application (rest typed) ------- -------------------- ------------------------ add middleware to a group Use(mw...) UseApp(mw...) derive a sub-group + middleware With(mw...) WithApp(mw...) register one handler Handle / HandleFunc HandleApp mount a sub-tree Mount / Route (shared)
New(appMids...) seeds the global application middleware. Use/UseApp mutate the current group and apply to handlers registered afterwards; With/WithApp return a derived group and never affect the parent. A single group may serve typed (HandleApp) and standard (Handle) handlers side by side.
Ordering ¶
On a request, layers run outermost first. For a typed route the order is:
transport (Use/With) -> app-global (New) -> app-group (UseApp/WithApp) -> app-route (HandleApp mids) -> handler
Application middleware added later stays inside the earlier ones, so a global localize-errors keeps wrapping a group's auth and can localize the error that auth returns. Register the transport stack panic-first, then trace, then the loggers and guards (see web/mid). A raw handler registered with Handle runs the transport stack but no application middleware.
Recipes ¶
Assemble the tree once (e.g. in an Install function) and hand features a rest.Handle seam (router.HandleApp) so they stay router-agnostic.
Global cross-cutting — transport for infra, app for request semantics:
r := router.New(reqctxMid, localizeErrMid) // application: every typed route r.Use(mid.Panics(log), mid.TraceRequest(tracer), // transport: every route incl. debug mid.AccessLog(log))
A business sub-group with guards that must not touch debug/pprof routes (a request timeout would cut off a long profile), then features register on it:
api := r.With(mid.Timeout(5*time.Second), mid.SizeLimit(1<<20)) // transport sub-group d.WidgetHandler(ctx).Routes(api.HandleApp) // feature owns its routes auditrest.NewHandlers(d.AuditLog(ctx)).Routes(api.HandleApp)
Per-route auth (mixed access within one feature — reads public, writes guarded) passes app middleware to the writes only:
authMW := []rest.MidFunc{auth.AuthenticateApp(v), auth.RequireRoleApp("admin")}
handle("GET /widgets", h.list) // public
handle("POST /widgets", h.create, authMW...) // guarded
A uniformly-protected group uses WithApp instead of repeating per-route:
admin := api.WithApp(auth.AuthenticateApp(v), auth.RequireRoleApp("admin"))
admin.HandleApp("POST /settings", h.update) // all admin routes guarded
Standard and typed handlers on the same router — a file server next to a typed API, or a third-party handler behind transport-only middleware:
r.Handle("GET /assets/", http.FileServer(http.FS(assets)))
r.With(corsMW).Handle("POST /webhook", webhookHandler) // 3rd-party http.Handler
Typed handlers also drop onto a foreign router with no adapter, because a chained HandlerFunc is an http.Handler:
mux := http.NewServeMux()
mux.Handle("POST /widgets", rest.ChainMiddleware(h.create, auth.AuthenticateApp(v)))
Best practices ¶
- Choose the layer by need, not habit: needs the ResponseEncoder / returns *errs.Error / injects into ctx for handlers -> application (UseApp/WithApp/per-route); generic, third-party, or must wrap the encoded bytes -> transport (Use/With).
- Keep features router-agnostic: a Routes method takes a rest.Handle (router.HandleApp), never the *Router. Composition (mounting, grouping, middleware) lives in one Install function.
- Put global localize/format-error middleware in New so it stays outermost of the app layer and can localize errors returned by deeper auth/validation.
- Keep request timeouts and size limits off debug/probe routes by applying them to a business sub-group (With), not the root.
- Prefer per-route rest.MidFunc when access is mixed inside one feature; prefer WithApp when an entire group shares the same guard.
- Don't try to run a transport middleware inside the typed chain; if it needs the ResponseEncoder it belongs as a rest.MidFunc, otherwise scope it with a sub-group so it wraps the boundary.
API ¶
- New(appMids ...rest.MidFunc): build a Router; appMids wrap every HandleApp route, outermost first.
- HandleApp(pattern, rest.HandlerFunc, mids ...rest.MidFunc): mount a typed handler; per-route mids sit inside appMids, the result registers as an http.Handler and is encoded via rest.Respond.
- UseApp / WithApp(mw ...rest.MidFunc): add application middleware to this group (mutating) or a derived group (immutable) — twins of Use / With.
- Mount(pattern): a sub-router rooted at a path prefix.
- Route(fn): register a nested group via a callback receiving a sub-router.
- With(mws ...Middleware): a sub-router with extra net/http middleware.
- Embedded *routegroup.Bundle methods (Handle, HandleFunc, Use, ...) remain available for raw net/http handlers.
Example ¶
Example shows the two-layer assembly: global transport and application middleware, a business sub-group carrying request guards, per-route and per-group application middleware, and a standard handler living next to the typed ones on the same router.
package main
import (
"context"
"net/http"
"time"
"github.com/assanoff/skit/web/mid"
"github.com/assanoff/skit/web/rest"
"github.com/assanoff/skit/web/router"
)
func main() {
// A typed handler returns a ResponseEncoder; errors are returned, not written.
widget := func(_ context.Context, _ *http.Request) rest.ResponseEncoder {
return rest.JSON(map[string]string{"name": "gadget"})
}
// An application middleware (rest.MidFunc) — here a trivial guard.
requireAuth := func(next rest.HandlerFunc) rest.HandlerFunc {
return func(ctx context.Context, r *http.Request) rest.ResponseEncoder {
if r.Header.Get("Authorization") == "" {
return rest.JSONStatus(map[string]string{"error": "unauthorized"}, http.StatusUnauthorized)
}
return next(ctx, r)
}
}
r := router.New( /* global application middleware, e.g. reqctx, localize-errors */ )
// Transport middleware for every route, including debug: panic-first.
r.Use(
mid.Panics(nil),
mid.AccessLog(nil),
)
// Probes stay outside the request guards below.
r.HandleApp("GET /healthz", func(_ context.Context, _ *http.Request) rest.ResponseEncoder {
return rest.JSON(map[string]string{"status": "ok"})
})
// Business sub-group: request timeout + body-size limit (transport), not on
// the probe above.
api := r.With(
mid.Timeout(5*time.Second),
mid.SizeLimit(1<<20),
)
api.HandleApp("GET /widgets", widget) // public read
// A whole group behind auth via WithApp (application sub-group).
admin := api.WithApp(requireAuth)
admin.HandleApp("POST /widgets", widget)
// A standard http.Handler on the same router (transport stack only).
r.Handle("GET /assets/", http.FileServer(http.Dir("./assets")))
// r is an http.Handler: http.ListenAndServe(":8080", r)
_ = r
}
Output:
Index ¶
- type Middleware
- type Router
- func (r *Router) HandleApp(pattern string, h rest.HandlerFunc, mids ...rest.MidFunc)
- func (r *Router) Mount(pattern string) *Router
- func (r *Router) Route(fn func(*Router))
- func (r *Router) UseApp(mw ...rest.MidFunc)
- func (r *Router) With(mws ...Middleware) *Router
- func (r *Router) WithApp(mw ...rest.MidFunc) *Router
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Middleware ¶
Middleware is standard net/http middleware.
type Router ¶
type Router struct {
*routegroup.Bundle
// contains filtered or unexported fields
}
Router is a thin wrapper over routegroup.Bundle that additionally knows how to mount typed rest.HandlerFunc handlers. The zero value is not usable; use New.
func New ¶
New creates a Router backed by a fresh ServeMux. appMids are applied to every handler registered via HandleApp (e.g. error mapping, auth), outermost first.
func (*Router) HandleApp ¶
HandleApp registers a typed rest.HandlerFunc on this group. The router's appMids (outermost) plus any per-route mids are applied, and the chained handler is registered as an http.Handler via the embedded bundle — so the group's transport middleware (Use/With) wraps it, and rest.Respond encodes the returned ResponseEncoder. Its method value is a rest.Handle, so an Install function passes r.HandleApp to a feature's Routes method as the registration seam.
func (*Router) UseApp ¶
UseApp appends application middleware (rest.MidFunc) to this group: it wraps every typed handler registered here and on sub-groups created afterwards. It is the typed-layer counterpart of the embedded Use (which adds net/http transport middleware). Existing app middleware stays outermost, so global concerns (request-context, error localization) keep wrapping the ones added here (e.g. group auth). Copy-on-write: groups already derived are unaffected.
func (*Router) With ¶
func (r *Router) With(mws ...Middleware) *Router
With returns a sub-router with the given net/http (transport) middleware applied. Pair concept: WithApp adds typed (application) middleware.
func (*Router) WithApp ¶
WithApp returns a sub-router whose typed handlers additionally run mw, without starting a new transport group (it shares this router's middleware bundle). It is the typed-layer counterpart of With. Use it to scope app middleware to a group of routes, e.g. admin := api.WithApp(auth).