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