Welcome
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() boolsafely. adapter.New(d)treatsd == nil || d.IsNil()as “nil driver” and recordsjetwarp.ErrNilDriverinstead 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) viaadapter.HTTP/adapter.HTTPNamed. - Any non-portable middleware passed to
Use/Group/With/Handleis currently rejected by the core router and recorded asjetwarp.ErrNativeMWUnsupported(documentation-only middleware is exempt; seeadapter.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
- Getting started: Quickstart
- Adapters: Adapter overview
- Drivers: Drivers overview
- OpenAPI: OpenAPI
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/v5when 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/stdlibcodeberg.org/iaconlabs/jetwarp/adapter/chicodeberg.org/iaconlabs/jetwarp/adapter/gincodeberg.org/iaconlabs/jetwarp/adapter/echocodeberg.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.1is the current stable release. Pin to a specific tag and update intentionally rather than using@latestin 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.workworkspace sogo 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 ./...)
Workspace (recommended when changing multiple modules)
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.workin your checkout, you can skipgo work init/useand just rungo 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:
- 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
- Preview in a local server:
mdbook serve docs
- 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
- Groups, With and Middleware — scoping and deterministic middleware order
- OpenAPI — build + attach OpenAPI from the registry snapshot
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/...) viaGroup - protected endpoints via
With - path parameters via
{id}andr.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 middlewareGroup(prefix, ...)scoped prefix + middlewareWith(...)scoped middleware without changing the prefixHandle(...)/HandleFunc(...)route registrationErr()accumulated registration errors- plus
ServeHTTPso the router itself is anhttp.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.
| Adapter | Good default when… | Notes |
|---|---|---|
stdlib | you want the smallest surface area, maximum portability | Based on net/http routing semantics |
chi | you already use chi, or you want a small, idiomatic router | Strong feature set, common in Go services |
gin | you’re in the gin ecosystem | Jetwarp wraps gin while keeping net/http handlers |
echo | you’re in the echo ecosystem | Jetwarp wraps echo while keeping net/http handlers |
fiber | you want Fiber’s performance model / ecosystem | Jetwarp 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 callErr()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
- Adapters overview — middleware ordering and scoping rules
- Drivers Overview — portable vs adapter-native middleware
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 needgin.HandlerFuncmiddleware, 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}.jsonpatterns: gin intentionally rejects these to avoid silently wrong param names. - Trying to use
gin.HandlerFuncmiddleware directly: built-in adapters do not apply native middleware; you will getjetwarp.ErrNativeMWUnsupported. Useadapter.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:
- reads route parameters from
fiber.Ctx - converts the Fiber request into a
*http.Request - installs path parameters into the request (see below)
- calls your
net/httphandler, 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 checkstrings.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 appAny— 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}.jsonpatterns: 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 supportsScope(prefix)with correct prefix semantics. -
CapParams
The driver supports canonical{name}parameters in patterns, and can return values viaParam(r, "name"). -
CapParamSuffix
The driver supports in-segment literals around a single param, such as/files/{id}.jsonor/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")thensc.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:
- The behavior is not universal across frameworks, and faking it would be surprising.
- The behavior has clear semantic rules (what must match, what must not, precedence rules, error cases).
- 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:
CapScopeonly (or even none) - v1: add
CapParamsonce param extraction is correct - v2: add
CapAnyMethodonce precedence is proven - v3: add
CapParamSuffixonly if you can preserve canonicalidnaming (noid.jsonsurprises)
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 — checklist for implementing a new driver
- Adapters overview — what application code should use
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:
- Choose capabilities honestly (
Caps()). - Normalize and validate input patterns at registration time.
- Translate canonical
{name}syntax into your engine’s pattern syntax. - Implement Scope(prefix) if you claim
CapScope. - Implement MethodAny if you claim
CapAnyMethod. - Ensure Param extraction works consistently (
Param(r, "id")). - 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 claimCapParams - ❌
/files/{id}.jsonis rejected (loud failure), unless you later add a correct implementation and claimCapParamSuffix
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 importingcodeberg.org/iaconlabs/jetwarp/internal/routingpathdirectly. 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:
- validate the handler is non-nil
- normalize pattern and join with prefix
- validate canonical braces rules (
routingpath.ValidatePattern) - translate pattern into engine syntax
- register on the correct engine (primary vs any)
- 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
*_testpackage (black-box style). This keeps you honest and matches the repo’s linting expectations.
2) Smoke suite (recommended)
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()
},
})
}
3) Driver-specific regression tests (highly recommended)
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:
- implement correct suffix/prefix behavior without changing the canonical param name
- add conformance tests that cover edge cases (infix, prefix+suffix, nesting with scopes)
- 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
- Drivers overview — the driver model, Kind vs Caps, and the portability contract
- Contributing: Tests & suite — how and why suite tests are structured
Groups, With, and Middleware
This chapter explains two closely related concepts:
- Scope composition: how
Group(...)andWith(...)build derived routers. - 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
Groupis 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
/apihas middlewareB - group
/v1has middlewareC - a
With(D)scope addsD - 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/v1group: 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:
request_id(globalUse)access_log(globalUse)timeout_3s(group/api)auth(theWith(...)scope)rate_limit(per-route)deleteUserhandler
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:
- Portable net/http middleware: works everywhere.
- 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:
- validates the middleware slice (e.g., detects nil middleware)
- splits documentation-only middleware (
DocCarrier) from runtime middleware (so docs metadata never breaks routing) - splits runtime middleware into portable vs native categories
- records any errors into:
- the router’s accumulated
Err()list - and the registry snapshot (scope-level or route-level errors)
- the router’s accumulated
- 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:
- Derived scopes inherit parent middleware, then add their own.
- 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/Withto 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) viaGroup - 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/healthzGET /api/v1/studentsGET /api/v1/students/{id}
Protected (Bearer token):
POST /api/v1/studentsPUT /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-referencescript from a CDN - it only needs your OpenAPI JSON URL
- jetwarp’s provider mounts a redirect entrypoint and a working
index.htmlpage
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:
- Builds an OpenAPI 3.2 document from
router.Registry()and emits deterministic JSON. - Mounts endpoints on the router:
- always:
GET cfg.JSONPath→ OpenAPI JSON (default:/openapi.json) - optionally: docs UI under
cfg.Docs.Pathwhencfg.Docs.Path != ""andcfg.Docs.Provider != nil(misconfigurations don’t panic; they emit diagnostics)
- always:
A key safety goal: Attach must not panic. Even if JSON generation fails, it still mounts a minimal fallback JSON endpoint.
Best practice: call
Attachafter you register routes (once), then checkr.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.htmlGET {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"}.
Validating your OpenAPI output (recommended)
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, setDocs.SpecURLexplicitly. -
You changed routes but docs didn’t update:
Attachsnapshots the registry at call time. CallAttachafter 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.workfile 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.workis not committed. Contributors create it locally withgo work init.
What lives in which module
| Package path | Module | Notes |
|---|---|---|
adapter/ | root | Core router, middleware composition, registry |
adapter/chi/ | adapter/chi | Chi adapter wrapper (one-liner: core.New(driver.New())) |
adapter/echo/ | adapter/echo | Echo adapter wrapper |
adapter/fiber/ | adapter/fiber | Fiber adapter wrapper |
adapter/gin/ | adapter/gin | Gin adapter wrapper |
adapter/stdlib/ | adapter/stdlib | Stdlib adapter wrapper |
compat/routingpath/ | root | Stable façade for submodules to import |
drv/ | root | drv.Drv interface, Capability, Kind, Method |
drivers/v1/chi/ | drivers/v1/chi | chi driver implementation |
drivers/v1/echo/ | drivers/v1/echo | Echo v5 driver |
drivers/v1/fiber/ | drivers/v1/fiber | Fiber v3 driver |
drivers/v1/gin/ | drivers/v1/gin | Gin driver |
drivers/v1/stdlib/ | drivers/v1/stdlib | stdlib (net/http) driver |
internal/routingpath/ | root | Path normalization + validation (implementation) |
openapi/oas3/ | openapi/oas3 | OpenAPI 3.2 generator |
openapi/oas3/validate/ | openapi/oas3/validate | pb33f-powered validator (dev-only) |
tests/suite/ | root | Conformance test entry points |
tests/testkit/ | root | Mock 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— theAdapterandRegistryProviderinterfacesadapter/router_implementation.go— core adapter implementationdrv/driver.go— theDrvinterfacedrv/capabilities.go— capability bitmask and constantstests/suite/driver_suite.go—RunDriverentry pointtests/suite/adapter_suite.go—RunAdapterentry point
Tools
| Script | Purpose |
|---|---|
tools/test-all.sh | Run go test ./... for every module |
tools/lint-all.sh | Run golangci-lint for every module |
tools/open_api_smoke.sh | Smoke-test OpenAPI output |
tools/release.sh | Create 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.shbefore 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.Drvin 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,Capsin 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, notpackage foo).
Multi-module hygiene
- Do not add
replacedirectives in committedgo.modfiles. Replace directives are for local hacks; the workspace (go.work) is the contributor convenience. go mod tidymust 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
| Function | Used by | What it tests |
|---|---|---|
suite.RunDriver(t, factory) | Driver packages | Routing, params, scoping, method handling, no-panic contract |
suite.RunDriverSmoke(t, factory) | Driver packages | Practical route tree, concurrency, MethodAny fallback priority |
suite.RunAdapter(t, factory) | Adapter packages | Logical scoping, middleware ordering, sentinel errors, native MW handling |
suite.RunDriverBenchmarks(b, factory) | Driver packages | Routing + 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:
- Add the test to the appropriate file in
tests/suite/(or a new file if it’s a new battery). - Gate it on a capability if the behavior is not universal:
if !d.Caps().Has(drv.SomeNewCap) { t.Skip("...") } - Run the full suite against all existing drivers to confirm they pass.
- 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’sinternal/rules prevent submodules from importinginternal/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.
| Capability | What it means | Why it exists |
|---|---|---|
CapScope | Scope(prefix) returns a derived driver whose routes are reachable only under that prefix. | Lets adapters implement Group(prefix) portably. |
CapParams | Canonical {name} params are supported and values are retrievable. | Enables portable path-params across backends. |
CapParamSuffix | In-segment prefix/suffix patterns work (e.g. /x/{id}.json). | Some routers can’t represent this without semantic changes. |
CapAnyMethod | Handle("*", …) is supported (method-agnostic routes). | Enables a portable “any method” registration. |
CapNativeScopeMW | Reserved — 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:
- Portable net/http middleware (
func(http.Handler) http.Handler) wrapped viaadapter.HTTP(...)/adapter.HTTPNamed(...). - Native middleware (framework-specific), represented as arbitrary
adapter.MWvalues.
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/Handleis recorded asjetwarp.ErrNativeMWUnsupportedand 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/Withvalidate 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
DocCarriermiddleware)
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 != ""andConfig.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.RunDriverruns the driver battery against adrv.Drvfactory.suite.RunAdapterruns adapter-level batteries against anadapter.Adapterfactory.
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
Recommended (contributors): go.work
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)
- No panics on bad registration. Convert to errors and keep the router usable.
- Deterministic middleware order:
Use → Group → With → per-route → handler. - Capabilities are promises. Only claim a cap if the driver passes the suite for it.
- Method-specific beats ANY (
"*") for the same path+method, independent of registration order. - Typed-nil driver safety: core must never store a typed-nil driver.
- Registry snapshots are immutable (deep-copied) and safe to read anytime.
Group("")andGroup("/")are no-ops (no error, no prefix change). Whitespace-only prefixes recordErrInvalidGroupPrefixand behave as no-ops.
If you change anything that touches one of these, you should expect to add or update suite contract tests.