router

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 26, 2026 License: MIT Imports: 3 Imported by: 0

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
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Middleware

type Middleware = func(http.Handler) http.Handler

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

func New(appMids ...rest.MidFunc) *Router

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

func (r *Router) HandleApp(pattern string, h rest.HandlerFunc, mids ...rest.MidFunc)

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) Mount

func (r *Router) Mount(pattern string) *Router

Mount returns a sub-router rooted at pattern.

func (*Router) Route

func (r *Router) Route(fn func(*Router))

Route registers nested routes via a callback that receives a sub-router.

func (*Router) UseApp

func (r *Router) UseApp(mw ...rest.MidFunc)

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

func (r *Router) WithApp(mw ...rest.MidFunc) *Router

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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL