← Volver al listado de tecnologías

Proyecto Final: API REST con Stdlib

Por: Artiko
goapirestproyectohttp

En este capitulo final construiremos una API REST completa de gestion de tareas usando exclusivamente la biblioteca estandar de Go. Sin frameworks, sin dependencias externas.

Estructura del Proyecto

tasks-api/
├── main.go
├── model.go
├── store.go
├── handlers.go
├── middleware.go
└── main_test.go

Modelo

Definimos el modelo Task con tags JSON para la serializacion:

// model.go
package main

import "time"

type Task struct {
    ID          string    `json:"id"`
    Titulo      string    `json:"titulo"`
    Descripcion string    `json:"descripcion,omitempty"`
    Completada  bool      `json:"completada"`
    CreadaEn    time.Time `json:"creada_en"`
}

Almacenamiento en Memoria

Usamos sync.RWMutex para acceso concurrente seguro:

// store.go
package main

import (
    "fmt"
    "sync"
    "time"
)

type TaskStore struct {
    mu     sync.RWMutex
    tasks  map[string]Task
    nextID int
}

func NewTaskStore() *TaskStore {
    return &TaskStore{
        tasks: make(map[string]Task),
    }
}

func (s *TaskStore) All() []Task {
    s.mu.RLock()
    defer s.mu.RUnlock()
    result := make([]Task, 0, len(s.tasks))
    for _, t := range s.tasks {
        result = append(result, t)
    }
    return result
}

func (s *TaskStore) Get(id string) (Task, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    t, ok := s.tasks[id]
    return t, ok
}

func (s *TaskStore) Create(t Task) Task {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.nextID++
    t.ID = fmt.Sprintf("%d", s.nextID)
    t.CreadaEn = time.Now()
    s.tasks[t.ID] = t
    return t
}

func (s *TaskStore) Update(id string, t Task) (Task, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    existing, ok := s.tasks[id]
    if !ok {
        return Task{}, false
    }
    t.ID = id
    t.CreadaEn = existing.CreadaEn
    s.tasks[id] = t
    return t, true
}

func (s *TaskStore) Delete(id string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    _, ok := s.tasks[id]
    if ok {
        delete(s.tasks, id)
    }
    return ok
}
MetodoLockDescripcion
All()RLockLectura multiple concurrente
Get()RLockLectura por ID
Create()LockEscritura exclusiva
Update()LockEscritura exclusiva
Delete()LockEscritura exclusiva

Handlers CRUD

Cada handler maneja una operacion especifica:

// handlers.go
package main

import (
    "encoding/json"
    "net/http"
)

type TaskHandler struct {
    store *TaskStore
}

func NewTaskHandler(store *TaskStore) *TaskHandler {
    return &TaskHandler{store: store}
}

func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request) {
    tasks := h.store.All()
    writeJSON(w, http.StatusOK, tasks)
}

func (h *TaskHandler) GetByID(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    task, ok := h.store.Get(id)
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{
            "error": "tarea no encontrada",
        })
        return
    }
    writeJSON(w, http.StatusOK, task)
}

func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
    var task Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{
            "error": "JSON invalido",
        })
        return
    }
    if task.Titulo == "" {
        writeJSON(w, http.StatusBadRequest, map[string]string{
            "error": "titulo es requerido",
        })
        return
    }
    created := h.store.Create(task)
    writeJSON(w, http.StatusCreated, created)
}

func (h *TaskHandler) Update(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    var task Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{
            "error": "JSON invalido",
        })
        return
    }
    updated, ok := h.store.Update(id, task)
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{
            "error": "tarea no encontrada",
        })
        return
    }
    writeJSON(w, http.StatusOK, updated)
}

func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    if !h.store.Delete(id) {
        writeJSON(w, http.StatusNotFound, map[string]string{
            "error": "tarea no encontrada",
        })
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

Middleware

Dos middlewares: logging y content-type:

// middleware.go
package main

import (
    "log"
    "net/http"
    "time"
)

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}

func JSONContentType(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        next.ServeHTTP(w, r)
    })
}

Router y Main

Usamos http.ServeMux de Go 1.22+ con patrones de metodo:

// main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {
    store := NewTaskStore()
    handler := NewTaskHandler(store)

    mux := http.NewServeMux()

    // Go 1.22: METHOD /pattern
    mux.HandleFunc("GET /tasks", handler.List)
    mux.HandleFunc("GET /tasks/{id}", handler.GetByID)
    mux.HandleFunc("POST /tasks", handler.Create)
    mux.HandleFunc("PUT /tasks/{id}", handler.Update)
    mux.HandleFunc("DELETE /tasks/{id}", handler.Delete)

    // Health check
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
    })

    // Aplicar middlewares
    wrapped := LoggingMiddleware(JSONContentType(mux))

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    addr := fmt.Sprintf(":%s", port)
    log.Printf("Servidor iniciado en %s", addr)
    log.Fatal(http.ListenAndServe(addr, wrapped))
}

Tabla de Endpoints

MetodoRutaDescripcionStatus
GET/tasksListar todas200
GET/tasks/{id}Obtener por ID200 / 404
POST/tasksCrear tarea201 / 400
PUT/tasks/{id}Actualizar tarea200 / 404
DELETE/tasks/{id}Eliminar tarea204 / 404
GET/healthHealth check200

Tests con httptest

// main_test.go
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func setupRouter() http.Handler {
    store := NewTaskStore()
    handler := NewTaskHandler(store)
    mux := http.NewServeMux()
    mux.HandleFunc("GET /tasks", handler.List)
    mux.HandleFunc("GET /tasks/{id}", handler.GetByID)
    mux.HandleFunc("POST /tasks", handler.Create)
    mux.HandleFunc("PUT /tasks/{id}", handler.Update)
    mux.HandleFunc("DELETE /tasks/{id}", handler.Delete)
    return mux
}

func TestCreateTask(t *testing.T) {
    router := setupRouter()
    body := `{"titulo":"Aprender Go","descripcion":"Completar tutorial"}`
    req := httptest.NewRequest("POST", "/tasks", bytes.NewBufferString(body))
    w := httptest.NewRecorder()

    router.ServeHTTP(w, req)

    if w.Code != http.StatusCreated {
        t.Fatalf("esperado %d, obtenido %d", http.StatusCreated, w.Code)
    }

    var task Task
    json.NewDecoder(w.Body).Decode(&task)
    if task.Titulo != "Aprender Go" {
        t.Fatalf("titulo esperado 'Aprender Go', obtenido '%s'", task.Titulo)
    }
    if task.ID == "" {
        t.Fatal("ID no deberia estar vacio")
    }
}

func TestListTasks(t *testing.T) {
    router := setupRouter()

    // Crear una tarea primero
    body := `{"titulo":"Tarea 1"}`
    req := httptest.NewRequest("POST", "/tasks", bytes.NewBufferString(body))
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)

    // Listar tareas
    req = httptest.NewRequest("GET", "/tasks", nil)
    w = httptest.NewRecorder()
    router.ServeHTTP(w, req)

    if w.Code != http.StatusOK {
        t.Fatalf("esperado %d, obtenido %d", http.StatusOK, w.Code)
    }

    var tasks []Task
    json.NewDecoder(w.Body).Decode(&tasks)
    if len(tasks) != 1 {
        t.Fatalf("esperada 1 tarea, obtenidas %d", len(tasks))
    }
}

func TestGetTaskNotFound(t *testing.T) {
    router := setupRouter()
    req := httptest.NewRequest("GET", "/tasks/999", nil)
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    if w.Code != http.StatusNotFound {
        t.Fatalf("esperado %d, obtenido %d", http.StatusNotFound, w.Code)
    }
}

func TestCreateTaskValidation(t *testing.T) {
    router := setupRouter()
    body := `{"descripcion":"sin titulo"}`
    req := httptest.NewRequest("POST", "/tasks", bytes.NewBufferString(body))
    w := httptest.NewRecorder()
    router.ServeHTTP(w, req)
    if w.Code != http.StatusBadRequest {
        t.Fatalf("esperado %d, obtenido %d", http.StatusBadRequest, w.Code)
    }
}

Ejecutar los tests:

go test -v ./...

Ejemplo de Uso con curl

Iniciar el servidor:

go run .
# Servidor iniciado en :8080
# Crear tarea
curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Aprender Go","descripcion":"Completar el tutorial"}'

# Listar tareas
curl http://localhost:8080/tasks

# Obtener por ID
curl http://localhost:8080/tasks/1

# Actualizar
curl -X PUT http://localhost:8080/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Aprender Go","completada":true}'

# Eliminar
curl -X DELETE http://localhost:8080/tasks/1

Resumen del Proyecto

ComponenteArchivoResponsabilidad
Modelomodel.goEstructura Task con JSON tags
Storestore.goCRUD en memoria con RWMutex
Handlershandlers.goLogica HTTP, validacion, respuestas
Middlewaremiddleware.goLogging y Content-Type
Routermain.goRutas, configuracion, arranque
Testsmain_test.goTests con httptest
Concepto Go AplicadoDonde
Structs y metodosStore, Handler
Interfaces (http.Handler)Middleware
Goroutine safety (sync.RWMutex)Store
JSON encoding/decodingHandlers
Testing con httptestTests
ServeMux Go 1.22 con patronesRouter
Variables de entornoConfiguracion de puerto

← Capitulo 21: Generics | Indice del Tutorial