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

Basic REST example

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

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

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

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


Why this example looks the way it does

1) Keep the application surface stable

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

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

2) Use portable middleware by default

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

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

3) Treat route registration as “config time”

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


The example API

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

Public:

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

Protected (Bearer token):

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

Full example (single file)

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

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

package main

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

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

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

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

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

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

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

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

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

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

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

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

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

type ctxKey string

const (
	ctxReqID ctxKey = "reqid"

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

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

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

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

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

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

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

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

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

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

type API struct {
	store *Store
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

How to test it quickly

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

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

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

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

Small things to adjust for real services

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

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

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