Saltar al contenido

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.

6 min de lectura
Diagrama de la estructura de una aplicación Go que muestra la cadena de middleware del router Chi y las capas de handlers

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.

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")
}

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.

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)
}

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.

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)
}

Helpers de respuesta

Las respuestas JSON consistentes hacen que la API sea predecible para los clientes.

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,
	})
}

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.

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)
	})
}

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

  1. Chi se mantiene cercano a net/http: los handlers son http.Handler estándar, el middleware es func(http.Handler) http.Handler
  2. Configura los timeouts del servidor explícitamente: ReadTimeout, WriteTimeout e IdleTimeout evitan el agotamiento de recursos
  3. Implementa el apagado controlado: captura SIGINT/SIGTERM y da tiempo a las peticiones en curso para completarse
  4. Usa sync.RWMutex para el acceso concurrente: bloqueos de lectura para consultas, bloqueos de escritura para mutaciones
  5. Valida la entrada a nivel de handler: comprueba los campos obligatorios, impone límites de longitud, valida los valores de enumeración
  6. Mantén las respuestas de error consistentes: un sobre de error JSON estándar hace que los clientes sean predecibles
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX