12  3. Necesitamos responder a URLs y verbos

Qué es exactamente un endpoint y qué promete

NotaEn una frase

Vas a construir tu primer CRUD real: cinco endpoints (GET, POST, DELETE…) que listan, crean y borran tareas en memoria — y a diseñar la tabla verbo+ruta que es el contrato de la API.

12.1 El problema

Taskflow necesita su primera funcionalidad real: gestionar tareas. El frontend (que ya sabes escribir) va a hacer esto:

await fetch("/api/tasks");                        // listar
await fetch("/api/tasks", { method: "POST", … }); // crear
await fetch("/api/tasks/42");                     // ver una
await fetch("/api/tasks/42", { method: "DELETE" });// borrar

Cada una de esas llamadas es una promesa que tu servidor debe cumplir: qué ruta atiende, qué verbo significa qué, qué código de estado devuelve. Diseñar eso es diseñar la API — y es una conversación de equipo antes que de código.

return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.

12.2 Cómo lo resuelve un equipo

Antes de escribir handlers, un equipo se sienta sobre una tabla así:

Verbo + ruta Significado Éxito Error esperado
GET /tasks listar todas 200 + array —
POST /tasks crear 201 + tarea creada 400 body inválido
GET /tasks/{id} una tarea 200 + tarea 404 no existe
PATCH /tasks/{id} modificar parcial 200 + tarea 404, 400
DELETE /tasks/{id} borrar 204 sin body 404

Dos decisiones de diseño ya están tomadas en esa tabla — fíjate:

  • Sustantivos plurales, no verbos en la URL: /tasks, no /getTasks ni /createTask. El verbo ya lo pone el método HTTP.
  • Los códigos de estado son parte del contrato: 201 dice “se creó”, 204 dice “salió bien y no hay nada que devolver”, 404 dice “eso no existe”. Devolver 200 con {"error": "not found"} es mentirle al cliente.

12.3 Conceptos nuevos

  • U Endpoint: la pareja (verbo + ruta) que atiende un handler. GET /tasks y POST /tasks son dos endpoints distintos aunque compartan ruta.
  • U Path params (/tasks/{id}) vs query params (/tasks?status=done): el path identifica qué recurso; el query modifica cómo se devuelve (filtros, orden, paginación).

Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.

  • U Idempotencia (primera aparición): GET, PUT, DELETE repetidos dan el mismo resultado; POST no — cada llamada crea otra tarea. Esto importa cuando el frontend reintenta.
  • TS Py Go Tres sintaxis de routing: cadena con :id (Express), decorator + firma (FastAPI), patrón "GET /tasks/{id}" en ServeMux (Go 1.22+).

Una firma (HMAC) prueba que un mensaje es auténtico y no fue tocado: se calcula con un secreto sobre el contenido. Los webhooks la usan para que verifiques “esto realmente vino de Stripe”.

12.4 La explicación visual

flowchart LR
  Req["POST /api/tasks<br/>{ title: 'x' }"] --> M{"Mux / Router<br/>¿existe ruta+verbo?"}
  M -->|"no"| R404["404 Not Found"]
  M -->|"sí"| H["handler createTask"]
  H -->|"éxito"| R201["201 Created + tarea"]
  H -->|"body malo"| R400["400 Bad Request"]

El router es un despachador: compara (método, path) contra la tabla de rutas registradas y entrega el request al handler correspondiente — o responde 404 él mismo si nadie reclamó la ruta.

12.5 Implementación

CRUD completo en memoria. El “modelo” es intencionalmente una lista — la base de datos llega en la Parte 2; hoy nos importa el contrato HTTP.

Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.

La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.

Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.

// Routing a mano: así se siente sin Express
import { createServer } from "node:http";

createServer((req, res) => {
  const parts = req.url!.split("/");          // "/tasks/42" → ["","tasks","42"]
  if (req.method === "GET" && parts[1] === "tasks" && !parts[2]) {
    res.end(JSON.stringify(tasks));
  } else if (req.method === "GET" && parts[1] === "tasks") {
    const id = Number(parts[2]);              // path param manual
    // …find, 404, res.end…
  }
  // …un if anidado por cada (método, ruta)
}).listen(8000);

Esto funciona — y es exactamente lo que Express abstrae: parsing de path params, dispatch por método, helpers de respuesta.

// src/index.ts — el canónico del libro
import express from "express";

const app = express();
app.use(express.json());

interface Task {
  id: number;
  title: string;
  done: boolean;
}

const tasks: Task[] = [];
let nextId = 1;

app.get("/tasks", (_req, res) => {
  res.json(tasks);
});

app.post("/tasks", (req, res) => {
  const { title } = req.body ?? {};
  if (typeof title !== "string" || !title.trim()) {
    return res.status(400).json({ error: "title is required" });
  }
  const task: Task = { id: nextId++, title, done: false };
  tasks.push(task);
  res.status(201).json(task);
});

app.get("/tasks/:id", (req, res) => {
  const task = tasks.find((t) => t.id === Number(req.params.id));
  if (!task) return res.status(404).json({ error: "task not found" });
  res.json(task);
});

app.delete("/tasks/:id", (req, res) => {
  const i = tasks.findIndex((t) => t.id === Number(req.params.id));
  if (i === -1) return res.status(404).json({ error: "task not found" });
  tasks.splice(i, 1);
  res.status(204).send();
});

app.listen(8000);
// Hono — las mismas rutas, API casi idéntica, corre en edge/bun/deno
import { Hono } from "hono";
const app = new Hono();

app.get("/tasks", (c) => c.json(tasks));
app.get("/tasks/:id", (c) => {
  const task = tasks.find((t) => t.id === Number(c.req.param("id")));
  return task ? c.json(task) : c.json({ error: "not found" }, 404);
});

Qué te cuesta: Hono/Fastify son swaps casi directos — mismo modelo, APIs más rápidas/modernas. NestJS es otro planeta: decoradores @Get("/tasks") sobre clases con DI — más estructura, más framework.

# Routing manual: lo que FastAPI hace por ti
from http.server import BaseHTTPRequestHandler

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/tasks":
            ...  # listar
        elif self.path.startswith("/tasks/"):
            task_id = int(self.path.split("/")[2])  # path param a mano
            ...
# app/main.py — el canónico del libro
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class Task(BaseModel):
    id: int
    title: str
    done: bool = False

class TaskCreate(BaseModel):
    title: str

tasks: list[Task] = []
next_id = 1

@app.get("/tasks")
def list_tasks() -> list[Task]:
    return tasks

@app.post("/tasks", status_code=201)
def create_task(payload: TaskCreate) -> Task:
    global next_id
    task = Task(id=next_id, title=payload.title)
    next_id += 1
    tasks.append(task)
    return task

@app.get("/tasks/{task_id}")
def get_task(task_id: int) -> Task:
    for t in tasks:
        if t.id == task_id:
            return t
    raise HTTPException(status_code=404, detail="task not found")

@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
    for i, t in enumerate(tasks):
        if t.id == task_id:
            tasks.pop(i)
            return
    raise HTTPException(status_code=404, detail="task not found")
# Flask — mismo mapa de rutas, validación manual
from flask import Flask, request, jsonify
app = Flask(__name__)

@app.get("/tasks/<int:task_id>")
def get_task(task_id):
    task = next((t for t in tasks if t["id"] == task_id), None)
    return (jsonify(task), 200) if task else (jsonify({"error": "not found"}), 404)

Qué te cuesta: Flask te da routing y JSON, pero la validación la escribes tú (no hay task_id: int que convierta+valide en la firma). Django REST Framework va al otro extremo: ViewSets + serializers — potencia total, framework entero.

// main.go
package main

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

type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Done  bool   `json:"done"`
}

var tasks []Task
var nextID = 1

func listTasks(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode(tasks)
}

func createTask(w http.ResponseWriter, r *http.Request) {
    var in struct{ Title string `json:"title"` }
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
        http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
        return
    }
    t := Task{ID: nextID, Title: in.Title}
    nextID++
    tasks = append(tasks, t)
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(t)
}

func getTask(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
    for _, t := range tasks {
        if t.ID == id {
            json.NewEncoder(w).Encode(t)
            return
        }
    }
    http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
}

func deleteTask(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
    for i, t := range tasks {
        if t.ID == id {
            tasks = append(tasks[:i], tasks[i+1:]...)
            w.WriteHeader(http.StatusNoContent)
            return
        }
    }
    http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /tasks", listTasks)
    mux.HandleFunc("POST /tasks", createTask)
    mux.HandleFunc("GET /tasks/{id}", getTask)
    mux.HandleFunc("DELETE /tasks/{id}", deleteTask)
    http.ListenAndServe(":8000", mux)
}
// chi — mismos handlers http.HandlerFunc, router con más conveniencias
r := chi.NewRouter()
r.Get("/tasks", listTasks)
r.Get("/tasks/{id}", getTask)   // chi.URLParam(r, "id")

// Gin — framework completo: binding, JSON helpers, middleware built-in
router := gin.Default()
router.GET("/tasks/:id", func(c *gin.Context) {
    id, _ := strconv.Atoi(c.Param("id"))
    // c.JSON(200, task) / c.AbortWithStatusJSON(404, …)
})

Qué te cuesta: chi es stdlib-friendly y casi gratis. Gin/Echo dan contextos propios (*gin.Context en vez de (w, r)) — cómodos pero te atan a su API. En Go muchas APIs serias viven solo con ServeMux.

Así se prueba cualquiera de los tres — misma llamada, mismo resultado:

curl -i -X POST http://localhost:8000/tasks \
  -H "Content-Type: application/json" -d '{"title":"ship v1"}'

# HTTP/1.1 201 Created
# {"id":1,"title":"ship v1","done":false}

curl -i http://localhost:8000/tasks/99
# HTTP/1.1 404 Not Found
# {"error":"task not found"}
Implementa un CRUD en memoria para una API de tareas en
[Express+TypeScript / FastAPI+Python / net/http+Go]. Endpoints:
GET /tasks (lista), POST /tasks (crear, body {title}), GET /tasks/{id}
(una, 404 si no existe), DELETE /tasks/{id} (204 o 404). Requisitos:
status codes correctos (201, 204, 404), sin base de datos (array en
memoria), y los comandos curl para probar cada endpoint. Después
explícame qué convención REST aplica cada ruta.

12.6 ¿Por qué cada stack lo hace así?

Lo único que necesitas llevarte: las rutas, verbos y status codes son idénticos en los tres — eso es HTTP, no el lenguaje. Lo que cambia es cuánto trabajo te quita el framework encima. El detalle:

Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.

  • Express es “Imperativo minimalista”: tú parseas el body (express.json()), tú conviertes req.params.id de string a número, tú eliges res.status(201). Libertad total = responsabilidad total.
  • FastAPI hace lo contrario: declaras task_id: int en la firma y el framework convierte y valida — si llega /tasks/abc, responde 422 antes de tocar tu función. Menos código, más “magia” (que el Cap. 16 desmontará).
  • Go stdlib es explícito al máximo: r.PathValue("id") devuelve string y tú la conviertes; http.Error escribe la respuesta de error a mano. Cero magia, cero sorpresas, más líneas.

Nótese lo que es idéntico en los tres: la forma de las rutas, los verbos, los status codes. Eso es el protocolo HTTP — el lenguaje solo cambia cómo lo expresas.

Esta decisión es material: algún mapa verbo+ruta→handler debe existir (estructural), pero las convenciones de diseño son elegibles — REST es una cimentación popular, no la única.

Alternativa Qué es Qué te cuesta Cuándo elegirla
REST de recursos (este libro) Sustantivos + verbos HTTP + status Diseñar recursos bien; no todo encaja en CRUD APIs públicas, equipos diversos — el default
RPC-style (/getTask, /doAction) Funciones remotas por nombre Pierdes convenciones (caché, idempotencia) que HTTP regala APIs internas, operaciones que no son recursos
GraphQL Un endpoint, el cliente pide campos Otra capa entera: schema, resolvers, N+1 Clientes con necesidades de datos muy distintas
tRPC / OpenAPI codegen Contrato tipado end-to-end Acopla cliente y servidor al mismo lenguaje/schema Monorepos TS, equipos full-stack

El libro usa REST porque sus convenciones (status codes, idempotencia) son lecciones gratis de diseño — y porque es lo que el 90% de los equipos espera ver.

12.7 Errores comunes

  • Responder siempre 200. 200 {"error":"not found"} obliga al cliente a parsear el body para saber si falló. El status code existe para eso.
  • Verbos en la URL (/getTask/42, /deleteTask/42): duplica lo que el método ya dice y rompe las convenciones que caches y herramientas asumen.

Un caché es una copia rápida de algo costoso de obtener: el resultado de una query pesada, la sesión. Redis es el caché estándar. La regla de oro: cachear es fácil, invalidar (saber cuándo el caché ya no vale) es lo difícil.

  • Olvidar express.json() (o equivalente): req.body llega undefined y pasas 20 minutos creyendo que el cliente manda mal.
  • Asumir que {id} es número: en Express y Go llega string — Number("abc") es NaN, Atoi("abc") es error. FastAPI lo resuelve por ti; en los otros es tu trabajo (hasta el capítulo de validación).

12.8 Buenas prácticas

  • Tabla de rutas antes que código: la tabla de arriba es el diseño.
  • 201 + el recurso creado en el body — el frontend no necesita un segundo GET para pintar la tarea.
  • 204 para DELETE exitoso: “salió bien, no hay contenido” es más honesto que devolver {}.
  • Query params para filtros (/tasks?done=false), path params para identidad (/tasks/42). Mezclarlos confunde el contrato.

EXTRA El libro usa Express, FastAPI y Go estándar, pero el ecosistema real es más amplio. Roadmaps externos recomiendan conocer —

  • Ruby on Rails — “convención sobre configuración”, testing integrado, localización; open source escrito en Ruby
  • Django (Python) — “baterías incluidas”: auth, admin, ORM; hoy también async
  • Flask (Python) — microframework minimalista, servidor de desarrollo integrado, rápido para prototipar
  • Express (Node) — ligero y adaptable; depuración y desarrollo de servidores rápidos

Mismo patrón en todos: rutas → handlers → middleware. Si entiendes el de este libro, lees cualquiera de estos en una tarde.

12.9 Ejercicio

  1. Implementa el CRUD en tu stack y pruébalo con curl o Thunder Client: crear dos tareas, listar, obtener una, borrar otra, verificar el 404.
  2. Agrega PATCH /tasks/{id} que solo permita cambiar done.
  3. Agrega GET /tasks?done=true que filtre por query param.

12.10 Mini reto

Diseña las rutas de una API de “likes” sobre tareas: un usuario puede dar like a una tarea, quitarlo, y ver quiénes dieron like. Sin verbos en la URL y sin ambigüedad — escribe la tabla antes de pensar en código.

Ejercicio 2 (PATCH). Solo se acepta done:

@app.patch("/tasks/{task_id}")
def update_task(task_id: int, payload: TaskUpdate):
    task = find_or_404(task_id)
    if payload.done is not None:
        task.done = payload.done
    return task
# TaskUpdate = BaseModel con done: bool | None = None

Ejercicio 3 (filtro): GET /tasks?done=true → [t for t in tasks if t.done == done] cuando el query param está presente.

Mini reto. Tabla modelo:

Verbo + ruta Significado Éxito
PUT /tasks/{id}/like dar like (idempotente: repetir no duplica) 204
DELETE /tasks/{id}/like quitar like 204
GET /tasks/{id}/likes quiénes dieron like 200 + lista

La decisión clave: PUT en vez de POST para dar like — como un usuario solo puede dar like una vez, la operación es idempotente y PUT lo expresa.

12.11 Vocabulario técnico del capítulo

Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.

Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.

Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.

Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.

Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.

Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.

Un booleano es un valor de dos estados: true o false. Es el resultado de toda comparación (age > 18) y lo que los if evalúan. Nombrado por George Boole, el matemático de la lógica.

null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.

localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.

El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.

Un middleware es un filtro en la cadena del request: pasa por él antes de llegar a tu handler. Auth es middleware — “verifica el token” vive una vez y protege todas las rutas, no se copia en cada una.

Un servidor es una computadora que espera peticiones y las responde 24/7. Físicamente no es nada mágico: es una máquina (a veces una VM alquilada) corriendo tu programa, con la diferencia de que está siempre encendida y conectada.

12.12 Lo que deberías saber hacer ahora