Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Groups, With, and Middleware

This chapter explains two closely related concepts:

  1. Scope composition: how Group(...) and With(...) build derived routers.
  2. Middleware composition: how middleware is attached, validated, ordered, and applied.

jetwarp intentionally makes these rules explicit and deterministic. If you understand this page, you’ll be able to read and review routing setup code with confidence.

Assumption: Route registration happens during initialization (before serving requests). Unless a specific adapter documents otherwise, do not register routes concurrently with ServeHTTP.


Groups and With

Jetwarp supports hierarchical routing setup. You can build a base router, then derive scopes from it:

  • Use(...) attaches middleware to the current scope (and everything derived from it).
  • Group(prefix, ...) creates a derived router that adds a path prefix and adds middleware.
  • With(...) creates a derived router that adds middleware only (no prefix change).

Why two primitives?

Many services need both:

  • a structural prefix (e.g. /api/v1, /internal, /admin) → Group
  • a temporary policy (auth, rate limits, feature flags) without changing the URL shape → With

Keeping these separate makes setup code clearer and makes it much harder to accidentally “leak” middleware into unrelated routes.


Group(prefix, …)

Group(prefix, mws...) returns a new router scope where:

  • every subsequently registered route is automatically prefixed with prefix
  • middleware passed to Group is applied to routes registered on that group (and anything derived from it)

A group also inherits everything from its parent scope.

Conceptually:

  • Parent middleware first
  • Group middleware next
  • Route-level middleware last
  • Then the handler

With(…)

With(mws...) returns a derived router scope that:

  • does not change the path prefix
  • does not mutate the receiver (the original router remains unchanged)
  • applies the given middleware only to routes registered via the returned router (and anything derived from it)

This is useful for “temporary scoping”:

  • public routes on a group
  • protected routes next to them on group.With(Auth())

How nested scopes compose

Scopes compose left to right as you build them:

  • prefixes are joined (no double slashes)
  • middleware stacks in order (parent → child → route)

Example:

  • root router has global middleware A
  • group /api has middleware B
  • group /v1 has middleware C
  • a With(D) scope adds D
  • a specific route adds per-route middleware E

The execution order is always:

Use (global) → Group → With → per-route → handler

When the request returns back up the chain, middleware “unwinds” in reverse order (per-route exits first, global exits last).


A “non-trivial” example (with commentary)

This example shows a common layout:

  • global middleware: request id + access log
  • /api/v1 group: versioning
  • public routes on the group
  • private routes on With(AuthBearer(...))
  • one route with extra per-route middleware (e.g. rate limiting)
package main

import (
	"log"
	"net/http"
	"time"

	"codeberg.org/iaconlabs/jetwarp/adapter"
	"codeberg.org/iaconlabs/jetwarp/adapter/chi"
)

func main() {
	r := chi.New()

	// 1) Global middleware (Use): applies everywhere.
	r.Use(
		adapter.HTTPNamed("request_id", RequestID()),
		adapter.HTTPNamed("access_log", AccessLog()),
	)

	// 2) API group (prefix + scoped middleware).
	//
	// Everything registered under v1 is reachable under /api/v1/...
	v1 := r.Group("/api",
		adapter.HTTPNamed("timeout_3s", Timeout(3*time.Second)),
	).Group("/v1")

	// 3) Public route (inherits global + group middleware).
	v1.HandleFunc(http.MethodGet, "/healthz", healthz)

	// 4) Public endpoint that uses a path param.
	v1.HandleFunc(http.MethodGet, "/users/{id}", getUser)

	// 5) A protected “temporary” scope (same prefix, extra middleware only).
	private := v1.With(adapter.HTTPNamed("auth", AuthBearer("dev-token")))

	// 6) Protected routes (inherit global + group + With middleware).
	private.HandleFunc(http.MethodPost, "/users", createUser)

	// 7) Per-route middleware: applies only to this route, most specific.
	private.HandleFunc(http.MethodDelete, "/users/{id}", deleteUser,
		adapter.HTTPNamed("rate_limit", RateLimit(10)),
	)

	// ---- do not skip this ----
	// Registration does not panic. Any failures accumulate here.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

	log.Fatal(http.ListenAndServe(":8080", r))
}

// The middleware/handlers below are intentionally omitted for brevity.
// See the Getting Started examples for full implementations.
func RequestID() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func AccessLog() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func Timeout(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler { return next }
}
func AuthBearer(token string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func RateLimit(rps int) func(http.Handler) http.Handler       { return func(next http.Handler) http.Handler { return next } }

func healthz(w http.ResponseWriter, _ *http.Request)         {}
func getUser(w http.ResponseWriter, r *http.Request)         { _ = r.PathValue("id") }
func createUser(w http.ResponseWriter, r *http.Request)      {}
func deleteUser(w http.ResponseWriter, r *http.Request)      { _ = r.PathValue("id") }

What middleware runs for DELETE /api/v1/users/123?

The request will see middleware in this precise order:

  1. request_id (global Use)
  2. access_log (global Use)
  3. timeout_3s (group /api)
  4. auth (the With(...) scope)
  5. rate_limit (per-route)
  6. deleteUser handler

And it unwinds in reverse when returning.

What about GET /api/v1/healthz?

That route is registered on v1 directly, not on private, so it does not see the auth middleware.

This is the core value of With: you can keep related routes close together without accidentally mutating a shared scope.


Middleware

Jetwarp middleware is intentionally split into two categories:

  1. Portable net/http middleware: works everywhere.
  2. Native / driver middleware: framework-specific, only available when the adapter/driver supports it.

Portable middleware

Portable middleware wraps the standard net/http shape:

func(http.Handler) http.Handler

You typically register it via helpers like:

  • adapter.HTTP(...)
  • adapter.HTTPNamed("name", ...)

Portable middleware is always accepted and is the recommended default for portability.

Native middleware (not supported by built-in adapters yet)

Some frameworks expose middleware types that are not func(http.Handler) http.Handler (for example, gin.HandlerFunc).

Jetwarp’s public middleware interface (adapter.MW) is designed to leave room for adapters that can apply those middleware kinds safely and deterministically. However, the built-in adapters shipped with jetwarp currently support portable middleware only. If you pass a non-portable middleware value, jetwarp records jetwarp.ErrNativeMWUnsupported (and the middleware is not applied).

If you strongly need framework-native middleware, use the adapter’s Engine() escape hatch and register it on the underlying router — but be aware this steps outside the portability contract and may bypass registry/OpenAPI tooling.

How middleware is recorded and applied

When you call Use, Group, With, or Handle(..., mws...), jetwarp:

  1. validates the middleware slice (e.g., detects nil middleware)
  2. splits documentation-only middleware (DocCarrier) from runtime middleware (so docs metadata never breaks routing)
  3. splits runtime middleware into portable vs native categories
  4. records any errors into:
    • the router’s accumulated Err() list
    • and the registry snapshot (scope-level or route-level errors)
  5. builds a deterministic wrapper chain for each route at registration time

The important consequence is:

  • middleware behavior is stable and inspectable
  • errors do not panic and do not stop later registrations
  • OpenAPI tooling reads from the registry, not from reflection or runtime introspection

Execution ordering (the invariant)

Jetwarp enforces this middleware ordering contract:

Use → Group → With → per-route → handler

Two details matter in practice:

  1. Derived scopes inherit parent middleware, then add their own.
  2. With does not mutate the receiver, so it cannot “leak” middleware into sibling routes.

This ordering is pinned by conformance tests. If you change a driver, adapter, or middleware implementation, treat this ordering as a compatibility contract.


Gotchas and best practices

Prefer portable middleware by default

If you want portability, start with portable net/http middleware.

Native middleware is sometimes tempting (because it’s what the framework ecosystem provides), but it reduces portability and often makes it harder to share code between adapters.

A good compromise is:

  • keep your core middleware portable (auth, logging, timeouts, request id, tracing)
  • use framework-native features only via well-documented escape hatches

Always check Err() once setup is done

Jetwarp does not panic on bad registration. It accumulates errors and keeps going.

This is intentional: it makes router setup robust and testable, and it allows you to collect multiple configuration issues in a single run.

But it means you must fail fast before serving traffic:

if err := r.Err(); err != nil {
	log.Fatal(err)
}

Avoid registering routes directly on the underlying engine

Most adapters expose an engine escape hatch for debugging or narrow integrations.

Registering routes directly on the engine usually bypasses:

  • the registry snapshot
  • deterministic middleware composition
  • OpenAPI generation / docs tooling

Don’t rely on “interleaving” portable and native middleware

Today, built-in adapters reject native middleware (jetwarp.ErrNativeMWUnsupported), so you’ll only see the portable ordering.

If you are using (or writing) an adapter that does support native middleware, the rule is:

  • ordering is preserved within portable and within native categories
  • but insertion-order interleaving across the two categories is not part of the contract

If ordering matters, keep middleware portable, or keep native middleware isolated and well-tested.


Where to go next

  • If you’re building a service: keep middleware portable and use Group/With to model your API structure.
  • If you’re building tooling: use the registry snapshot; don’t scrape the router engine.
  • If you’re writing drivers: treat capability checks and ordering as contracts, and add suite tests for any new guarantee.