Zum Inhalt springen

Eine REST API mit Go und Chi bauen

Praxisnahes Tutorial für eine produktionsreife REST API mit Go und Chi: Routing, Middleware, JSON-Verarbeitung, Fehlerantworten und Graceful Shutdown.

6 Min. Lesezeit
Diagramm der Struktur einer Go-Anwendung mit der Middleware-Kette des Chi-Routers und den Handler-Schichten

Go ist eine starke Wahl für HTTP-Services. Das Paket net/http der Standardbibliothek deckt die Grundlagen ab, aber ein leichtgewichtiger Router wie Chi liefert Pfadparameter, Middleware-Verkettung und Routengruppierung, ohne ein schweres Framework einzubinden. Chi bleibt nah an der Standardbibliothek — Handler sind weiterhin http.Handler-kompatibel, und Middleware folgt dem Standardmuster.

Dieses Tutorial baut eine vollständige REST API für einen Aufgabenverwaltungsdienst mit sauberer Fehlerbehandlung, Middleware und Graceful Shutdown.

Projektstruktur

Halte die Struktur flach, bis das Projekt mehr Struktur braucht. Eine vorschnelle Pakethierarchie verursacht mehr Komplexität, als sie löst.

task-api/
├── main.go              # Entry point, server setup
├── handler.go           # HTTP handlers
├── middleware.go         # Custom middleware
├── model.go             # Data types
├── store.go             # Data access layer
├── go.mod
└── go.sum

Den Router einrichten

Der Router von Chi komponiert Middleware und Routen zu einer lesbaren Kette.

gogo
// main.go
package main
 
import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
 
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)
 
func main() {
	store := NewMemoryStore()
	handler := NewTaskHandler(store)
 
	r := chi.NewRouter()
 
	// Global middleware stack
	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)
	r.Use(middleware.Timeout(30 * time.Second))
 
	// Routes
	r.Route("/api/v1/tasks", func(r chi.Router) {
		r.Get("/", handler.ListTasks)
		r.Post("/", handler.CreateTask)
		r.Route("/{taskID}", func(r chi.Router) {
			r.Get("/", handler.GetTask)
			r.Put("/", handler.UpdateTask)
			r.Delete("/", handler.DeleteTask)
		})
	})
 
	// Health check outside the versioned API
	r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok"}`))
	})
 
	srv := &http.Server{
		Addr:         ":8080",
		Handler:      r,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}
 
	// Graceful shutdown
	go func() {
		log.Printf("Server starting on %s", srv.Addr)
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("Server error: %v", err)
		}
	}()
 
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
 
	log.Println("Shutting down server...")
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatalf("Forced shutdown: %v", err)
	}
	log.Println("Server stopped")
}

Der Graceful-Shutdown-Block lauscht auf SIGINT/SIGTERM und gibt laufenden Requests dann 10 Sekunden Zeit, sich abzuschließen, bevor ein Stopp erzwungen wird. Ohne diesen Block beenden Deployments aktive Verbindungen.

Datenmodell und Store

Definiere die Datentypen und einen einfachen In-Memory-Store. In Produktion ersetzt du ihn durch eine datenbankgestützte Implementierung hinter demselben Interface.

gogo
// model.go
package main
 
import "time"
 
type Task struct {
	ID          string    `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description,omitempty"`
	Status      string    `json:"status"`
	CreatedAt   time.Time `json:"createdAt"`
	UpdatedAt   time.Time `json:"updatedAt"`
}
 
type CreateTaskRequest struct {
	Title       string `json:"title"`
	Description string `json:"description,omitempty"`
}
 
type UpdateTaskRequest struct {
	Title       *string `json:"title,omitempty"`
	Description *string `json:"description,omitempty"`
	Status      *string `json:"status,omitempty"`
}
gogo
// store.go
package main
 
import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"sync"
	"time"
)
 
type TaskStore interface {
	List() ([]Task, error)
	Get(id string) (Task, error)
	Create(req CreateTaskRequest) (Task, error)
	Update(id string, req UpdateTaskRequest) (Task, error)
	Delete(id string) error
}
 
type MemoryStore struct {
	mu    sync.RWMutex
	tasks map[string]Task
}
 
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{tasks: make(map[string]Task)}
}
 
func (s *MemoryStore) List() ([]Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	tasks := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		tasks = append(tasks, t)
	}
	return tasks, nil
}
 
func (s *MemoryStore) Get(id string) (Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	task, ok := s.tasks[id]
	if !ok {
		return Task{}, fmt.Errorf("task not found: %s", id)
	}
	return task, nil
}
 
func (s *MemoryStore) Create(req CreateTaskRequest) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	id := generateID()
	now := time.Now().UTC()
	task := Task{
		ID:          id,
		Title:       req.Title,
		Description: req.Description,
		Status:      "pending",
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	s.tasks[id] = task
	return task, nil
}
 
func (s *MemoryStore) Update(id string, req UpdateTaskRequest) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	task, ok := s.tasks[id]
	if !ok {
		return Task{}, fmt.Errorf("task not found: %s", id)
	}
	if req.Title != nil {
		task.Title = *req.Title
	}
	if req.Description != nil {
		task.Description = *req.Description
	}
	if req.Status != nil {
		task.Status = *req.Status
	}
	task.UpdatedAt = time.Now().UTC()
	s.tasks[id] = task
	return task, nil
}
 
func (s *MemoryStore) Delete(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tasks[id]; !ok {
		return fmt.Errorf("task not found: %s", id)
	}
	delete(s.tasks, id)
	return nil
}
 
func generateID() string {
	b := make([]byte, 8)
	rand.Read(b)
	return hex.EncodeToString(b)
}

Das sync.RWMutex erlaubt nebenläufige Lesezugriffe bei exklusiven Schreibzugriffen — korrekt für einen In-Memory-Store, auf den mehrere Goroutines zugreifen.

HTTP-Handler

Handler parsen Requests, rufen den Store auf und formatieren Antworten. Halte sie schlank — die Geschäftslogik gehört in den Store oder eine Service-Schicht.

gogo
// handler.go
package main
 
import (
	"encoding/json"
	"net/http"
	"strings"
 
	"github.com/go-chi/chi/v5"
)
 
type TaskHandler struct {
	store TaskStore
}
 
func NewTaskHandler(store TaskStore) *TaskHandler {
	return &TaskHandler{store: store}
}
 
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
	tasks, err := h.store.List()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Failed to list tasks")
		return
	}
	writeJSON(w, http.StatusOK, tasks)
}
 
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "taskID")
	task, err := h.store.Get(id)
	if err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeError(w, http.StatusNotFound, "Task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "Failed to get task")
		return
	}
	writeJSON(w, http.StatusOK, task)
}
 
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "Invalid JSON body")
		return
	}
	if req.Title == "" {
		writeError(w, http.StatusBadRequest, "Title is required")
		return
	}
	if len(req.Title) > 200 {
		writeError(w, http.StatusBadRequest, "Title must be 200 characters or less")
		return
	}
	task, err := h.store.Create(req)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "Failed to create task")
		return
	}
	writeJSON(w, http.StatusCreated, task)
}
 
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "taskID")
	var req UpdateTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "Invalid JSON body")
		return
	}
	if req.Status != nil {
		valid := map[string]bool{"pending": true, "in-progress": true, "done": true}
		if !valid[*req.Status] {
			writeError(w, http.StatusBadRequest, "Status must be pending, in-progress, or done")
			return
		}
	}
	task, err := h.store.Update(id, req)
	if err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeError(w, http.StatusNotFound, "Task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "Failed to update task")
		return
	}
	writeJSON(w, http.StatusOK, task)
}
 
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "taskID")
	if err := h.store.Delete(id); err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeError(w, http.StatusNotFound, "Task not found")
			return
		}
		writeError(w, http.StatusInternalServerError, "Failed to delete task")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

Response-Helper

Konsistente JSON-Antworten machen die API für Clients vorhersehbar.

gogo
// ❌ Inconsistent error responses
w.WriteHeader(500)
w.Write([]byte("something went wrong"))
// Client gets plain text sometimes, JSON other times
 
// ✅ Consistent JSON error envelope
type ErrorResponse struct {
	Error   string `json:"error"`
	Status  int    `json:"status"`
}
 
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}
 
func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, ErrorResponse{
		Error:  message,
		Status: status,
	})
}

Jede Fehlerantwort hat dieselbe Form. Clients können Fehler zuverlässig parsen, ohne den Content-Type prüfen zu müssen.

Eigene Middleware

Chi-Middleware folgt dem Standardmuster func(http.Handler) http.Handler.

gogo
// middleware.go
package main
 
import (
	"net/http"
	"strings"
)
 
// ContentTypeJSON ensures POST/PUT requests send JSON
func ContentTypeJSON(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost || r.Method == http.MethodPut {
			ct := r.Header.Get("Content-Type")
			if !strings.HasPrefix(ct, "application/json") {
				writeError(w, http.StatusUnsupportedMediaType,
					"Content-Type must be application/json")
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}
 
// CORS middleware for development
func CORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
 
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
 
		next.ServeHTTP(w, r)
	})
}

Wende Middleware auf verschiedenen Ebenen an — globale Middleware am Root-Router, routenspezifische Middleware an Sub-Routern. Die Middleware ContentTypeJSON verhindert kryptische Parse-Fehler, indem sie Nicht-JSON-Requests früh ablehnt.

Wichtige Erkenntnisse

  1. Chi bleibt nah an net/http — Handler sind Standard-http.Handler, Middleware ist func(http.Handler) http.Handler
  2. Setze Server-Timeouts explizit — ReadTimeout, WriteTimeout und IdleTimeout verhindern Ressourcenerschöpfung
  3. Implementiere Graceful Shutdown — fange SIGINT/SIGTERM ab und gib laufenden Requests Zeit zum Abschluss
  4. Nutze sync.RWMutex für nebenläufigen Zugriff — Lesesperren für Abfragen, Schreibsperren für Änderungen
  5. Validiere Eingaben auf Handler-Ebene — prüfe Pflichtfelder, erzwinge Längenlimits, validiere Enum-Werte
  6. Halte Fehlerantworten konsistent — ein standardisierter JSON-Fehlerumschlag macht Clients vorhersehbar
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX