Construyendo una REST API con Go y Chi
Tutorial práctico para una REST API lista para producción con Go y Chi: enrutamiento, middleware, JSON, respuestas de error y apagado controlado.

Go es una excelente opción para servicios HTTP. El paquete net/http de la biblioteca estándar cubre lo fundamental, pero añadir un router ligero como Chi te da parámetros de ruta, encadenamiento de middleware y agrupación de rutas sin arrastrar un framework pesado. Chi se mantiene cercano a la biblioteca estándar: los handlers siguen siendo compatibles con http.Handler y el middleware sigue el patrón estándar.
Este tutorial construye una REST API completa para un servicio de gestión de tareas con manejo de errores adecuado, middleware y apagado controlado.
Estructura del proyecto
Mantén la estructura plana hasta que el proyecto necesite más organización. Una jerarquía de paquetes prematura añade más complejidad de la que resuelve.
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
Configurando el router
El router de Chi compone middleware y rutas en una cadena legible.
// 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")
}El bloque de apagado controlado escucha las señales SIGINT/SIGTERM y luego da a las peticiones en curso 10 segundos para completarse antes de forzar la detención. Sin esto, los despliegues matan las conexiones activas.
Modelo de datos y store
Define los tipos de datos y un store simple en memoria. En producción, reemplázalo con una implementación respaldada por una base de datos detrás de la misma interfaz.
// 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"`
}// 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)
}El sync.RWMutex permite lecturas concurrentes con escrituras exclusivas: correcto para un store en memoria accedido por múltiples goroutines.
Handlers HTTP
Los handlers parsean las peticiones, llaman al store y formatean las respuestas. Mantenlos ligeros: la lógica de negocio pertenece al store o a una capa de servicio.
// 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)
}Helpers de respuesta
Las respuestas JSON consistentes hacen que la API sea predecible para los clientes.
// ❌ 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,
})
}Todas las respuestas de error tienen la misma forma. Los clientes pueden parsear los errores de forma fiable sin comprobar el Content-Type.
Middleware personalizado
El middleware de Chi sigue el patrón estándar func(http.Handler) http.Handler.
// 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)
})
}Aplica el middleware en distintos ámbitos: middleware global en el router raíz, middleware específico de rutas en sub-routers. El middleware ContentTypeJSON evita errores de parseo crípticos rechazando pronto las peticiones que no son JSON.
Puntos clave
- Chi se mantiene cercano a
net/http: los handlers sonhttp.Handlerestándar, el middleware esfunc(http.Handler) http.Handler - Configura los timeouts del servidor explícitamente:
ReadTimeout,WriteTimeouteIdleTimeoutevitan el agotamiento de recursos - Implementa el apagado controlado: captura SIGINT/SIGTERM y da tiempo a las peticiones en curso para completarse
- Usa
sync.RWMutexpara el acceso concurrente: bloqueos de lectura para consultas, bloqueos de escritura para mutaciones - Valida la entrada a nivel de handler: comprueba los campos obligatorios, impone límites de longitud, valida los valores de enumeración
- Mantén las respuestas de error consistentes: un sobre de error JSON estándar hace que los clientes sean predecibles


