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

Welcome

jetwarp

jetwarp is a portable Go HTTP routing + middleware + registration-error API. Portability is enforced by a shared conformance suite (tests/suite), not “best effort”.


Philosophy & Design Decisions

Jetwarp was built around a small set of convictions about what a routing library should and should not do. Understanding these helps explain why the API is shaped the way it is.


The application should not know what router it is running on

This is the central premise. Application code — handlers, middleware, route registration — imports only adapter.Adapter. The concrete driver (chi, gin, echo, fiber, or stdlib) is named in exactly one place: the call to adXXX.New() (or adapter/xxx.New()) in your main.go.

The consequence is that the same registerRoutes(r adapter.Adapter) function compiles and runs against every shipped adapter without a single change. This is not just a nice property to have — it is enforced by the conformance suite, which runs an identical test battery against all adapters.


Normalize at registration time (and mirror it at serve time)

Path pattern normalization and validation happen in a single place — the routingpath package — before the driver sees anything:

  • Leading slashes are enforced (patterns become absolute paths)
  • Trailing slashes are removed (except for /)
  • Internal double slashes are preserved intentionally to avoid surprising callers
  • Malformed {name} patterns are rejected (empty names, unbalanced braces, multiple brace pairs per segment, etc.)

This happens at registration time, and jetwarp mirrors the same “trailing slash normalization” at serve time in Router.ServeHTTP, so /docs/ reaches the same handler as /docs even if the underlying router framework treats trailing slashes differently.

One nuance worth calling out: routingpath.ValidatePattern is intentionally conservative. It does not try to normalize or reject internal // sequences. Instead, it focuses on the canonical pattern rules relevant to portable routing semantics.


Never panic. Accumulate errors instead.

Every registration method — Handle, HandleFunc, Group, With, Use — is designed to never panic.

Nil handlers, empty methods, invalid patterns, and unsupported middleware/features are recorded as errors and returned through r.Err(). Drivers are also protected: router registration uses a safeDriverHandle wrapper that catches panics from Driver.Handle(...) and converts them into ordinary errors.

The consequence is that a misconfigured router does not kill the process at startup. It surfaces problems through Err(), which returns a multi-error (*adapter.ListError) containing every issue. Callers can then decide:

  • log and abort, or
  • serve a hard 503 until the configuration is fixed (for staging/tests) using adapter.RefuseOnErr(...).

This design also makes tests simpler: you never need recover() in a test just to verify a bad input was rejected.


Typed-nil safety is part of the contract

Go interfaces can hold a typed-nil pointer (e.g. var d drv.Drv = (*MyDriver)(nil)), which looks non-nil but will panic when methods are called.

Jetwarp treats this as a first-class sharp edge:

  • Drivers implement IsNil() bool safely.
  • adapter.New(d) treats d == nil || d.IsNil() as “nil driver” and records jetwarp.ErrNilDriver instead of storing a value that could panic later.

Capabilities are promises, not aspirations

Every driver declares a drv.Capability bitmask. These bits are not hints or best-effort flags — they are promises that the conformance suite actively verifies.

For example: a driver that claims CapParamSuffix is promising it can support patterns like /files/{id}.json with the canonical meaning preserved. If a driver cannot provide correct, portable semantics, it should not claim the capability — and attempted registrations that depend on it should fail as ordinary configuration errors (not panics).

The practical consequence is that the capability matrix stays honest. If gin does not claim CapParamSuffix, it is because suffix/prefix params cannot be made portable on gin-style routing without changing semantics — not because nobody got around to implementing it.

Capabilities also make it possible to write adaptive setup code (or tooling) without surprises at runtime.


Middleware order is a contract, not an implementation detail

The execution order is fixed and guaranteed by the core adapter implementation, not by convention:

Global Use → Group → With → Per-route → Handler

Jetwarp assembles middleware by walking the scope chain from root to leaf and building the wrapper chain deterministically. No driver can change this ordering.

With() is worth highlighting separately: it returns a new scope and never mutates the receiver. This means you can freely mix public and protected endpoints within the same group without any risk of auth middleware leaking onto routes registered before the With() call. The immutability is structural, not “documented and hoped for”.

One more practical detail: jetwarp’s middleware surface is adapter.MW.

  • Shipped adapters support portable middleware (func(http.Handler) http.Handler) via adapter.HTTP / adapter.HTTPNamed.
  • Any non-portable middleware passed to Use/Group/With/Handle is currently rejected by the core router and recorded as jetwarp.ErrNativeMWUnsupported (documentation-only middleware is exempt; see adapter.DocCarrier).

The adapter.MW hook still matters: it leaves room for future or third‑party adapters to apply framework-specific middleware while preserving the same ordering contract. When that exists, portable and native middleware will remain layered; ordering is preserved within each category, but interleaving across portable vs native middleware in the same call is not promised.


The escape hatch is explicit and named

Jetwarp is a leaky abstraction by design. adapter.EngineProvider exists and is documented. You can retrieve the raw *gin.Engine, *echo.Echo, *fiber.App, chi.Router, or *http.ServeMux when you need it.

The difference from an unprincipled leak is that accessing the engine requires a conscious type assertion to a clearly named interface. There is no hidden state to stumble onto.


Separate modules — you pay only for what you import

Each adapter lives in its own module (adapter/chi, adapter/gin, etc.). The OpenAPI generator and validator also live in separate modules:

  • OpenAPI 3.2 generation: openapi/oas3
  • Dev-only spec validation: openapi/oas3/validate

Adding jetwarp to a project does not pull in chi, gin, echo, fiber, or OpenAPI dependencies unless you explicitly require the corresponding module. This keeps the dependency graph honest and makes go mod graph readable.

The monorepo is held together by a go.work file for local development. Published modules should not rely on replace directives — the workspace is a contributor convenience, invisible to downstream users.


Diagnostics are a machine-readable contract

The OpenAPI package (codeberg.org/iaconlabs/jetwarp/openapi/oas3, imported as v3) returns diagnostics from Build and Attach.

v3.DiagCode is part of the public API. Codes are stable strings suitable for programmatic checks in CI. Message text is explicitly not part of the contract and may change. Diagnostic ordering is also deterministic (build-time first, then attach-time, then UI provider diagnostics).

The same principle applies to errors: every error wraps jetwarp.ErrJetwarp, making errors.Is checks reliable regardless of wrapping depth.


Where to start

Installation

This page covers the practical ways to start using jetwarp:

  • Library users: add an adapter to your existing Go module (chi / gin / echo / fiber / stdlib).
  • Contributors: clone the repository, run the test suites, and (optionally) build the docs locally.

Requirements

  • Go 1.26+ (the repository and all submodules are currently go 1.26.0).
  • A Go module (go.mod) in your application.
  • If you want to use a framework-backed adapter, you’ll also need that framework’s dependency (for example, codeberg.org/go-chi/chi/v5 when using the chi adapter).

Add jetwarp to your Go project

jetwarp is a multi-module repository. In practice, that means you usually install one adapter module (and it brings in the core module transitively).

Pick the adapter that matches the router you want to run on:

  • codeberg.org/iaconlabs/jetwarp/adapter/stdlib
  • codeberg.org/iaconlabs/jetwarp/adapter/chi
  • codeberg.org/iaconlabs/jetwarp/adapter/gin
  • codeberg.org/iaconlabs/jetwarp/adapter/echo
  • codeberg.org/iaconlabs/jetwarp/adapter/fiber

1) Choose an adapter module

If you already know which router you want, install that adapter directly:

# Example: chi
go get codeberg.org/iaconlabs/jetwarp/adapter/chi@v1.0.1

Or, if you prefer the stdlib router:

go get codeberg.org/iaconlabs/jetwarp/adapter/stdlib@v1.0.1

Then tidy your module:

go mod tidy

Note: v1.0.1 is the current stable release. Pin to a specific tag and update intentionally rather than using @latest in production.

2) Import and create a router

In your application, you typically import the adapter package and call New():

import (
    "net/http"

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

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

    r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
        _, _ = w.Write([]byte("ok\n"))
    })

    // Always check registration errors before serving traffic.
    if err := r.Err(); err != nil {
        panic(err)
    }

    _ = http.ListenAndServe(":8080", r)
}

(For a more complete example, see the examples/ pages in this book.)


Optional: OpenAPI 3.2 generation

OpenAPI support lives in an optional module:

  • codeberg.org/iaconlabs/jetwarp/openapi/oas3

Install it like any other module:

go get codeberg.org/iaconlabs/jetwarp/openapi/oas3@v1.0.1
go mod tidy

You can then call oas3.Attach(...) after registering routes to mount /openapi.json and an optional UI.


Contributor setup (developing jetwarp itself)

1) Clone the repository

git clone https://codeberg.org/iaconlabs/jetwarp
cd jetwarp

2) Run tests

Because jetwarp is multi-module, there are two common workflows:

  • Simple: run tests module-by-module (reliable everywhere).
  • Workspace: use a go.work workspace so go test ./... sees all modules together.

Simple (module-by-module)

From the repo root:

go test ./...

Then run the same command inside any submodule you touched (for example: adapter/chi, drivers/v1/chi, openapi/oas3, etc.):

(cd adapter/chi && go test ./...)
(cd drivers/v1/chi && go test ./...)
(cd openapi/oas3 && go test ./...)

If you prefer a workspace, create one locally:

go work init .
go work use ./adapter/chi ./adapter/echo ./adapter/fiber ./adapter/gin ./adapter/stdlib
go work use ./drivers/v1/chi ./drivers/v1/echo ./drivers/v1/fiber ./drivers/v1/gin ./drivers/v1/stdlib
go work use ./openapi/oas3 ./openapi/oas3/validate
go work use ./examples/students ./examples/openapi-students ./examples/openapi-scalar-students
go work sync

After that, you can generally run:

go test ./...

If you already have a committed go.work in your checkout, you can skip go work init/use and just run go work sync.


Building the docs locally (mdBook)

The project documentation is built with mdBook. CI currently installs mdBook v0.4.40 and runs:

mdbook build docs

To build the docs locally:

  1. Install mdBook (one way is to use the same approach as CI):
curl -sSL https://codeberg.org/rust-lang/mdBook/releases/download/v0.4.40/mdbook-v0.4.40-x86_64-unknown-linux-gnu.tar.gz \
  | tar -xz --directory /usr/local/bin
  1. Preview in a local server:
mdbook serve docs
  1. Or build the static site:
mdbook build docs

Quickstart

This page is intentionally small: a first router, a couple of routes, and the one safety check you should not skip.

If you have not installed jetwarp yet, start with the Installation page.


1) Pick an adapter

jetwarp exposes one stable API (adapter.Adapter) and ships multiple adapter packages that bind it to a concrete router backend (stdlib, chi, gin, echo, fiber, …).

For a first run, chi is a good default:

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

If you want zero external router dependencies, use the stdlib adapter instead:

import "codeberg.org/iaconlabs/jetwarp/adapter/stdlib"

2) Register routes and middleware

jetwarp’s canonical path patterns use {name} parameters (ServeMux-style). Query strings are not part of matching.

A tiny server might look like this:

package main

import (
	"log"
	"net/http"

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

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

	// Optional: portable net/http middleware (works across all adapters).
	r.Use(adapter.HTTPNamed("request_log", func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			log.Printf("%s %s", req.Method, req.URL.Path)
			next.ServeHTTP(w, req)
		})
	}))

	// A simple health endpoint.
	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	// Path params use {name}. In handlers, prefer stdlib-style PathValue.
	r.HandleFunc(http.MethodGet, "/users/{id}", func(w http.ResponseWriter, req *http.Request) {
		id := req.PathValue("id")
		_, _ = w.Write([]byte("user id = " + id))
	})

	// ---- do not skip this ----
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

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

Why req.PathValue("id")?

Some routers (like Gin) do not populate net/http path parameters on their own. jetwarp drivers bridge captured params into *http.Request so you can read them portably via PathValue across adapters.


3) Fail fast on configuration errors

jetwarp’s registration contract is “no panics”: invalid patterns, unsupported middleware, etc. accumulate into Err().

The recommended default is:

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

If you want a belt-and-suspenders safety net in addition to the fail-fast check, you can wrap the server handler:

// RefuseOnErr rejects all requests with 503 if Err() is non-nil.
log.Fatal(http.ListenAndServe(":8080", adapter.RefuseOnErr(r, r)))

That wrapper is useful in staging/tests and as a copy/paste-safe pattern.


Next steps

A more complex router example

Quickstart focuses on the smallest possible “hello router”. This page takes the next step: a small-but-realistic API server with:

  • global middleware (panic recovery, request IDs, logging)
  • versioned routing (/api/v1/...) via Group
  • protected endpoints via With
  • path parameters via {id} and r.PathValue("id")
  • configuration error handling via Err() (and an optional runtime guard)

The code below is deliberately dependency-light: it uses only the standard library plus a jetwarp adapter.

Tip: Route registration is expected to happen during initialization, before you start serving requests. Unless an adapter explicitly documents otherwise, don’t mutate routes/middleware concurrently with ServeHTTP.


1) The full example

Pick an adapter. This example uses chi, but you can swap it for stdlib, echo, fiber, or gin without touching your application routing logic.

package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"strings"
	"sync"
	"time"

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

// -----------------------------------------------------------------------------
// Domain model + storage (in-memory for demo)
// -----------------------------------------------------------------------------

type Student struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

type StudentStore struct {
	mu sync.RWMutex
	m  map[string]Student
}

func NewStudentStore() *StudentStore {
	return &StudentStore{
		m: map[string]Student{
			"1": {ID: "1", Name: "Ada Lovelace", Email: "ada@example.com"},
			"2": {ID: "2", Name: "Alan Turing", Email: "alan@example.com"},
		},
	}
}

func (s *StudentStore) List() []Student {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Student, 0, len(s.m))
	for _, v := range s.m {
		out = append(out, v)
	}
	return out
}

func (s *StudentStore) Get(id string) (Student, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	v, ok := s.m[id]
	return v, ok
}

func (s *StudentStore) Create(v Student) (Student, error) {
	if strings.TrimSpace(v.Name) == "" || strings.TrimSpace(v.Email) == "" {
		return Student{}, errors.New("name and email are required")
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	v.ID = newID()
	s.m[v.ID] = v
	return v, nil
}

func (s *StudentStore) Update(id string, patch Student) (Student, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	cur, ok := s.m[id]
	if !ok {
		return Student{}, errors.New("not found")
	}
	if strings.TrimSpace(patch.Name) != "" {
		cur.Name = patch.Name
	}
	if strings.TrimSpace(patch.Email) != "" {
		cur.Email = patch.Email
	}
	s.m[id] = cur
	return cur, nil
}

func (s *StudentStore) Delete(id string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.m[id]; !ok {
		return false
	}
	delete(s.m, id)
	return true
}

// -----------------------------------------------------------------------------
// Middleware (portable net/http)
// -----------------------------------------------------------------------------

type ctxKey string

const (
	ctxReqID ctxKey = "reqid"

	headerReqID      = "X-Request-ID"
	headerAuth       = "Authorization"
	bearerPrefix     = "Bearer "
	defaultAuthToken = "dev-token" // demo only
)

// RequestID sets/propagates X-Request-ID and stores it in context.
func RequestID() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := strings.TrimSpace(r.Header.Get(headerReqID))
			if id == "" {
				id = newReqID()
			}
			w.Header().Set(headerReqID, id)
			ctx := context.WithValue(r.Context(), ctxReqID, id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// RecoverJSON turns panics into a minimal JSON 500 response.
func RecoverJSON() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if rec := recover(); rec != nil {
					writeJSON(w, http.StatusInternalServerError, map[string]any{
						"error": "internal server error",
					})
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

// Timeout adds a request context deadline.
func Timeout(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx, cancel := context.WithTimeout(r.Context(), d)
			defer cancel()
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// AccessLog logs method/path/status/duration (simple).
func AccessLog() func(http.Handler) http.Handler {
	type logRW struct {
		http.ResponseWriter
		status int
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			lw := &logRW{ResponseWriter: w, status: http.StatusOK}
			// Capture status code.
			wrapped := http.ResponseWriter(lw)
			next.ServeHTTP(wrapped, r)
			dur := time.Since(start)

			reqID, _ := r.Context().Value(ctxReqID).(string)
			if reqID != "" {
				log.Printf("[%s] %s %s -> %d (%s)", reqID, r.Method, r.URL.Path, lw.status, dur)
			} else {
				log.Printf("%s %s -> %d (%s)", r.Method, r.URL.Path, lw.status, dur)
			}
		})
	}
}

// AuthBearer protects endpoints with a shared bearer token (demo).
func AuthBearer(token string) func(http.Handler) http.Handler {
	token = strings.TrimSpace(token)
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			raw := strings.TrimSpace(r.Header.Get(headerAuth))
			if !strings.HasPrefix(raw, bearerPrefix) {
				writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "missing bearer token"})
				return
			}
			got := strings.TrimSpace(strings.TrimPrefix(raw, bearerPrefix))
			if got == "" || got != token {
				writeJSON(w, http.StatusForbidden, map[string]any{"error": "invalid token"})
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

// -----------------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------------

type API struct {
	store *StudentStore
}

func (a *API) healthz(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *API) listStudents(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, a.store.List())
}

func (a *API) getStudent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // jetwarp bridges params to PathValue for a portable UX.
	if id == "" {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "missing id"})
		return
	}
	v, ok := a.store.Get(id)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
		return
	}
	writeJSON(w, http.StatusOK, v)
}

func (a *API) createStudent(w http.ResponseWriter, r *http.Request) {
	var in Student
	if err := readJSON(r, &in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
		return
	}
	out, err := a.store.Create(in)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusCreated, out)
}

func (a *API) updateStudent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var patch Student
	if err := readJSON(r, &patch); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
		return
	}
	out, err := a.store.Update(id, patch)
	if err != nil {
		status := http.StatusBadRequest
		if err.Error() == "not found" {
			status = http.StatusNotFound
		}
		writeJSON(w, status, map[string]any{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, out)
}

func (a *API) deleteStudent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if ok := a.store.Delete(id); !ok {
		writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// -----------------------------------------------------------------------------
// JSON helpers
// -----------------------------------------------------------------------------

func readJSON(r *http.Request, dst any) error {
	if r.Body == nil {
		return errors.New("empty body")
	}
	defer r.Body.Close()
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec.Decode(dst)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	enc := json.NewEncoder(w)
	enc.SetEscapeHTML(true)
	_ = enc.Encode(v)
}

func newReqID() string {
	var b [12]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

func newID() string {
	return newReqID()[:8]
}

// -----------------------------------------------------------------------------
// main
// -----------------------------------------------------------------------------

func main() {
	// 1) Create a router via an adapter package.
	r := chi.New()

	api := &API{store: NewStudentStore()}

	// 2) Global middleware: applied to every route, including groups and With scopes.
	r.Use(
		adapter.HTTPNamed("recover_json", RecoverJSON()),
		adapter.HTTPNamed("request_id", RequestID()),
		adapter.HTTPNamed("access_log", AccessLog()),
		adapter.HTTPNamed("timeout_5s", Timeout(5*time.Second)),
	)

	// 3) Register public endpoints (no auth).
	r.HandleFunc(http.MethodGet, "/healthz", api.healthz)

	// 4) Versioned API routing via groups.
	//
	// All routes registered on v1 are prefixed with /api/v1.
	v1 := r.Group("/api").Group("/v1")

	// Public routes.
	v1.HandleFunc(http.MethodGet, "/students", api.listStudents)
	v1.HandleFunc(http.MethodGet, "/students/{id}", api.getStudent)

	// 5) Protected routes via With.
	//
	// With creates a derived router that does not mutate v1, so you can keep public and
	// private routes side-by-side.
	private := v1.With(adapter.HTTPNamed("auth_bearer", AuthBearer(defaultAuthToken)))

	private.HandleFunc(http.MethodPost, "/students", api.createStudent)
	private.HandleFunc(http.MethodPut, "/students/{id}", api.updateStudent)
	private.HandleFunc(http.MethodDelete, "/students/{id}", api.deleteStudent)

	// 6) Fail fast on configuration errors.
	//
	// jetwarp does not panic on bad registrations; it accumulates errors in Err().
	// Always check Err() before serving traffic.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

	// Optional belt-and-suspenders runtime guard:
	// if Err() becomes non-nil later (e.g. you accidentally registered after starting),
	// requests are rejected with 503 rather than partially served.
	h := adapter.RefuseOnErr(r, r)

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", h))
}

2) What this example is doing (and why)

Router creation (adapter first)

Your application always talks to adapter.Adapter. The adapter you pick determines the underlying driver, but your route registrations stay the same:

  • stdlib: codeberg.org/iaconlabs/jetwarp/adapter/stdlib
  • chi: codeberg.org/iaconlabs/jetwarp/adapter/chi
  • gin: codeberg.org/iaconlabs/jetwarp/adapter/gin
  • echo: codeberg.org/iaconlabs/jetwarp/adapter/echo
  • fiber: codeberg.org/iaconlabs/jetwarp/adapter/fiber

Global middleware (Use)

Use(...) registers middleware on the current scope and all derived scopes.

In this example we used only portable net/http middleware, wrapped by:

adapter.HTTPNamed("name", mw)

Portable middleware is the simplest way to stay framework-agnostic.

Groups (Group) for prefixes

Group("/api").Group("/v1") gives you a derived router whose registered routes are automatically prefixed.

This is a clean way to model versioning and sub-APIs without manually concatenating strings.

With for “temporary scopes”

With(...) is for “same prefix, extra middleware”. Here we used it for auth:

  • public routes are registered on v1
  • protected routes are registered on v1.With(AuthBearer(...))

This keeps your route definitions close together while still making auth boundaries explicit.

Path parameters via {id} + PathValue

Routes use {id} parameters:

  • "/students/{id}" matches "/students/42"

In handlers, prefer:

id := r.PathValue("id")

jetwarp drivers bridge native framework params into the standard *http.Request PathValue mechanism so handlers can use a uniform, stdlib-style API.

Error handling: Err() and the optional runtime guard

A key design rule is: registration must not panic. When something can’t be applied (invalid patterns, unsupported features, incompatible middleware), the router accumulates errors and keeps going.

That makes Err() a required “fail fast” check.

The optional adapter.RefuseOnErr wrapper is a small runtime guard: if Err() is non-nil, it rejects requests with 503. It is useful in tests and staging, and it can protect against accidental late registration.


3) A note on “in-segment” params (/files/{id}.json)

jetwarp supports {id} parameters. Some adapters also support “in-segment” prefix/suffix patterns, such as:

  • /files/{id}.json
  • /reports/prefix-{id}

However, not all frameworks can express these patterns safely. For example, some routers would interpret :id.json as a parameter named id.json, which breaks Param("id") semantics.

If you want maximum portability, prefer plain segment params (/files/{id}) unless you explicitly require suffix/prefix routing and have confirmed your chosen adapters support it.


Next steps

  • Groups & With — middleware ordering and scoping rules
  • Middleware — portable middleware, ordering, and gotchas
  • OpenAPI — building docs from the registry snapshot

Adapters (overview)

In jetwarp, an adapter is the entry-point you use in application code.

An adapter gives you a single, stable API (adapter.Adapter) while delegating the actual routing to a pluggable driver under the hood. Your handlers stay standard net/http (http.Handler / http.HandlerFunc), and you can swap the underlying router framework (stdlib, chi, gin, echo, fiber, …) without rewriting your app.


What an adapter gives you

1) A portable router API

All adapters return the same interface:

  • Use(...) global middleware
  • Group(prefix, ...) scoped prefix + middleware
  • With(...) scoped middleware without changing the prefix
  • Handle(...) / HandleFunc(...) route registration
  • Err() accumulated registration errors
  • plus ServeHTTP so the router itself is an http.Handler

This is the only surface your application should depend on.

2) A strict “no panic” setup experience

Jetwarp treats route registration as input validation, not a crash site:

  • Invalid patterns, unsupported features, or driver failures must accumulate as errors
  • The router must remain usable even after a bad registration
  • You check r.Err() once your setup is done (before serving traffic)

3) Thin packaging (you only pull what you use)

Each adapter lives in its own module (for example adapter/chi, adapter/gin, …), so you don’t download every framework dependency just to use one.


Choosing an adapter

There is no “best” adapter — pick what matches your project and team.

AdapterGood default when…Notes
stdlibyou want the smallest surface area, maximum portabilityBased on net/http routing semantics
chiyou already use chi, or you want a small, idiomatic routerStrong feature set, common in Go services
ginyou’re in the gin ecosystemJetwarp wraps gin while keeping net/http handlers
echoyou’re in the echo ecosystemJetwarp wraps echo while keeping net/http handlers
fiberyou want Fiber’s performance model / ecosystemJetwarp bridges Fiber (not net/http native) to net/http handlers

If you’re not sure, start with stdlib or chi. You can always change later — that’s the point.


Using an adapter

Adapters intentionally keep construction boring:

package main

import (
	"log"
	"net/http"

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

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

	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("OK"))
	})

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

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

That same application code should work with adapter/stdlib, adapter/gin, adapter/echo, adapter/fiber, etc. (only the import + constructor change).

Middleware support

Jetwarp middleware is expressed via adapter.MW.

Portable middleware

Portable middleware wraps standard net/http middleware (func(http.Handler) http.Handler) and works everywhere:

mw := adapter.HTTP(func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// before
		next.ServeHTTP(w, r)
		// after
	})
})

r.Use(mw)

Native middleware (framework-specific)

Jetwarp has a design for native/driver middleware, but not every adapter/driver can express it. If a middleware cannot be applied portably, jetwarp will record an error and continue setup (you’ll see it in Err()).

As a rule of thumb: if you want maximum portability, keep middleware portable.

Reading path parameters

Jetwarp’s canonical pattern syntax uses {name} parameters (e.g. /users/{id}), but how you read the value inside a handler depends on the underlying router.

Many adapters bridge parameters into the standard library *http.Request API (r.PathValue("id")) for a consistent user experience. Some frameworks don’t expose this natively, so bridging is handled at the driver level where possible.

If your service depends heavily on params, test your chosen adapter(s) early and keep your handler param access strategy consistent across the project. (Driver capabilities and param rules are covered in the Drivers section of the docs.)

Introspection and escape hatches (optional)

Most application code should never need these, but they exist for tooling and debugging.

Registry snapshots

Some adapter implementations also expose a registry snapshot (the “truth source” for OpenAPI, diagnostics, and other tooling). When supported, you’ll access it via a type assertion to the appropriate optional interface documented in the adapter package.

Engine access

Similarly, some adapters expose the underlying router/engine as any (for debugging or very specific integrations). This is intentionally an escape hatch — if you rely on it, you are stepping outside the portability contract.

Conformance: why adapters are trusted

Jetwarp’s portability promise is enforced by a shared conformance suite (tests/suite). Drivers must pass driver conformance tests for the capabilities they claim. Adapters are also exercised against adapter-level conformance. If something “works in gin but not in chi”, that’s either:

  • a capability you relied on that one adapter doesn’t claim/support, or

  • a bug (and the suite is where we pin it).

The STDLIB adapter

The stdlib adapter wires jetwarp’s portable adapter.Adapter API to the Go standard library router, net/http’s ServeMux.

If you want the smallest dependency footprint and the most “plain net/http” experience, this is the best place to start.


Install

Add the adapter module to your project:

go get codeberg.org/iaconlabs/jetwarp/adapter/stdlib@latest

This pulls in:

  • codeberg.org/iaconlabs/jetwarp (core adapter + registry)
  • codeberg.org/iaconlabs/jetwarp/drivers/v1/stdlib (the stdlib driver)

No third-party router dependencies are required.


Create a router

package main

import (
	"log"
	"net/http"

	twstdlib "codeberg.org/iaconlabs/jetwarp/adapter/stdlib"
)

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

	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	// Always check accumulated registration errors before serving traffic.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

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

Route patterns and params

The stdlib adapter is the “native home” of jetwarp’s canonical pattern model.

Parameters ({name}) and PathValue

Jetwarp patterns use ServeMux-style {name} parameters:

r.HandleFunc(http.MethodGet, "/users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	_, _ = w.Write([]byte("id=" + id))
})

Because this is standard library routing, PathValue works without any driver bridging.


MethodAny ("*") support

Jetwarp supports method-agnostic routes via the canonical method token "*".

On stdlib, this maps naturally to how Go 1.22+ ServeMux patterns work:

  • method-specific registration uses a mux pattern like: "GET /path"
  • MethodAny registration uses the raw path: "/path"
r.HandleFunc("*", "/any", func(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("ANY:" + r.Method))
})

Method-specific beats ANY for the same path. If you register both:

  • GET /any
  • * /any

a GET /any request must hit the GET handler (independent of registration order).


Scoping (Group) on stdlib

The stdlib driver supports scoping by returning derived drivers that share the same underlying ServeMux and apply a prefix internally.

From application code, you’ll normally use Group:

v1 := r.Group("/api").Group("/v1")
v1.HandleFunc(http.MethodGet, "/students", listStudents)

The router will join prefixes using jetwarp’s path-join rules (no accidental double slashes).


Important gotchas

1) No in-segment suffix/prefix params

The stdlib driver does not claim support for in-segment suffix/prefix patterns (CapParamSuffix).

That means patterns like these are not portable on stdlib and should be rejected during registration:

  • /files/{id}.json
  • /prefix-{id}

If you need this feature, use an adapter whose driver advertises param-suffix support.

Portable alternative: use a plain segment param and validate/parse in the handler:

  • /files/{id} then parse/validate
  • /files/{id}.json (not supported on stdlib)

2) Duplicate registrations can trigger ServeMux panics (but jetwarp turns them into errors)

http.ServeMux may panic when registering invalid or conflicting patterns (including duplicates).

The stdlib driver protects you: it converts those panics into returned errors and keeps the driver usable. Still, it’s a good reason to treat Err() as a required setup check.

3) Trailing slashes are normalized away

Jetwarp normalizes patterns by trimming trailing slashes for non-root paths.

This is intentional (it avoids ambiguous “/x vs /x/” behavior), but it also means you should not rely on ServeMux’s legacy “subtree” semantics that depend on patterns ending with /.

If you need a file-server-style subtree mount, consider:

  • mounting it on a separate parent http.ServeMux, or
  • using a router/driver that supports your desired prefix matching model explicitly.

Accessing the underlying engine (escape hatch)

If you really need direct access to the underlying mux for debugging or integration, the stdlib driver’s engine is a *http.ServeMux.

Be careful: registering routes directly on the mux bypasses jetwarp’s registry and tooling (including OpenAPI).

type engineProvider interface {
	Engine() any
}

if ep, ok := r.(engineProvider); ok {
	if mux, ok := ep.Engine().(*http.ServeMux); ok {
		_ = mux
	}
}

See also

The CHI adapter

The chi adapter wires jetwarp’s portable adapter.Adapter API to the go-chi/chi router.

It’s a good default when you want a lightweight, idiomatic net/http stack, or you already use chi in other services and want to keep the same mental model while still getting jetwarp’s portability guarantees.


Install

Add the adapter module to your project:

go get codeberg.org/iaconlabs/jetwarp/adapter/chi@latest

This pulls in:

  • codeberg.org/iaconlabs/jetwarp (core adapter + registry)

  • codeberg.org/iaconlabs/jetwarp/drivers/v1/chi (the driver implementation)

  • codeberg.org/go-chi/chi/v5 (the underlying router)

Create a router

package main

import (
	"log"
	"net/http"

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

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

	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	// Always check accumulated registration errors before serving traffic.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

	_ = http.ListenAndServe(":8080", r)
}

Route patterns and params

Jetwarp’s canonical pattern syntax is ServeMux-style {name} parameters:

r.HandleFunc(http.MethodGet, "/users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	_, _ = w.Write([]byte(id))
})

In-segment suffix/prefix parameters

The chi driver advertises support for in-segment literals around a single param, for example:

  • /files/{id}.json

  • /prefix-{id}

r.HandleFunc(http.MethodGet, "/files/{id}.json", func(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("id=" + r.PathValue("id")))
})

Reading parameters portably

jetwarp’s recommended application-level way to read params is (*http.Request).PathValue (Go 1.22+).

Most non-stdlib drivers install parameters into PathValue so your handlers remain portable. If you run into a framework where PathValue isn’t populated for a given adapter/driver, that’s a portability bug and should be treated as such (it belongs in the conformance suite and/or driver regression tests).

MethodAny (“*”) support

Jetwarp supports method-agnostic routes via the canonical method token “*”.

r.HandleFunc("*", "/any", func(w http.ResponseWriter, _ *http.Request) {
	_, _ = w.Write([]byte("matched regardless of method"))
})

The chi driver implements a “method-specific wins over ANY” rule consistently:

  • If you register GET /any and later register * /any (or the reverse), a GET /any request must hit the explicit GET handler.

This rule is enforced by conformance tests and is independent of registration order.

Middleware with chi

chi middleware is already func(http.Handler) http.Handler, which is the same shape as jetwarp’s portable HTTP middleware. In practice, this means you can use most existing chi middleware as-is.

import (
	twadapter "codeberg.org/iaconlabs/jetwarp/adapter"
)

r.Use(twadapter.HTTPNamed("request-id", func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Request-Id", "example")
		next.ServeHTTP(w, r)
	})
}))

The ordering contract is deterministic:

Use → Group → With → per-route → handler

Accessing the underlying engine (escape hatch)

Sometimes you need a router feature that’s intentionally outside jetwarp’s portability surface (custom chi routing features, third-party chi tooling, etc.).

The returned router is a concrete *adapter.Router, which exposes an Engine() any escape hatch. This is optional and not part of the portable adapter.Adapter interface, so use it sparingly:

type engineProvider interface {
	Engine() any
}

if ep, ok := r.(engineProvider); ok {
	eng := ep.Engine()
	_ = eng // type-assert to the chi engine type if you really need it
}

If you find yourself relying on the engine often, it’s usually a sign you should either:

  • keep that service “chi-native” (no portability requirement), or

  • write a small helper driver/adapter extension that can be tested in the suite.

Common pitfalls

  • Forgetting to check Err(): jetwarp accumulates registration errors instead of panicking. Always call Err() after setup.

  • Bypassing jetwarp for routing: registering routes directly on the underlying chi engine skips the registry, OpenAPI generation, and conformance guarantees.

  • Depending on non-portable parameter APIs: prefer r.PathValue("name") in handlers to keep your code adapter-agnostic.

Next steps

echo adapter

The echo adapter wires jetwarp’s portable adapter.Adapter API to the labstack/echo router (Echo v5).

It’s a solid choice if your team already uses Echo, but you still want your route registration and middleware composition to stay portable across other adapters (stdlib, chi, gin, fiber, …).


Install

Add the adapter module to your project:

go get codeberg.org/iaconlabs/jetwarp/adapter/echo@latest

This pulls in:

  • codeberg.org/iaconlabs/jetwarp (core adapter + registry)
  • codeberg.org/iaconlabs/jetwarp/drivers/v1/echo (the Echo v5 driver)
  • codeberg.org/labstack/echo/v5 (Echo itself)

Create a router

package main

import (
	"log"
	"net/http"

	twecho "codeberg.org/iaconlabs/jetwarp/adapter/echo"
)

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

	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	// Always check accumulated registration errors before serving traffic.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

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

Route patterns and parameters

jetwarp’s canonical pattern syntax uses ServeMux-style {name} parameters. Echo uses :name internally, but you keep writing the canonical form:

r.HandleFunc(http.MethodGet, "/users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	_, _ = w.Write([]byte("id=" + id))
})

Why PathValue works with Echo

Echo exposes params through its own context API (c.Param(...)), not via *http.Request.

The jetwarp Echo driver bridges captured parameters into the request before calling your handler, so you can read them portably via:

id := r.PathValue("id")

This is the recommended application-level approach when you want to keep handlers adapter-agnostic.


MethodAny ("*") support

jetwarp supports method-agnostic routes via the canonical method token "*":

r.HandleFunc("*", "/any", func(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("ANY:" + r.Method))
})

Echo has a dedicated ANY method route type, and it is intentionally lower priority than method-specific routes. The driver relies on that behavior so the portability rule holds:

  • explicit methods (e.g. GET /any) must win over * /any, regardless of registration order

Middleware with Echo

Prefer portable net/http middleware

Echo middleware is not the same shape as portable net/http middleware. For maximum portability, prefer standard net/http middleware and wrap it with adapter.HTTP(...) / adapter.HTTPNamed(...):

import (
	"net/http"

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

r.Use(adapter.HTTPNamed("example", func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// before
		next.ServeHTTP(w, r)
		// after
	})
}))

Ordering is deterministic across adapters:

Use → Group → With → per-route → handler

Gotcha: Echo defaults are not installed

The Echo driver constructs the engine with echo.New() (not a preconfigured “defaults” bundle). That means you should install any recovery/logging/auth middleware explicitly.

This tends to work well with jetwarp’s philosophy: middleware should be visible in the setup code, and portable by default.


Important gotchas

1) No in-segment suffix/prefix params

Echo-style routers would interpret a route like :id.json as a parameter named id.json.

To avoid silently wrong behavior, the Echo driver rejects in-segment suffix/prefix patterns such as:

  • /files/{id}.json
  • /prefix-{id}

Use a plain segment param instead and validate in the handler:

  • /files/{id} then parse/validate
  • /files/{id}.json (not supported on Echo adapter)

2) Literal segments may not start with : or *

Echo reserves : and * prefixes in path segments. The driver rejects literal segments that start with those prefixes to avoid registering routes that Echo would interpret as params/wildcards.

If you really need a literal segment that begins with : or *, consider URL-encoding it at the application level, or choose a different path shape.


Accessing the underlying engine (escape hatch)

For debugging or Echo-specific integration, you can access the underlying *echo.Echo engine via an optional escape hatch.

Be careful: registering routes directly on the engine bypasses jetwarp’s registry (and therefore OpenAPI generation and some tooling).

import echov5 "codeberg.org/labstack/echo/v5"

type engineProvider interface {
	Engine() any
}

if ep, ok := r.(engineProvider); ok {
	if e, ok := ep.Engine().(*echov5.Echo); ok {
		_ = e // Echo engine
	}
}

See also

The GIN adapter

The gin adapter wires jetwarp’s portable adapter.Adapter API to the gin-gonic/gin router.

This adapter is a good fit when you want to stay in the Gin ecosystem, but still keep your application routing code portable across other adapters (stdlib, chi, echo, fiber, …).


Install

Add the adapter module to your project:

go get codeberg.org/iaconlabs/jetwarp/adapter/gin@latest

This pulls in:

  • codeberg.org/iaconlabs/jetwarp (core adapter + registry)
  • codeberg.org/iaconlabs/jetwarp/drivers/v1/gin (the gin driver)
  • github.com/gin-gonic/gin (the underlying router)

Create a router

package main

import (
	"log"
	"net/http"

	twgin "codeberg.org/iaconlabs/jetwarp/adapter/gin"
)

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

	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	// Always check accumulated registration errors before serving traffic.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

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

Route patterns and parameters

jetwarp’s canonical pattern syntax uses ServeMux-style {name} parameters.

The Gin driver converts {id} to Gin/httprouter-style :id internally, but you keep writing the canonical form:

r.HandleFunc(http.MethodGet, "/users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	_, _ = w.Write([]byte("id=" + id))
})

Why PathValue works with Gin

Gin exposes parameters via its own context API (c.Param(...)), not via *http.Request.

jetwarp bridges captured parameters into the request so handlers can read them portably using (*http.Request).PathValue("id"), regardless of which adapter is underneath.

If you ever see empty PathValue params on the gin adapter, treat it as a regression: it should be covered by tests.


Important gotcha: suffix/prefix patterns are rejected

The Gin driver does not support “in-segment” suffix/prefix patterns such as:

  • /files/{id}.json
  • /prefix-{id}

Why? In Gin/httprouter-style routers, :id.json would be interpreted as a parameter named id.json. That breaks jetwarp’s canonical semantics where the parameter name is id.

So jetwarp chooses a loud, safe behavior:

  • registration fails (recorded in Err())
  • the router remains usable after the failure

Portable alternative: use a plain segment param and validate the suffix yourself in the handler:

  • /files/{id} then validate/parse
  • /files/{id}.json (not portable on gin)

MethodAny ("*") support

jetwarp supports method-agnostic routes via the canonical method token "*":

r.HandleFunc("*", "/any", func(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("ANY:" + r.Method))
})

On Gin, the driver uses two internal engines:

  • primary: method-specific routes (GET, POST, …)
  • any: MethodAny routes ("*")

Request dispatch tries primary first and falls back to any. This guarantees:

  • explicit method routes win over "*" routes for the same path
  • "*" handles methods that do not have an explicit registration

This rule is enforced by tests and does not depend on registration order.


Middleware with Gin

Prefer portable net/http middleware

Gin middleware (gin.HandlerFunc) is not the same shape as net/http middleware. For maximum portability, prefer standard net/http middleware wrapped with adapter.HTTP(...):

Note: the built-in jetwarp adapters currently support portable middleware only. If you attempt to register framework-native middleware through jetwarp, it will be recorded as jetwarp.ErrNativeMWUnsupported. If you truly need gin.HandlerFunc middleware, attach it via the engine escape hatch (see below).

import (
	"net/http"

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

r.Use(adapter.HTTPNamed("example", func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// before
		next.ServeHTTP(w, r)
		// after
	})
}))

Ordering is deterministic across adapters:

Use → Group → With → per-route → handler

Gotcha: Gin defaults are not installed

The gin driver constructs engines with gin.New() (not gin.Default()), so Gin’s default logger/recovery middleware is not installed automatically.

That’s usually what you want in a portable net/http-first design: you bring your own middleware explicitly. If you want recovery/logging, add it via portable middleware (recommended), or use the engine escape hatch for Gin-specific wiring (see below).


Accessing the underlying engine (escape hatch)

Sometimes you need Gin-specific integration (debugging, profiling hooks, or other tooling). jetwarp exposes an Engine() any escape hatch on the concrete router.

For Gin, the engine value includes both underlying *gin.Engine instances (primary + any). Example:

package main

import (
	twdrv "codeberg.org/iaconlabs/jetwarp/drivers/v1/gin"
)

type engineProvider interface {
	Engine() any
}

func useEngine(r any) {
	ep, ok := r.(engineProvider)
	if !ok {
		return
	}

	if eng, ok := ep.Engine().(twdrv.Engine); ok {
		_ = eng.Primary // *gin.Engine
		_ = eng.Any     // *gin.Engine
	}
}

Be careful with this escape hatch:

  • registering routes directly on Gin will bypass jetwarp’s registry (and therefore OpenAPI generation)
  • relying on Gin-specific behaviors reduces portability

Common pitfalls

  • Forgetting Err(): registration errors accumulate instead of panicking; always fail fast after setup.
  • Using {id}.json patterns: gin intentionally rejects these to avoid silently wrong param names.
  • Trying to use gin.HandlerFunc middleware directly: built-in adapters do not apply native middleware; you will get jetwarp.ErrNativeMWUnsupported. Use adapter.HTTP(...) or the engine escape hatch.
  • Registering routes directly on Gin: useful occasionally, but bypasses jetwarp’s registry and tooling.

See also

fiber adapter

The fiber adapter wires jetwarp’s portable adapter.Adapter API to a driver built on top of Fiber v3.

Fiber is not net/http native (it is based on fasthttp). jetwarp still exposes standard net/http handlers, so the Fiber driver performs a small request/response bridge at the edge.

This adapter is a good fit when you want to keep jetwarp’s portability guarantees while running on Fiber’s ecosystem.


Install

Add the adapter module to your project:

go get codeberg.org/iaconlabs/jetwarp/adapter/fiber@latest

This pulls in:

  • codeberg.org/iaconlabs/jetwarp (core adapter + registry)
  • codeberg.org/iaconlabs/jetwarp/drivers/v1/fiber (Fiber v3 driver)
  • github.com/gofiber/fiber/v3 (Fiber itself)

Create a router

package main

import (
	"log"
	"net/http"

	twfiber "codeberg.org/iaconlabs/jetwarp/adapter/fiber"
)

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

	r.HandleFunc(http.MethodGet, "/healthz", func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte("ok"))
	})

	// Always check accumulated registration errors before serving traffic.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

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

How the Fiber driver bridges net/http

jetwarp’s driver contract is net/http first: handlers are http.Handler / http.HandlerFunc.

Because Fiber is not net/http-native, the driver registers a Fiber handler wrapper that:

  1. reads route parameters from fiber.Ctx
  2. converts the Fiber request into a *http.Request
  3. installs path parameters into the request (see below)
  4. calls your net/http handler, using a ResponseWriter implementation that writes back to Fiber

You don’t usually need to think about this, but it explains most “Fiber-specific” behavior and limitations.


Route patterns and parameters

jetwarp’s canonical path patterns use {name} parameters (ServeMux-style):

r.HandleFunc(http.MethodGet, "/users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	_, _ = w.Write([]byte("id=" + id))
})

Why PathValue works with Fiber

Fiber’s built-in net/http adaptor does not expose route parameters on *http.Request. The jetwarp Fiber driver bridges parameters explicitly so both of these are true:

  • driver-level access: drv.Param(r, "id")
  • portable access: r.PathValue("id")

If you ever see empty params on Fiber, treat it as a bug/regression: that invariant is pinned in tests.


Important gotcha: no in-segment suffix/prefix params

The Fiber driver is intentionally conservative about parameter correctness.

It does not support “in-segment” suffix/prefix patterns such as:

  • /files/{id}.json
  • /prefix-{id}

Why? Fiber would treat :id.json as a parameter named id.json, which breaks jetwarp’s canonical Param("id") / PathValue("id") semantics.

If you register a suffix/prefix pattern on the Fiber adapter, it should be rejected (recorded in Err()) rather than silently behaving differently.

Portable alternative: use a plain segment param and validate the suffix yourself in the handler:

  • /files/{id} then check strings.HasSuffix(...) or parse
  • /files/{id}.json (not portable on Fiber)

MethodAny ("*") support

jetwarp supports method-agnostic routes via the canonical method token "*":

r.HandleFunc("*", "/any", func(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("ANY:" + r.Method))
})

On Fiber, the driver maintains two internal apps:

  • primary: method-specific routes (GET, POST, …)
  • any: MethodAny routes ("*")

Request dispatch tries primary first, then falls back to any. This guarantees:

  • explicit method routes win over "*" routes for the same path
  • "*" handles methods that do not have an explicit registration

This is enforced by regression tests and does not depend on registration order.


Middleware with Fiber

Prefer portable net/http middleware

Fiber middleware (func(*fiber.Ctx) error) is not compatible with jetwarp’s portable middleware surface. For maximum portability, prefer standard net/http middleware wrapped with adapter.HTTP(...):

import (
	"net/http"

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

r.Use(adapter.HTTPNamed("example", func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// before
		next.ServeHTTP(w, r)
		// after
	})
}))

Ordering is deterministic across adapters:

Use → Group → With → per-route → handler

If you need Fiber-native middleware

If you strongly need a Fiber-native middleware or feature, you can use the engine escape hatch (see below). Be aware this steps outside the portability contract and may bypass the registry / OpenAPI tooling if you register routes directly on Fiber.

Note: jetwarp’s built-in adapters currently do not apply native middleware. If you attempt to register a non-portable middleware value, it will be recorded as jetwarp.ErrNativeMWUnsupported.


Accessing the underlying engine (escape hatch)

Sometimes you want to interact with Fiber directly (debugging, profiling hooks, custom Fiber features). jetwarp exposes an Engine() any escape hatch on the concrete router.

The Fiber driver’s engine value is a small struct containing the underlying apps:

  • Primary — method-specific app
  • Any — MethodAny app

Example:

package main

import (
	twdrv "codeberg.org/iaconlabs/jetwarp/drivers/v1/fiber"
)

type engineProvider interface {
	Engine() any
}

func useEngine(r any) {
	ep, ok := r.(engineProvider)
	if !ok {
		return
	}

	if eng, ok := ep.Engine().(twdrv.Engine); ok {
		_ = eng.Primary // *fiber.App
		_ = eng.Any     // *fiber.App
	}
}

If you rely on the engine in production code, consider documenting that your service is Fiber-specific.


Common pitfalls

  • Forgetting Err(): registration errors accumulate instead of panicking; always fail fast after setup.
  • Using {id}.json patterns: Fiber intentionally rejects these to avoid silently wrong param names.
  • Assuming net/http edge behavior is identical: Fiber is bridged; if you rely on advanced HTTP behaviors (streaming, websocket upgrades, connection hijacking, etc.), validate with an early spike test.
  • Registering routes directly on Fiber: it can be useful, but you’ll bypass jetwarp’s registry and tooling.

See also

Drivers (overview)

In jetwarp, drivers are the pluggable backends that actually talk to a concrete router framework (stdlib, chi, gin, echo, fiber, …).

Most application code never touches drivers directly. You choose an adapter (e.g. adapter/chi) and work with the portable adapter.Adapter API. Under the hood, the adapter delegates to a driver that implements the contract in codeberg.org/iaconlabs/jetwarp/drv.

Drivers matter when you:

  • want to understand what is portable vs framework-specific
  • add support for a new router framework
  • debug a capability-dependent behavior (params, scopes, MethodAny, …)

The driver contract: drv.Drv

A driver is a net/http handler plus a small registration API:

  • it serves requests (ServeHTTP)
  • it registers routes (Handle)
  • it can optionally create scopes (Scope)
  • it can read path params (Param)
  • it advertises capabilities (Caps) and a stable kind (Kind)
  • it exposes an escape hatch to the underlying engine (Engine)
  • it must support safe typed-nil detection (IsNil)

The interface lives in drv/driver.go and is intentionally small. The adapter layer does the higher-level work: pattern normalization, middleware composition and ordering, registry recording, error accumulation, and so on.

Why drivers serve net/http even for non-net/http routers?

Jetwarp is “net/http-first”: application handlers are always http.Handler / http.HandlerFunc.

Some frameworks are not net/http-native (notably Fiber, which is based on fasthttp). In that case the driver performs a bridging step inside ServeHTTP: convert the incoming request into a standard *http.Request, call your handler, and write the response back to the underlying engine.

This design keeps application code and middleware portable and testable, and keeps the adapter layer framework-agnostic.


Capability bits: how portability is enforced

Capabilities are promises, not wishes

A driver’s Caps() method returns a bitmask of features the driver is claiming it can support.

The wording is important: a capability means core can rely on this behavior — not “the underlying router kinda does something similar”.

If a feature is not claimed, core code must not assume it works, and tests that require it should be skipped.

Why a bitmask?

A bitmask is:

  • cheap to check at runtime (Has / Any)
  • composable (drivers can advertise multiple capabilities)
  • stable for diagnostics (CapScope|CapParams|...)
  • friendly to “progressive enhancement” (a driver can start small and add caps over time)

The current capability set

Jetwarp currently defines these capability flags:

  • CapScope
    The driver supports Scope(prefix) with correct prefix semantics.

  • CapParams
    The driver supports canonical {name} parameters in patterns, and can return values via Param(r, "name").

  • CapParamSuffix
    The driver supports in-segment literals around a single param, such as /files/{id}.json or /pre-{id}. Drivers may support this natively or via careful driver-side emulation.

  • CapAnyMethod
    The driver supports method-agnostic registration (Handle("*", pattern, h)), and it must truly match regardless of HTTP method.

  • CapNativeScopeMW (reserved — v1.1+)
    Not functional in v1. This bit is reserved for a future mechanism that will let drivers accept native middleware per scope. No adapter uses this capability in v1, and drivers must not implement native scope hooks based on it. The interface contract and ordering semantics will be defined in v1.1.

How to read a capability check

The drv.Capability type provides helpers:

if d.Caps().Has(drv.CapParams) {
    // safe to use {name} patterns and Param(...)
}

if d.Caps().Has(drv.CapScope | drv.CapAnyMethod) {
    // safe to rely on both scope semantics and MethodAny behavior
}

if d.Caps().Any(drv.CapParams | drv.CapAnyMethod) {
    // at least one of these features is present
}

Capabilities and the conformance suite

Capabilities are enforced by the shared conformance suite (tests/suite):

  • tests that require a capability are skipped if the driver does not claim it
  • claiming a capability is a contract: the driver must pass all tests for that capability

This lets drivers be honest and incremental:

  • a minimal driver can claim only CapParams (or even none)
  • a mature driver can add CapScope, CapAnyMethod, etc., once it can guarantee the semantics

Design decisions behind specific capabilities

This section explains “why it’s done this way” — these constraints exist because different frameworks have different routing models, and jetwarp chooses correctness and portability over surprising behavior.

CapScope: why scoping must be explicit

Jetwarp’s adapter builds Group("/prefix") and nested groups on top of driver scoping.

Some frameworks have real “sub-routers”; others don’t. If a driver can correctly model a scope under a prefix, it can claim CapScope. If it can’t, it should not pretend it can.

When CapScope is present, core can rely on this mental model:

sc, _ := d.Scope("/v1") then sc.Handle("GET", "/x", ...) must be reachable at /v1/x.

CapParams: why {name} is canonical

Different routers use different param syntax (:id, {id}, *, regex segments, …). Jetwarp picks one canonical format ({name}) and requires drivers to translate.

This keeps application code consistent and makes tooling possible (e.g. OpenAPI generation from the registry snapshot).

The driver must also be able to read the param value back via Param(r, key).

CapParamSuffix: why it’s optional (and often rejected)

In-segment patterns like /files/{id}.json look simple, but many routers interpret them differently.

For example, in “colon param” routers, :id.json is often parsed as a parameter named id.json, not a parameter named id with a .json suffix. That breaks canonical semantics (Param("id")).

Jetwarp’s stance is conservative:

  • if a driver can guarantee correct semantics, it may claim CapParamSuffix
  • if it cannot, it should reject these patterns during registration (recorded in Err()), rather than silently do the wrong thing

This is why the Fiber and Gin adapters call it out as a gotcha.

CapAnyMethod: why "*" must behave like a real wildcard

Some frameworks support an explicit “ANY” route; others don’t. Some support it but resolve conflicts differently.

Jetwarp requires a clear rule:

Explicit method routes must win over MethodAny for the same path, regardless of registration order.

Drivers often implement this with a two-router approach (a primary router for explicit methods and a secondary router for "*"), and a deterministic dispatch rule (try primary first, then fallback to any).

If a driver cannot guarantee this, it must not claim CapAnyMethod.

CapNativeScopeMW: reserved — do not implement in v1

This capability bit is defined but intentionally unused in v1. It marks a slot for a future mechanism that will allow drivers to accept native middleware per scope without changing the ordering contract.

No adapter mechanism reads or acts on this bit in v1. Drivers must not implement native scope hooks based on it. The interface contract and ordering semantics will be specified in v1.1.

Why capabilities exist (and how to use them)

Jetwarp runs on many router frameworks, and they do not share the same routing model. Some have true sub-routers, some don’t. Some can express {id}.json safely, others would silently treat it as a param named id.json. Some have a real “ANY” route with deterministic precedence, others don’t.

Capabilities are jetwarp’s way to make portability explicit and testable:

  • A capability is a promise: “core and tooling may rely on this behavior”.
  • If a capability is not advertised, jetwarp must not assume it works.
  • The conformance suite is capability-driven: claiming a capability means you must pass the tests for it.

How to think about adding a capability

Create or claim a capability when:

  1. The behavior is not universal across frameworks, and faking it would be surprising.
  2. The behavior has clear semantic rules (what must match, what must not, precedence rules, error cases).
  3. You can enforce it with repeatable conformance tests, not “it seems to work”.

Capabilities should reduce ambiguity in both code and docs.

Use cases for capabilities

1) Progressive driver development
Start a new driver with a small set of features and expand over time:

  • v0: CapScope only (or even none)
  • v1: add CapParams once param extraction is correct
  • v2: add CapAnyMethod once precedence is proven
  • v3: add CapParamSuffix only if you can preserve canonical id naming (no id.json surprises)

2) Safe feature gating in core/tooling
Core code and downstream tooling (like OpenAPI generation) can do:

if d.Caps().Has(drv.CapParamSuffix) {
    // allow /files/{id}.json patterns
} else {
    // reject or downgrade to portable alternatives
}

This prevents “works on my framework” bugs.

**3) Honest documentation

Docs can say: “This adapter supports in-segment suffix params” only when the driver advertises CapParamSuffix.

**4) Conformance-driven guarantees

When you add a new portability rule, you typically:

  • add/extend a capability (if the feature isn’t universal), and/or

  • add a suite contract test that every driver claiming that capability must pass

This is how jetwarp turns design intent into enforced behavior.

How a driver should claim capabilities

A driver should only claim a capability when it can prove it:

  • You’ve implemented the semantics in the driver.

  • You’ve added/updated conformance tests to cover the feature.

  • All drivers claiming the capability pass the suite in CI.

If any of those are missing, the correct move is to not claim the capability yet (and reject the feature loudly via registration errors rather than silently misbehaving).


Kind vs capabilities

Drivers also report a Kind() (a stable string identifier like chi, gin, …).

  • Capabilities exist for behavior decisions (“can I rely on X?”).
  • Kind exists for diagnostics and compatibility checks (especially for framework-native middleware wrappers).

As a rule: prefer Caps() in code; use Kind() for humans and debugging.


Error model: no panics, and typed-nil safety

No panics during registration

A driver must never let framework panics escape during registration (Handle, Scope). If the underlying framework panics (duplicate route registration, invalid patterns, internal assertions, …), the driver should recover and return an error.

Jetwarp’s higher-level contract is “no panics on bad registrations”: errors accumulate and are surfaced via Err() on the adapter.

Typed-nil (IsNil) requirement

Go interfaces can hold a typed-nil pointer value while the interface itself is non-nil. If core stores that interface and later calls methods on it, it can panic.

To prevent this, the driver contract includes:

  • IsNil() bool — and it must be safe to call even on a nil receiver

The adapter constructor uses d == nil || d.IsNil() to guarantee it never stores a typed-nil driver.


The Engine() escape hatch

Drivers may expose the underlying router engine via Engine() any.

This is intentionally an “escape hatch”:

  • it is useful for debugging and narrow integrations
  • it is not part of the portability contract
  • registering routes directly on the engine will usually bypass jetwarp’s registry and tooling (including OpenAPI)

If you need this often, consider whether that service should be treated as framework-specific.


Where drivers live in the repo

Built-in drivers are in drivers/v1/<name> (for example drivers/v1/chi, drivers/v1/stdlib, …).

The adapter packages are thin shims that return:

  • adapter.New(driver.New())

So drivers are the “real” integration work; adapters are packaging and dependency boundaries.


Next steps

Custom drivers

You typically write a custom driver when:

  • you want to support a router framework that jetwarp does not ship with
  • you have an in-house router and want jetwarp portability + tooling (registry/OpenAPI)
  • you want to lock down behavior with the conformance suite before adopting a router broadly

This page explains the design constraints and then walks through a “cool” example driver pattern that covers the non-trivial parts (capabilities, MethodAny, params, scoping, and panic hardening).


The rules a driver must never break

1) No panics on bad registrations

Some router engines panic on invalid patterns, duplicate registrations, or internal invariants. Jetwarp’s contract requires drivers to turn those panics into errors and keep the driver usable afterward.

This is not just a style preference: the conformance suite and smoke suite explicitly check that a driver remains usable after a failed registration.

2) Capability-driven behavior (no guessing)

Drivers advertise supported features via Caps() drv.Capability (a bitmask).

If a feature cannot be implemented with correct semantics, the driver should not claim the capability and should prefer loud failure (return an error) over “silent wrong behavior”.

A classic example is in-segment suffix patterns like /files/{id}.json: many colon-param routers interpret :id.json as a parameter named id.json, which breaks jetwarp’s canonical id semantics. A correct driver must reject it unless it can preserve the canonical meaning.

3) Typed-nil safety (IsNil)

Go interfaces can hold typed-nil pointers. If core stores such a value and later calls methods, you can get panics. Every driver must implement IsNil() bool in a way that is safe on a nil receiver (usually return d == nil).


Where a custom driver should live

Jetwarp is structured as a multi-module repo. Built-in drivers live at:

drivers/v1/<name>

A new driver in this repo should follow the same pattern:

drivers/v1/myrouter/
  go.mod
  myrouter.go
  driver_conformance_test.go
  myrouter_smoke_test.go
  myrouter_regression_test.go   (optional but recommended)

If you’re building outside the repo (in your own module), the layout can be different — but the testing expectations stay the same.


Step-by-step: implementing drv.Drv

The drv.Drv interface is intentionally small. Your driver is responsible for translating and registering routes; the adapter layer handles middleware ordering, path joining, and registry recording.

A high-level checklist:

  1. Choose capabilities honestly (Caps()).
  2. Normalize and validate input patterns at registration time.
  3. Translate canonical {name} syntax into your engine’s pattern syntax.
  4. Implement Scope(prefix) if you claim CapScope.
  5. Implement MethodAny if you claim CapAnyMethod.
  6. Ensure Param extraction works consistently (Param(r, "id")).
  7. Harden all registration entry points against panics.

A “cool” example driver pattern: colon-param router with correct semantics

Many routers use a :id style for path parameters (Gin, Fiber, httprouter-like engines, etc.). The tricky part is making that model compatible with jetwarp’s canonical {id} patterns without breaking parameter naming semantics.

This example shows a minimal but realistic design that supports:

  • CapScope (prefix scoping)
  • CapParams (canonical {name} params)
  • CapAnyMethod (method-agnostic routes with deterministic precedence)
  • does not support CapParamSuffix (suffix/prefix patterns are rejected)

1) Driver structure

A common strategy for deterministic MethodAny behavior is the two-engine approach:

  • primary: method-specific routes (GET, POST, …)
  • any: MethodAny routes ("*")

ServeHTTP tries primary first, then falls back to any.

// myrouter.go (illustrative skeleton)

type Driver struct {
    primary *MyEngine
    any     *MyEngine
    prefix  string
}

type Engine struct {
    Primary *MyEngine
    Any     *MyEngine
}

func New() drv.Drv {
    return &Driver{
        primary: NewMyEngine(),
        any:     NewMyEngine(),
        prefix:  routingpath.NormalizePattern("/"),
    }
}

func (d *Driver) Kind() drv.Kind { return drv.Kind("myrouter") }

func (d *Driver) Caps() drv.Capability {
    return drv.CapScope | drv.CapParams | drv.CapAnyMethod
}

func (d *Driver) Engine() any { return Engine{Primary: d.primary, Any: d.any} }

func (d *Driver) IsNil() bool { return d == nil }

2) Pattern translation: {id}:id (and rejecting suffix/prefix)

Here is the key design choice: if the segment is not exactly {id}, we reject it when it includes braces, because we cannot preserve canonical param naming for suffix/prefix patterns.

func translateSegment(seg string) (engineSeg string, paramName string, isParam bool, err error) {
    // No braces: literal segment.
    if !strings.Contains(seg, "{") && !strings.Contains(seg, "}") {
        return seg, "", false, nil
    }

    // Reject anything except an exact "{name}" segment.
    // This is the “no suffix/prefix” policy (no CapParamSuffix).
    if !(strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}")) {
        return "", "", false, fmt.Errorf("myrouter: param suffix/prefix not supported in segment %q", seg)
    }
    if strings.Count(seg, "{") != 1 || strings.Count(seg, "}") != 1 {
        return "", "", false, fmt.Errorf("myrouter: invalid param segment %q", seg)
    }

    name := strings.TrimSpace(seg[1 : len(seg)-1])
    if name == "" || strings.ContainsAny(name, "/{}") {
        return "", "", false, fmt.Errorf("myrouter: invalid param name %q", name)
    }

    // Colon-param router syntax.
    return ":" + name, name, true, nil
}

With this policy:

  • /users/{id} works everywhere you claim CapParams
  • /files/{id}.json is rejected (loud failure), unless you later add a correct implementation and claim CapParamSuffix

3) Handle: validate, normalize, translate, safe-register

Import note for submodule and external drivers: Go’s internal/ visibility rules prevent submodules (and external modules) from importing codeberg.org/iaconlabs/jetwarp/internal/routingpath directly. Use the public façade instead:

import "codeberg.org/iaconlabs/jetwarp/compat/routingpath"

This package is listed as “Internal-stability” in STABILITY.md — it is not a primary user-facing API, but drivers built on top of jetwarp can rely on it for normalization and validation.

A good driver registration flow looks like this:

  1. validate the handler is non-nil
  2. normalize pattern and join with prefix
  3. validate canonical braces rules (routingpath.ValidatePattern)
  4. translate pattern into engine syntax
  5. register on the correct engine (primary vs any)
  6. recover panics from the underlying engine
func (d *Driver) Handle(method, pattern string, h http.Handler) (err error) {
    if h == nil {
        return errors.New("myrouter: handler is nil")
    }

    pattern = routingpath.NormalizePattern(pattern)
    if pattern == "" {
        return errors.New("myrouter: empty pattern")
    }
    if err := routingpath.ValidatePattern(pattern); err != nil {
        return err
    }

    full := routingpath.JoinPaths(d.prefix, pattern)
    enginePath, err := translatePattern(full) // uses translateSegment for each segment
    if err != nil {
        return err
    }

    m := drv.CanonicalMethod(drv.Method(method))
    target := d.primary
    if m.IsAny() {
        target = d.any
    }

    // Panic hardening: do not let engine panics escape.
    return d.safeRegister("method "+m.NormalizedString()+" "+full, func() {
        target.Handle(m.NormalizedString(), enginePath, wrapHTTPHandler(h))
    })
}

func (d *Driver) safeRegister(op string, fn func()) (err error) {
    defer func() {
        if rec := recover(); rec != nil {
            err = fmt.Errorf("myrouter: panic during %s: %v", op, rec)
        }
    }()
    fn()
    return nil
}

That safeRegister pattern is simple, but it’s one of the most important pieces of driver hardening.

4) ServeHTTP: deterministic MethodAny precedence

This is the guarantee jetwarp cares about:

method-specific routes must win over MethodAny ("*"), regardless of registration order.

Two-engine dispatch makes it straightforward:

func (d *Driver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if w == nil || r == nil {
        return
    }

    // Try primary first.
    if matched := d.primary.ServeHTTPMaybe(w, r); matched {
        return
    }

    // Fallback to any.
    _ = d.any.ServeHTTPMaybe(w, r)
}

Your “maybe” API might not exist in your router. In that case you typically implement a small buffering strategy (status/headers/body) or a router-specific match check, then decide whether to fall back. (See the built-in drivers for concrete strategies; different engines have different match signals.)

5) Scope: derived driver with shared engine and updated prefix

If you claim CapScope, Scope(prefix) must create a derived driver where new registrations are mounted under that prefix. Jetwarp provides helpers for this:

func (d *Driver) Scope(prefix string) (drv.Drv, error) {
    prefix = routingpath.NormalizePattern(prefix)
    if prefix == "" || prefix == "/" {
        return nil, fmt.Errorf("myrouter: invalid scope %q", prefix)
    }

    return &Driver{
        primary: d.primary,
        any:     d.any,
        prefix:  routingpath.JoinPaths(d.prefix, prefix),
    }, nil
}

6) Param extraction: bridge into *http.Request

Many non-stdlib routers have their own param APIs (e.g., ctx.Param("id")). Jetwarp wants a portable experience, so drivers should install params into the request.

The most portable approach on modern Go is:

  • req.SetPathValue(name, value) (Go 1.22+)

With that in place, your Param can be simple:

func (d *Driver) Param(r *http.Request, key string) string {
    if r == nil || key == "" {
        return ""
    }
    return r.PathValue(key)
}

If your engine can’t run inside net/http without an adaptor (e.g., Fiber), you typically do the bridging in the engine wrapper where you still have access to the native context, and you create a *http.Request for the handler.


Testing your driver

1) Conformance suite (required)

In drivers/v1/myrouter/driver_conformance_test.go:

package myrouter_test

import (
    "testing"

    "codeberg.org/iaconlabs/jetwarp/drivers/v1/myrouter"
    "codeberg.org/iaconlabs/jetwarp/drv"
    "codeberg.org/iaconlabs/jetwarp/tests/suite"
)

func TestDriverConformance_MyRouter(t *testing.T) {
    suite.RunDriver(t, suite.DriverFactory{
        Name: "myrouter",
        New: func(t *testing.T) drv.Drv {
            t.Helper()
            return myrouter.New()
        },
    })
}

Note: tests should live in a separate *_test package (black-box style). This keeps you honest and matches the repo’s linting expectations.

The smoke suite is a practical “real-ish usage” battery (tree, scopes, params, MethodAny, concurrency). It complements the conformance suite.

func TestDriverSmoke_MyRouter(t *testing.T) {
    suite.RunDriverSmoke(t, suite.DriverFactory{
        Name: "myrouter",
        New: func(t *testing.T) drv.Drv {
            t.Helper()
            return myrouter.New()
        },
    })
}

Some drivers make deliberate choices that are not strictly implied by drv.Drv (for example: “suffix/prefix patterns must be rejected loudly”). These are easy to regress during refactors.

If your driver has such choices, pin them with a few focused regression tests.


When (and how) to add more capabilities later

A good rule of thumb: only claim a capability when you can prove the semantics, and the tests agree.

For example, to add CapParamSuffix later you would typically:

  1. implement correct suffix/prefix behavior without changing the canonical param name
  2. add conformance tests that cover edge cases (infix, prefix+suffix, nesting with scopes)
  3. claim the capability only when those tests pass reliably

If you cannot preserve semantics, do not claim the capability — reject the feature loudly instead.


Next steps

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.

Basic REST example

This page shows a small-but-realistic REST API built with jetwarp.

It is intentionally “basic” in dependencies (stdlib-friendly), but not trivial in structure. It demonstrates:

  • a versioned API prefix (/api/v1) via Group
  • public and protected endpoints side-by-side via With
  • portable net/http middleware (request id, recovery, logging, timeouts)
  • canonical {name} path parameters read via (*http.Request).PathValue
  • “no panic” registration rules and a fail-fast Err() check

If you want the smallest possible hello-world, start with Quickstart. This page is meant to be the next step: a reference you can adapt for real services.


Why this example looks the way it does

1) Keep the application surface stable

Jetwarp’s goal is that your service code registers routes against one API (adapter.Adapter) and can swap the underlying router backend later (stdlib/chi/gin/echo/fiber) without rewriting your routing setup.

So this example avoids framework-specific handler types and sticks to net/http everywhere.

2) Use portable middleware by default

Router ecosystems often come with their own middleware APIs. That’s convenient, but not portable.

This example uses only portable net/http middleware wrapped with adapter.HTTPNamed(...) so it behaves consistently across adapters.

3) Treat route registration as “config time”

Invalid patterns, unsupported features, or driver failures should not crash your process. Jetwarp accumulates errors and keeps going, which is helpful during iterative setup — but it makes the final Err() check mandatory.


The example API

We’ll implement a minimal “students” API with an in-memory store:

Public:

  • GET /api/v1/healthz
  • GET /api/v1/students
  • GET /api/v1/students/{id}

Protected (Bearer token):

  • POST /api/v1/students
  • PUT /api/v1/students/{id}
  • DELETE /api/v1/students/{id}

Full example (single file)

You can paste this into main.go and run it.

Note: For a real service, you’d split storage, middleware, and handlers into packages. Here we keep it in one file so it’s easy to copy and tweak.

package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"strings"
	"sync"
	"time"

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

// -----------------------------------------------------------------------------
// Model + in-memory store
// -----------------------------------------------------------------------------

type Student struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

type Store struct {
	mu sync.RWMutex
	m  map[string]Student
}

func NewStore() *Store {
	return &Store{
		m: map[string]Student{
			"1": {ID: "1", Name: "Ada Lovelace", Email: "ada@example.com"},
			"2": {ID: "2", Name: "Alan Turing", Email: "alan@example.com"},
		},
	}
}

func (s *Store) List() []Student {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Student, 0, len(s.m))
	for _, v := range s.m {
		out = append(out, v)
	}
	return out
}

func (s *Store) Get(id string) (Student, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	v, ok := s.m[id]
	return v, ok
}

func (s *Store) Create(v Student) (Student, error) {
	if strings.TrimSpace(v.Name) == "" || strings.TrimSpace(v.Email) == "" {
		return Student{}, errors.New("name and email are required")
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	v.ID = newID()
	s.m[v.ID] = v
	return v, nil
}

func (s *Store) Update(id string, patch Student) (Student, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	cur, ok := s.m[id]
	if !ok {
		return Student{}, errors.New("not found")
	}
	if strings.TrimSpace(patch.Name) != "" {
		cur.Name = patch.Name
	}
	if strings.TrimSpace(patch.Email) != "" {
		cur.Email = patch.Email
	}
	s.m[id] = cur
	return cur, nil
}

func (s *Store) Delete(id string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.m[id]; !ok {
		return false
	}
	delete(s.m, id)
	return true
}

// -----------------------------------------------------------------------------
// Middleware (portable net/http)
// -----------------------------------------------------------------------------

type ctxKey string

const (
	ctxReqID ctxKey = "reqid"

	headerReqID      = "X-Request-ID"
	headerAuth       = "Authorization"
	bearerPrefix     = "Bearer "
	defaultAuthToken = "dev-token" // demo only
)

func RequestID() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := strings.TrimSpace(r.Header.Get(headerReqID))
			if id == "" {
				id = newReqID()
			}
			w.Header().Set(headerReqID, id)
			ctx := context.WithValue(r.Context(), ctxReqID, id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func RecoverJSON() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if recover() != nil {
					writeJSON(w, http.StatusInternalServerError, map[string]any{
						"error": "internal server error",
					})
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

func Timeout(d time.Duration) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx, cancel := context.WithTimeout(r.Context(), d)
			defer cancel()
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// logRW wraps http.ResponseWriter to capture the status code for logging.
type logRW struct {
	http.ResponseWriter
	status int
}

func (w *logRW) WriteHeader(statusCode int) {
	w.status = statusCode
	w.ResponseWriter.WriteHeader(statusCode)
}

func AccessLog(logf func(format string, args ...any)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			lw := &logRW{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(lw, r)
			dur := time.Since(start)

			reqID, _ := r.Context().Value(ctxReqID).(string)
			if reqID != "" {
				logf("[%s] %s %s -> %d (%s)", reqID, r.Method, r.URL.Path, lw.status, dur)
			} else {
				logf("%s %s -> %d (%s)", r.Method, r.URL.Path, lw.status, dur)
			}
		})
	}
}

// AuthBearer is intentionally minimal (good for internal tools and prototypes).
func AuthBearer(token string) func(http.Handler) http.Handler {
	token = strings.TrimSpace(token)
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			raw := strings.TrimSpace(r.Header.Get(headerAuth))
			if !strings.HasPrefix(raw, bearerPrefix) {
				writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "missing bearer token"})
				return
			}
			got := strings.TrimSpace(strings.TrimPrefix(raw, bearerPrefix))
			if got == "" || got != token {
				writeJSON(w, http.StatusForbidden, map[string]any{"error": "invalid token"})
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

// -----------------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------------

type API struct {
	store *Store
}

func (a *API) Healthz(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

func (a *API) ListStudents(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, a.store.List())
}

func (a *API) GetStudent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": "missing id"})
		return
	}
	v, ok := a.store.Get(id)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
		return
	}
	writeJSON(w, http.StatusOK, v)
}

func (a *API) CreateStudent(w http.ResponseWriter, r *http.Request) {
	var in Student
	if err := readJSON(r, &in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
		return
	}
	out, err := a.store.Create(in)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusCreated, out)
}

func (a *API) UpdateStudent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var patch Student
	if err := readJSON(r, &patch); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
		return
	}
	out, err := a.store.Update(id, patch)
	if err != nil {
		status := http.StatusBadRequest
		if err.Error() == "not found" {
			status = http.StatusNotFound
		}
		writeJSON(w, status, map[string]any{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, out)
}

func (a *API) DeleteStudent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if ok := a.store.Delete(id); !ok {
		writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// -----------------------------------------------------------------------------
// JSON helpers
// -----------------------------------------------------------------------------

func readJSON(r *http.Request, dst any) error {
	if r.Body == nil {
		return errors.New("empty body")
	}
	defer r.Body.Close()
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec.Decode(dst)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	enc := json.NewEncoder(w)
	enc.SetEscapeHTML(true)
	_ = enc.Encode(v)
}

func newReqID() string {
	var b [12]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

func newID() string {
	return newReqID()[:8]
}

// -----------------------------------------------------------------------------
// main
// -----------------------------------------------------------------------------

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

	api := &API{store: NewStore()}

	r.Use(
		adapter.HTTPNamed("recover_json", RecoverJSON()),
		adapter.HTTPNamed("request_id", RequestID()),
		adapter.HTTPNamed("access_log", AccessLog(log.Printf)),
		adapter.HTTPNamed("timeout_5s", Timeout(5*time.Second)),
	)

	v1 := r.Group("/api").Group("/v1")

	// Public endpoints.
	v1.HandleFunc(http.MethodGet, "/healthz", api.Healthz)
	v1.HandleFunc(http.MethodGet, "/students", api.ListStudents)
	v1.HandleFunc(http.MethodGet, "/students/{id}", api.GetStudent)

	// Protected endpoints.
	private := v1.With(adapter.HTTPNamed("auth", AuthBearer(defaultAuthToken)))
	private.HandleFunc(http.MethodPost, "/students", api.CreateStudent)
	private.HandleFunc(http.MethodPut, "/students/{id}", api.UpdateStudent)
	private.HandleFunc(http.MethodDelete, "/students/{id}", api.DeleteStudent)

	// Fail fast on configuration errors.
	if err := r.Err(); err != nil {
		log.Fatal(err)
	}

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

How to test it quickly

# Health check
curl -i http://localhost:8080/api/v1/healthz

# List
curl -i http://localhost:8080/api/v1/students

# Get
curl -i http://localhost:8080/api/v1/students/1

# Create (requires token)
curl -i -X POST http://localhost:8080/api/v1/students   -H 'Authorization: Bearer dev-token'   -H 'Content-Type: application/json'   -d '{"name":"Grace Hopper","email":"grace@example.com"}'

Small things to adjust for real services

This example keeps things minimal on purpose. In a real service you may want to add:

  • request size limits and strict JSON limits
  • structured logging (slog) + tracing correlation
  • a proper auth mechanism (JWT / mTLS / session)
  • a real persistence layer instead of an in-memory map

Even with those changes, the overall structure tends to stay the same: portable middleware, Group for structure, With for policy boundaries, and a strict Err() check before serving.

OpenAPI 3.2 + Scalar

jetwarp can generate an OpenAPI 3.2 document from your router’s registry snapshot and optionally mount a documentation UI. This page focuses on the Scalar integration (see examples/openapi-scalar-students).


Why this design

Registry-first generation (no reflection, no AST parsing)

jetwarp’s portability story depends on having one “truth source” across all adapters/drivers. We already maintain that truth source for routing: the registry snapshot (attempted routes, resolved paths, scope metadata, registration errors).

OpenAPI generation builds from that snapshot, which means:

  • the same app code produces the same OpenAPI output no matter which underlying router driver is used
  • docs generation does not rely on framework-specific reflection hacks
  • output can be made deterministic (great for golden tests and long-term stability)

Optional OpenAPI module (keeps core lightweight)

The OpenAPI implementation lives in a dedicated module:

  • codeberg.org/iaconlabs/jetwarp/openapi/oas3 (OpenAPI 3.2)

Most applications can generate docs without pulling in additional dev tooling. Validation (pb33f) lives in a separate dev-only module (see below), so you don’t pay runtime dependency cost unless you opt in.

Why Scalar

Scalar is a clean, modern API reference UI and is extremely easy to mount:

  • by default it loads a pinned @scalar/api-reference script from a CDN
  • it only needs your OpenAPI JSON URL
  • jetwarp’s provider mounts a redirect entrypoint and a working index.html page

If you later want Swagger UI, jetwarp also provides a Swagger UI provider — the OpenAPI JSON endpoint stays the same.


The “attach” mental model

oas3.Attach(router, cfg) does two things:

  1. Builds an OpenAPI 3.2 document from router.Registry() and emits deterministic JSON.
  2. Mounts endpoints on the router:
    • always: GET cfg.JSONPath → OpenAPI JSON (default: /openapi.json)
    • optionally: docs UI under cfg.Docs.Path when cfg.Docs.Path != "" and cfg.Docs.Provider != nil (misconfigurations don’t panic; they emit diagnostics)

A key safety goal: Attach must not panic. Even if JSON generation fails, it still mounts a minimal fallback JSON endpoint.

Best practice: call Attach after you register routes (once), then check r.Err() and fail fast if needed.


Non-trivial example: “students” API + Scalar docs

This is the core wiring pattern from examples/openapi-scalar-students/main.go.

Highlights:

  • routes are grouped under /api
  • public read endpoints
  • protected write endpoints via With(authMW)
  • OpenAPI JSON is always mounted
  • Scalar docs are optional (toggle with a flag)
  • diagnostics are logged without crashing the server
  • optional “dump OpenAPI JSON to file” for CI/debugging
package main

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

	twadapter "codeberg.org/iaconlabs/jetwarp/adapter"
	v3 "codeberg.org/iaconlabs/jetwarp/openapi/oas3"
	"codeberg.org/iaconlabs/jetwarp/openapi/oas3/ui/scalar"
)

func main() {
	r := newRouter("stdlib") // or chi/echo/gin; see the example for CLI flags

	// ---- middleware (portable net/http) ----
	const maxTime = 5
	r.Use(
		twadapter.HTTPNamed("request_id", RequestID()),
		twadapter.HTTPNamed("recover", RecoverJSON()),
		twadapter.HTTPNamed("cors", CORS()),
		twadapter.HTTPNamed("timeout", Timeout(maxTime*time.Second)),
		twadapter.HTTPNamed("logger", AccessLog()),
	)

	store := NewStore()
	api := NewAPI(store, tokenFromEnv())

	// ---- routes ----
	apiR := r.Group("/api")
	apiR.HandleFunc(http.MethodGet, "/healthz", api.Healthz)

	students := apiR.Group("/students")

	// Public reads
	students.HandleFunc(http.MethodGet, "/", api.ListStudents)
	students.HandleFunc(http.MethodGet, "/{id}", api.GetStudent)
	students.HandleFunc(http.MethodGet, "/{id}/grades", api.ListGrades)

	// Protected writes (Auth middleware only for mutating endpoints)
	protected := students.With(twadapter.HTTPNamed("auth", api.Auth()))
	protected.HandleFunc(http.MethodPost, "/", api.CreateStudent)
	protected.HandleFunc(http.MethodPut, "/{id}", api.UpdateStudent)
	protected.HandleFunc(http.MethodDelete, "/{id}", api.DeleteStudent)

	protected.HandleFunc(http.MethodPost, "/{id}/grades", api.AddGrade)
	protected.HandleFunc(http.MethodPut, "/{id}/grades/{gid}", api.UpdateGrade)
	protected.HandleFunc(http.MethodDelete, "/{id}/grades/{gid}", api.DeleteGrade)

	// ---- OpenAPI ----
	rr, ok := any(r).(v3.RegistryRouter)
	if ok {
		cfg := v3.Config{
			Title:    "Students API",
			Version:  "0.1.0",
			JSONPath: "", // empty => default /openapi.json
		}

		// Scalar docs UI enabled:
		cfg.Docs = v3.DocsConfig{
			Path:     "/docs",           // set to "" to disable docs
			Provider: scalar.Provider{}, // CDN mode by default
			SpecURL:  "",                // empty => computed default
		}

		attached, diags := v3.Attach(rr, cfg)
		for _, d := range diags {
			log.Printf("openapi diag: code=%s msg=%q path=%q method=%q seq=%d",
				d.Code, d.Message, d.Path, d.Method, d.Seq,
			)
		}

		log.Printf("OpenAPI JSON mounted at %s", attached.JSONPath)
		log.Printf("Docs UI (Scalar) mounted at %s", cfg.Docs.Path)
	} else {
		log.Printf("openapi disabled: router does not expose registry snapshots (missing adapter.RegistryProvider)")
	}

	if err := r.Err(); err != nil {
		log.Printf("router configuration errors:\n%v", err)
		os.Exit(1)
	}

	_ = http.ListenAndServe(":8080", r)
}

What this gives you at runtime

Assuming defaults:

  • OpenAPI JSON: GET /openapi.json
  • Scalar docs: GET /docs (redirects to /docs/index.html)

The Scalar UI loads a single script (CDN by default) and fetches your OpenAPI JSON URL.


Scalar provider behavior (what gets mounted)

scalar.Provider{} mounts:

  • GET {DocsPath}redirect to {DocsPath}/index.html
  • GET {DocsPath}/index.html → serves HTML

Notes / gotchas:

  • jetwarp normalizes away trailing slashes in registration, so providers avoid relying on "/docs/" being distinct from "/docs".
  • if you need to self-host the Scalar script (air-gapped environments), set scalar.Provider{CDNURL: "https://.../scalar.js"}.

jetwarp ships a dev-only validator module powered by pb33f:

  • codeberg.org/iaconlabs/jetwarp/openapi/oas3/validate

Option A: validate the live server via URL

From the repo root:

go run ./openapi/oas3/validate/cmd/jetwarp-openapi-validate   -url "http://127.0.0.1:8080/openapi.json"

You can also use the alias flag name:

go run ./openapi/oas3/validate/cmd/jetwarp-openapi-validate   -openapi-url "http://127.0.0.1:8080/openapi.json"

Option B: validate a JSON file (great for CI artifacts)

go run ./openapi/oas3/validate/cmd/jetwarp-openapi-validate   -file ./openapi.json

To validate from stdin:

cat ./openapi.json | go run ./openapi/oas3/validate/cmd/jetwarp-openapi-validate -file -

Option C: validate in tests (library API)

If you want a “spec must validate” test, the validator exposes a small API:

ok, issues, err := validate.Document(jsonBytes)
if err != nil {
    t.Fatalf("validator setup failed: %v", err)
}
if !ok {
    t.Fatalf("spec invalid; issues: %+v", issues)
}

Troubleshooting checklist

  • Docs loads but spec fetch fails: confirm the UI is pointing at the correct SpecURL. If you have reverse proxies or mount OpenAPI under a different prefix, set Docs.SpecURL explicitly.

  • You changed routes but docs didn’t update: Attach snapshots the registry at call time. Call Attach after registration (once), not before.

  • You’re seeing OpenAPI diagnostics: these are non-fatal by design. Log them (like the example) and decide if your app should fail fast in production.


See the full runnable example

  • examples/openapi-scalar-students/ (router + API + Scalar docs + flags)

Getting Started (contributors)

This page is for people who want to contribute to jetwarp itself — whether that’s a bug fix, a new driver, a suite test, or documentation.


Prerequisites

  • Go 1.26+ — all modules in the repo declare go 1.26.0.
  • git
  • golangci-lint (for linting; install via the script or your package manager)

Optional but useful:

  • mdBook v0.4.40 — for building the docs site locally.

1) Clone

git clone https://codeberg.org/iaconlabs/jetwarp
cd jetwarp

2) Set up a workspace

jetwarp is a multi-module repository. For local development across modules, use a Go workspace:

go work init .
go work use \
  ./adapter/chi ./adapter/echo ./adapter/fiber ./adapter/gin ./adapter/stdlib \
  ./drivers/v1/chi ./drivers/v1/echo ./drivers/v1/fiber ./drivers/v1/gin ./drivers/v1/stdlib \
  ./openapi/oas3 ./openapi/oas3/validate
go work sync

The go.work file is not committed to the repo. It is a contributor convenience and is invisible to downstream users.


3) Run the tests

Quick check (all modules)

bash tools/test-all.sh

This runs go test ./... for every module in the workspace, including the OpenAPI smoke gate.

With the race detector:

TW_TEST_ALL_RACE=1 bash tools/test-all.sh

Module-by-module (if you don’t have a workspace)

go test ./...                       # root module
(cd adapter/chi && go test ./...)
(cd drivers/v1/chi && go test ./...)
(cd openapi/oas3 && go test ./...)

Lint

bash tools/lint-all.sh

4) Before opening a PR

Run the full gate that the release script checks:

bash tools/test-all.sh
bash tools/lint-all.sh

Then make sure:

  • gofmt -l . outputs nothing (no unformatted files)
  • go vet ./... passes (per module)
  • Any new driver or behavior change is covered by the conformance suite

See Tests & suite for guidance on adding suite tests.


5) Building the docs locally (optional)

# install mdBook once
curl -sSL https://github.com/rust-lang/mdBook/releases/download/v0.4.40/mdbook-v0.4.40-x86_64-unknown-linux-gnu.tar.gz \
  | tar -xz --directory /usr/local/bin

# live preview
mdbook serve docs

# static build
mdbook build docs

Project Structure

jetwarp is a multi-module repository. Each adapter and driver lives in its own go.mod so downstream users only download what they actually import.


Top-level layout

/
├── adapter/                # Core adapter + registry (root module)
├── compat/
│   └── routingpath/        # Public façade for path normalization (root module)
├── drv/                    # Driver interface: drv.Drv, capabilities, kinds (root module)
├── drivers/v1/             # Concrete drivers — each is a separate Go module
│   ├── chi/
│   ├── echo/
│   ├── fiber/
│   ├── gin/
│   └── stdlib/
├── internal/
│   └── routingpath/        # Implementation (not importable from submodules)
├── openapi/
│   └── oas3/               # OpenAPI 3.2 generator (separate module)
│       └── validate/       # Dev-only spec validator (separate module)
├── tests/
│   ├── suite/              # Conformance suite: RunDriver, RunAdapter, RunDriverSmoke
│   └── testkit/            # Mock drivers used by the suite
├── tools/                  # Shell scripts: test-all.sh, lint-all.sh, release.sh, …
├── docs/                   # This documentation site (mdBook)
├── examples/               # Runnable example applications
├── go.mod                  # Root module: codeberg.org/iaconlabs/jetwarp
├── errors.go               # Sentinel errors (ErrJetwarp, ErrNilDriver, …)
├── version.go              # Version constant
├── STABILITY.md            # Public API stability policy
├── CHANGELOG.md            # Release notes
└── RELEASE_GUIDE.md        # Operator runbook for releases

go.work is not committed. Contributors create it locally with go work init.


What lives in which module

Package pathModuleNotes
adapter/rootCore router, middleware composition, registry
adapter/chi/adapter/chiChi adapter wrapper (one-liner: core.New(driver.New()))
adapter/echo/adapter/echoEcho adapter wrapper
adapter/fiber/adapter/fiberFiber adapter wrapper
adapter/gin/adapter/ginGin adapter wrapper
adapter/stdlib/adapter/stdlibStdlib adapter wrapper
compat/routingpath/rootStable façade for submodules to import
drv/rootdrv.Drv interface, Capability, Kind, Method
drivers/v1/chi/drivers/v1/chichi driver implementation
drivers/v1/echo/drivers/v1/echoEcho v5 driver
drivers/v1/fiber/drivers/v1/fiberFiber v3 driver
drivers/v1/gin/drivers/v1/ginGin driver
drivers/v1/stdlib/drivers/v1/stdlibstdlib (net/http) driver
internal/routingpath/rootPath normalization + validation (implementation)
openapi/oas3/openapi/oas3OpenAPI 3.2 generator
openapi/oas3/validate/openapi/oas3/validatepb33f-powered validator (dev-only)
tests/suite/rootConformance test entry points
tests/testkit/rootMock drivers

Why compat/routingpath exists

Go’s internal/ visibility rules prevent submodules from importing packages under internal/ of the root module. Because drivers, adapters, and the OpenAPI module all need path normalization helpers, there is a thin public façade at compat/routingpath that re-exports the relevant functions.

compat/routingpath is classified as “Internal-stability” in STABILITY.md. It is not a primary user-facing API but is stable enough for drivers built inside or alongside this repo.


Key files in the root module

  • errors.go — all sentinel errors (ErrJetwarp, ErrNilDriver, ErrNativeMWUnsupported, …)
  • adapter/adapter.go — the Adapter and RegistryProvider interfaces
  • adapter/router_implementation.go — core adapter implementation
  • drv/driver.go — the Drv interface
  • drv/capabilities.go — capability bitmask and constants
  • tests/suite/driver_suite.goRunDriver entry point
  • tests/suite/adapter_suite.goRunAdapter entry point

Tools

ScriptPurpose
tools/test-all.shRun go test ./... for every module
tools/lint-all.shRun golangci-lint for every module
tools/open_api_smoke.shSmoke-test OpenAPI output
tools/release.shCreate and (optionally) push lockstep tags

The release runbook is in RELEASE_GUIDE.md.

Code Style

jetwarp follows standard Go idioms with a few explicit conventions.


Formatting

  • gofmt: all code must be gofmt-clean. The release gate checks this.
  • golangci-lint: run bash tools/lint-all.sh before opening a PR.

Error handling

No panics on bad input

Registration methods (Handle, Scope, Use, Group, With) must never panic. Bad input becomes a returned error. This is a compatibility contract enforced by the suite.

Sentinel errors

All errors wrap jetwarp.ErrJetwarp so errors.Is(err, jetwarp.ErrJetwarp) is always true. Concrete sentinels live in errors.go:

var ErrNilDriver = fmt.Errorf("%w: nil driver", ErrJetwarp)

Use %w wrapping, not string concatenation. This keeps errors.Is / errors.As working regardless of how many layers the error travels through.

Error codes vs message text

For OpenAPI diagnostics: DiagCode is stable (part of the public API). Message text is not stable and may change between minor versions. Code accordingly.


Package conventions

Driver packages

  • Implement drv.Drv in a file named <routername>.go.
  • Add var _ drv.Drv = (*Driver)(nil) as the first line after imports.
  • Keep safeRegister-style panic recovery in every registration path.
  • Tests go in a <pkgname>_test (black-box) package.

Adapter wrappers

Adapter packages are one-liners:

func New() core.Adapter { return core.New(driver.New()) }

Do not add business logic to adapter wrappers. If you need custom construction logic, add it to the driver.


Naming

  • Capabilities: Cap<FeatureName> (e.g. CapParams, CapAnyMethod).
  • Error sentinels: Err<Condition> (e.g. ErrNilDriver, ErrInvalidPattern).
  • Driver kinds: Kind<Framework> (e.g. KindChi, KindGin).
  • Suite entry points: Run<Thing> (e.g. RunDriver, RunAdapter, RunDriverSmoke).

Comments

  • Public API: full Godoc with contract semantics (not just “what it does” but “what it guarantees”).
  • Handle, Scope, Caps in driver implementations: comment the capability claim with the reason.
  • Any //nolint: directive must have a trailing comment explaining why.

Tests

  • Suite tests live in tests/suite/. They are the portability spec.
  • Driver-specific behavior (things not covered by the suite) belongs in <n>_regression_test.go.
  • Use t.Helper() in test helpers.
  • Test packages are black-box (package foo_test, not package foo).

Multi-module hygiene

  • Do not add replace directives in committed go.mod files. Replace directives are for local hacks; the workspace (go.work) is the contributor convenience.
  • go mod tidy must pass per module before a PR is merged.
  • All modules must declare go 1.26.0.

Tests & Suite

jetwarp’s portability guarantee is enforced by a shared conformance suite under tests/suite. This page explains how to run tests, what the suite covers, and how to add new contract tests.


Running tests

All modules at once

bash tools/test-all.sh

Runs go test ./... per module. Set TW_TEST_ALL_RACE=1 to enable the race detector.

One module

cd drivers/v1/chi && go test ./...
cd adapter/chi && go test ./...
cd openapi/oas3 && go test ./...

Lint

bash tools/lint-all.sh

The conformance suite

The suite lives in tests/suite/ and is importable by any module that needs to validate a driver or adapter.

Entry points

FunctionUsed byWhat it tests
suite.RunDriver(t, factory)Driver packagesRouting, params, scoping, method handling, no-panic contract
suite.RunDriverSmoke(t, factory)Driver packagesPractical route tree, concurrency, MethodAny fallback priority
suite.RunAdapter(t, factory)Adapter packagesLogical scoping, middleware ordering, sentinel errors, native MW handling
suite.RunDriverBenchmarks(b, factory)Driver packagesRouting + method benchmarks

How to call them

In your driver’s test package:

// drivers/v1/myrouter/driver_conformance_test.go
func TestDriverConformance(t *testing.T) {
    suite.RunDriver(t, suite.DriverFactory{
        Name: "myrouter",
        New:  func(t *testing.T) drv.Drv { return myrouter.New() },
    })
}

func TestDriverSmoke(t *testing.T) {
    suite.RunDriverSmoke(t, suite.DriverFactory{
        Name: "myrouter",
        New:  func(t *testing.T) drv.Drv { return myrouter.New() },
    })
}

In your adapter’s test package:

// adapter/myrouter/adapter_conformance_test.go
func TestAdapterConformance(t *testing.T) {
    suite.RunAdapter(t, suite.AdapterFactory{
        Name: "myrouter adapter",
        New:  func(t *testing.T) adapter.Adapter { return myadapter.New() },
    })
}

Capability-gated tests

Many suite tests are gated on capabilities:

if !d.Caps().Has(drv.CapParamSuffix) {
    t.Skipf("driver %s missing CapParamSuffix", f.Name)
    return
}

If your driver does not claim a capability, those tests are silently skipped — not failures. This means you can incrementally implement capabilities without breaking CI.

Go version requirement

The suite helper uses strings.SplitSeq (Go 1.24+). Your test module’s go.mod must declare at least go 1.24 to import and run the suite. Since the repo minimum is Go 1.26, this is automatically satisfied for all in-repo modules.


Adding a contract test

When you add a new portability guarantee (e.g. a new semantic rule for Group or a new capability), you typically need to add a suite test so every current and future driver/adapter must satisfy it.

Steps:

  1. Add the test to the appropriate file in tests/suite/ (or a new file if it’s a new battery).
  2. Gate it on a capability if the behavior is not universal:
    if !d.Caps().Has(drv.SomeNewCap) {
        t.Skip("...")
    }
    
  3. Run the full suite against all existing drivers to confirm they pass.
  4. If a driver should now claim the new capability, update its Caps() return value.

Logical scoping tests

RunAdapter includes logical scoping contract tests (testAdapterLogicalScopingContract). These require the adapter to implement adapter.RegistryProvider. Adapters built with core.New(driver.New()) implement it automatically.


Mock drivers (tests/testkit)

testkit.MockDriver is a deterministic in-memory driver for testing core semantics without a real router. It records every Handle call and serves by exact-match lookup.

testkit.LimitedMockDriver wraps MockDriver and lets you selectively disable capabilities, which is useful for testing how core or the suite reacts to missing caps.


What the suite does NOT cover

  • Framework-specific features (gin performance characteristics, fiber websockets, etc.)
  • Request body / response streaming edge cases
  • Production observability or graceful shutdown

These belong in driver-specific regression tests, not the shared suite.

Writing a New Driver

This page covers what you need to implement, how to structure your module, and how to verify correctness with the conformance suite. It applies both to drivers added inside this repo and to drivers maintained externally.

For a deeper reference (with full code examples), see also docs/driver_authors.md in the repo root.


When to write a driver

  • You want to support a router framework that jetwarp does not currently ship with.
  • You have an in-house router and want jetwarp’s portability guarantees and OpenAPI tooling.
  • You want to explore a new backend while keeping application code untouched.

The interface

type Drv interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
    Kind()   Kind
    Caps()   Capability
    Scope(prefix string) (Drv, error)
    Handle(method string, pattern string, h http.Handler) error
    Param(r *http.Request, key string) string
    Engine() any
    IsNil() bool
}

Implement all nine methods. None of them may panic. See the Drivers overview for the full contract of each method.


Module layout (in-repo drivers)

drivers/v1/<n>/
    go.mod                     ← module: codeberg.org/iaconlabs/jetwarp/drivers/v1/<n>
    <n>.go                     ← Driver struct + drv.Drv implementation
    driver_conformance_test.go ← RunDriver
    <n>_smoke_test.go          ← RunDriverSmoke
    <n>_regression_test.go     ← driver-specific edge cases (recommended)

adapter/<n>/
    go.mod
    <n>.go                     ← func New() adapter.Adapter { return core.New(driver.New()) }
    adapter_conformance_test.go ← RunAdapter

External drivers can use any module path and layout; the test expectations remain the same.


Key implementation rules

1) No panics during registration

Drivers must catch panics from the underlying framework and convert them to returned errors. The driver must remain usable after a failed registration.

func (d *Driver) safeRegister(op string, fn func()) (err error) {
    defer func() {
        if rec := recover(); rec != nil {
            err = fmt.Errorf("mydriver: panic during %s: %v", op, rec)
        }
    }()
    fn()
    return nil
}

This applies to Handle and Scope.

2) Capabilities are promises

Only claim a capability if the driver passes the full conformance suite for it. If uncertain, leave it out — the suite skips gated tests automatically.

CapParamSuffix requires CapParams. The suite validates this in newDriver() and fatals immediately if violated.

3) Pattern translation

jetwarp patterns use {name} syntax. Translate to your framework’s syntax before registering:

// gin/echo example: {id} → :id
func translate(p string) string {
    p = strings.ReplaceAll(p, "{", ":")
    return strings.ReplaceAll(p, "}", "")
}

Param(r, key) must still work with the original jetwarp key ("id", not ":id"). The adapter always calls Param(r, "id") regardless of internal syntax.

4) Import path for normalization helpers

Submodule drivers cannot import internal/routingpath directly. Use the public façade:

import "codeberg.org/iaconlabs/jetwarp/compat/routingpath"

5) Compile guard

var _ drv.Drv = (*Driver)(nil)

6) IsNil must be safe on nil receiver

func (d *Driver) IsNil() bool { return d == nil }

7) CapNativeScopeMW — do not implement in v1

This bit is reserved for v1.1. Do not implement native scope hooks based on it.


MethodAny ("*") implementation

Many routers do not support a true method wildcard with deterministic precedence. The standard approach: two-engine pattern.

  • Primary engine: method-specific routes (GET, POST, …)
  • Any engine: MethodAny routes ("*")

ServeHTTP tries primary first, then falls back to any. This guarantees method-specific beats MethodAny for the same path, regardless of registration order. The chi, gin, echo, and fiber drivers all use this pattern.


Running the conformance suite

// driver_conformance_test.go
func TestDriverConformance(t *testing.T) {
    suite.RunDriver(t, suite.DriverFactory{
        Name: "myrouter",
        New:  func(t *testing.T) drv.Drv { t.Helper(); return myrouter.New() },
    })
}

func TestDriverSmoke(t *testing.T) {
    suite.RunDriverSmoke(t, suite.DriverFactory{
        Name: "myrouter",
        New:  func(t *testing.T) drv.Drv { return myrouter.New() },
    })
}

RunDriver covers routing, method handling, and the no-panic contract. RunDriverSmoke covers a practical route tree with concurrency.

RunAdapter (for your adapter wrapper) additionally runs logical scoping contract tests, which require adapter.RegistryProvider. Adapters built with core.New(d) satisfy this automatically.


Checklist before opening a PR

[ ] var _ drv.Drv = (*Driver)(nil) compile guard present
[ ] Kind() returns a stable, non-empty string
[ ] Caps() only claims capabilities that pass the suite
[ ] CapParamSuffix not claimed without CapParams
[ ] Handle() never panics; panics caught and returned as errors
[ ] Scope() never panics; returns error for empty or root-only prefix
[ ] Param() returns "" for nil request or empty key
[ ] Param() uses jetwarp key ("id") regardless of internal framework syntax
[ ] IsNil() safe on nil receiver
[ ] suite.RunDriver passes
[ ] suite.RunDriverSmoke passes
[ ] suite.RunAdapter passes (if adapter wrapper included)
[ ] CapNativeScopeMW NOT implemented (reserved v1.1)
[ ] Uses compat/routingpath (not internal/routingpath)
[ ] go.mod declares go 1.26.0
[ ] go.mod dependency on codeberg.org/iaconlabs/jetwarp is versioned (not a replace directive)

jetwarp — New Developer Onboarding Guide

Welcome to the team. This document is your first-day map of the jetwarp codebase: what each layer does, how they talk to each other, and what rules we treat as compatibility contracts.

Read it top to bottom once, then keep it nearby as a reference.


1. The Big Picture

jetwarp is a portable Go HTTP routing + middleware + registration-error API.

The core promise is:

Application code registers routes and middleware against one stable API (adapter.Adapter). You can swap the underlying router backend (stdlib mux, chi, gin, echo, fiber, …) without rewriting your application routing setup.

Portability is not “best effort” — it is enforced by the shared conformance suite under tests/suite.

The layers

┌──────────────────────────────────────────────────────────┐
│ Your application                                         │
│   registerRoutes(r adapter.Adapter)                      │
│   r.Group("/v1", ...).HandleFunc("GET", "/users/{id}")   │
└───────────────────────┬──────────────────────────────────┘
                        │ adapter.Adapter
┌───────────────────────▼──────────────────────────────────┐
│ adapter.Router (core implementation)                     │
│   • validates + normalizes patterns                      │
│   • composes middleware deterministically                │
│   • records the registry snapshot                        │
│   • delegates registration + serving to a driver         │
└───────────────────────┬──────────────────────────────────┘
                        │ drv.Drv
┌───────────────────────▼──────────────────────────────────┐
│ drivers/v1/* (router drivers)                            │
│   • wrap a concrete router (chi/gin/echo/fiber/stdlib)   │
│   • translate canonical patterns to native syntax        │
│   • expose params + scoping through a common contract    │
└──────────────────────────────────────────────────────────┘

Your app should only depend on adapter.Adapter (and net/http). Drivers are internal plumbing — you touch them when writing a new driver or debugging a driver behavior.


2. Repository layout

jetwarp is intentionally multi-module. Each adapter and driver lives in its own go.mod so downstream users only download what they import.

Common directories you’ll touch:

  • adapter/ — public API + core router implementation (adapter.Router).
  • drv/ — driver contract: drv.Drv, capabilities, kinds, method canonicalization.
  • drivers/v1/* — concrete drivers (stdlib/chi/gin/echo/fiber).
  • compat/routingpath/ — public façade for canonical path normalization/validation. Used by drivers, adapters, and openapi when they need to import from a separate module (Go’s internal/ rules prevent submodules from importing internal/ directly).
  • internal/routingpath/ — actual implementation. Only importable within the root module.
  • tests/suite/ — conformance tests (the portability “spec”).
  • tests/testkit/ — deterministic mock drivers used by the suite.
  • openapi/oas3/ — optional OpenAPI 3.2 generator (separate module).
  • openapi/oas3/validate/ — optional dev-only validator wrapper (separate module).

For local development across modules, contributors commonly use a go.work workspace. The workspace is a convenience for contributors; consumers should never need it.


3. The driver contract (drv)

File: drv/driver.go

Every backend router is represented as a drv.Drv.

A few guiding principles:

  • Drivers are capability-driven: they declare what they can do via a bitmask (Caps()), and core/suite only relies on behavior when the capability is claimed.
  • Drivers must be typed-nil safe: a Go interface can hold a typed-nil pointer value that panics when methods are called. Drivers implement IsNil() so core can defend itself.

Capabilities are promises

File: drv/capabilities.go

Capabilities are not hints — they are promises verified by the suite.

CapabilityWhat it meansWhy it exists
CapScopeScope(prefix) returns a derived driver whose routes are reachable only under that prefix.Lets adapters implement Group(prefix) portably.
CapParamsCanonical {name} params are supported and values are retrievable.Enables portable path-params across backends.
CapParamSuffixIn-segment prefix/suffix patterns work (e.g. /x/{id}.json).Some routers can’t represent this without semantic changes.
CapAnyMethodHandle("*", …) is supported (method-agnostic routes).Enables a portable “any method” registration.
CapNativeScopeMWReserved — v1.1+. Not functional in v1. Defined to mark the slot for a future native-middleware-per-scope mechanism. Do not implement in v1.Future extension point; the interface contract will be defined in v1.1.

Rule of thumb: if you’re deciding behavior, prefer Caps() over Kind().

Kind() is for diagnostics and human labeling; Caps() is for correctness.


4. Canonical patterns (routingpath)

jetwarp routes on the URL path only (query string is ignored by matching).

Normalization

routingpath.NormalizePattern does the minimal normalization that keeps behavior predictable:

  • trims whitespace
  • ensures a leading /
  • removes trailing / (except /)
  • does not collapse internal // sequences

That last bullet is intentional: collapsing internal slashes can surprise callers, so validation focuses on brace correctness rather than rewriting paths.

Validation

routingpath.ValidatePattern enforces jetwarp’s canonical param rules (what drivers must agree on):

  • rejects empty param names ({})
  • rejects unbalanced braces in a segment
  • rejects multiple brace pairs in a segment
  • rejects invalid param names (containing /, {, })

If you see a route registration error, it is usually produced by NormalizePattern + ValidatePattern before the driver is touched.


5. The adapter (adapter.Router) — what apps use

Key files:

  • adapter/adapter.go (interfaces)
  • adapter/router_implementation.go (core implementation)

Scopes: Use, Group, With

jetwarp intentionally models routing configuration as a tree of immutable “scopes”:

  • Use(mw...) attaches middleware to the current scope.
  • Group(prefix, mw...) returns a derived scope with a path prefix and extra middleware.
  • With(mw...) returns a derived scope that adds middleware without changing the prefix.

With is explicitly non-mutating. That structural immutability is what prevents middleware “leaks” between sibling routes.

Deterministic middleware ordering

The contract is:

Use → Group → With → per-route → handler

Core assembles middleware by walking the scope chain (root → leaf) and wrapping in a deterministic order. Drivers do not get to reorder it.

Portable middleware vs native middleware

The public middleware surface is adapter.MW, which allows two categories:

  1. Portable net/http middleware (func(http.Handler) http.Handler) wrapped via adapter.HTTP(...) / adapter.HTTPNamed(...).
  2. Native middleware (framework-specific), represented as arbitrary adapter.MW values.

Important current reality: the built-in adapters shipped with jetwarp currently support portable middleware only.

  • Portable middleware is always accepted.
  • Any non-portable middleware passed to Use/Group/With/Handle is recorded as jetwarp.ErrNativeMWUnsupported and is not applied.
  • Documentation-only middleware (adapter.DocCarrier) is exempt and is extracted before middleware splitting.

Native middleware support is deliberately “future/third-party ready” — if an adapter chooses to support it, the suite enforces separation and ordering contracts.

Param portability: (*http.Request).PathValue

jetwarp bridges driver params into the stdlib-style API:

  • drivers expose params via drv.Drv.Param(r, key)
  • core copies them onto the request using req.SetPathValue(key, value)

That means handlers can read {id} via:

id := r.PathValue("id")

…and it works the same across all adapters.

“Never panic” registration

Bad registrations must not panic. jetwarp accumulates errors and keeps the router usable:

  • Handle/HandleFunc/Use/Group/With validate and record errors
  • registration panics from underlying frameworks are caught and converted to ordinary errors
  • all issues are surfaced through r.Err()

The goal is to make setup failures observable and testable without requiring recover() in application code.

Typed-nil safety

adapter.New(d) must never store a typed-nil driver.

Core treats d == nil || d.IsNil() as a nil driver, records jetwarp.ErrNilDriver, and nils out the stored driver to prevent later panics.


6. The registry snapshot

Every core router implements adapter.RegistryProvider via:

Registry() adapter.RegistrySnapshot

The snapshot is a deep-copied view of:

  • scope records (root/groups/with scopes)
  • route records (attempted registrations)
  • errors recorded at scope level and route level
  • merged documentation metadata (from DocCarrier middleware)

This registry is the “truth source” for downstream tooling.


7. OpenAPI 3.2 (optional module)

OpenAPI support is in the optional module:

  • codeberg.org/iaconlabs/jetwarp/openapi/oas3

Generation is deterministic and based on the registry snapshot.

The main UX entrypoint is:

attached, diags := oas3.Attach(router, oas3.Config{ ... })
  • JSON is mounted at Config.JSONPath (default /openapi.json).
  • Docs UI is mounted only when Config.Docs.Path != "" and Config.Docs.Provider != nil.
  • Diagnostics ([]oas3.Diagnostic) are non-fatal and ordered; codes are stable for CI.

Validation tooling lives in:

  • openapi/oas3/validate/cmd/jetwarp-openapi-validate

(The binary name follows the folder name; renaming it is a potential future cleanup.)


8. Conformance testing (tests/suite)

The suite is the portability spec.

  • suite.RunDriver runs the driver battery against a drv.Drv factory.
  • suite.RunAdapter runs adapter-level batteries against an adapter.Adapter factory.

Tests are capability-driven: a driver that does not claim CapParamSuffix will have suffix tests skipped.

The mock drivers (tests/testkit)

testkit.MockDriver is a deterministic in-memory driver used to validate core semantics.

testkit.LimitedMockDriver wraps MockDriver and lets tests “turn off” capabilities to exercise skip paths and error behavior.


9. Errors and diagnostics

jetwarp uses a sentinel error to make errors.Is checks reliable:

  • jetwarp.ErrJetwarp

Concrete errors wrap that sentinel (e.g. jetwarp.ErrInvalidPattern, jetwarp.ErrNilDriver, jetwarp.ErrNativeMWUnsupported).

For OpenAPI, oas3.Diagnostic.Code is the stable machine-readable contract. Message text is intentionally not treated as stable.


10. Local development workflow

A workspace makes it easy to run tests and tools across modules locally:

go work init
# add the modules you’re actively editing
go work use . ./adapter/chi ./drivers/v1/chi ./openapi/oas3

go work sync

go test ./...

Conservative (always works): module-by-module

# root
(go test ./...)

# adapters/drivers
(cd adapter/chi && go test ./...)
(cd drivers/v1/chi && go test ./...)

# openapi
(cd openapi/oas3 && go test ./...)
(cd openapi/oas3/validate && go test ./...)

11. Invariants (do not break these)

  1. No panics on bad registration. Convert to errors and keep the router usable.
  2. Deterministic middleware order: Use → Group → With → per-route → handler.
  3. Capabilities are promises. Only claim a cap if the driver passes the suite for it.
  4. Method-specific beats ANY ("*") for the same path+method, independent of registration order.
  5. Typed-nil driver safety: core must never store a typed-nil driver.
  6. Registry snapshots are immutable (deep-copied) and safe to read anytime.
  7. Group("") and Group("/") are no-ops (no error, no prefix change). Whitespace-only prefixes record ErrInvalidGroupPrefix and behave as no-ops.

If you change anything that touches one of these, you should expect to add or update suite contract tests.