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