Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A more complex router example

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

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

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

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


1) The full example

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

package main

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

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

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

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

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

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

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

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

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

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

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

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

type ctxKey string

const (
	ctxReqID ctxKey = "reqid"

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

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

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

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

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

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

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

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

type API struct {
	store *StudentStore
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2) What this example is doing (and why)

Router creation (adapter first)

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

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

Global middleware (Use)

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

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

adapter.HTTPNamed("name", mw)

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

Groups (Group) for prefixes

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

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

With for “temporary scopes”

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

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

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

Path parameters via {id} + PathValue

Routes use {id} parameters:

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

In handlers, prefer:

id := r.PathValue("id")

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

Error handling: Err() and the optional runtime guard

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

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

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


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

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

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

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

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


Next steps

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