Saltar al contenido
Framework Ecosystems LabsUn contrato, muchos ecosistemas, la misma prueba.

Módulo 01 — HTTP, eventos y contratos#

Todo framework web es una forma de escribir menos HTTP a mano. Quien no sabe qué está escribiendo el framework por él, no puede diagnosticar cuándo lo escribe mal.

Prerrequisitos y nivel#

Nivel: introductorio. Duración: 16 horas. Requiere el módulo 00.

Este módulo se implementa sin ningún framework: solo el runtime. Es deliberado. La referencia de labs/01-http-contract/reference-node/ es el patrón de medida contra el que se comparan todas las implementaciones posteriores.

Objetivos observables#

  1. Explicar qué significan las propiedades segura, idempotente y cacheable de un método, y clasificar GET, POST, PUT, PATCH y DELETE según ellas [rfc9110].
  2. Elegir el código de estado correcto para ocho situaciones dadas, justificando la elección con la semántica normativa y no con la costumbre [rfc9110].
  3. Emitir errores con una forma estable y documentada [rfc9457].
  4. Escribir y validar un contrato en OpenAPI que describa el mismo servicio [openapi-spec].
  5. Implementar un servidor que cumpla el contrato usando solo el runtime [nodejs-docs].
  6. Explicar qué cambia y qué no cambia entre HTTP/1.1, HTTP/2 y HTTP/3.

Concepto independiente del framework#

Una petición HTTP es un mensaje con método, destino, campos de cabecera y contenido opcional; una respuesta es un código de estado, campos y contenido opcional [rfc9110]. Nada más. Todo framework se reduce a construir y descomponer estos mensajes.

sequenceDiagram
  participant C as Cliente
  participant S as Servidor
  C->>S: POST /tasks<br/>Content-Type: application/json<br/>Idempotency-Key: k-1
  S-->>C: 201 Created<br/>Location: /tasks/t1
  C->>S: POST /tasks (misma clave k-1)
  S-->>C: 200 OK (misma tarea, sin duplicar)
  C->>S: GET /tasks/desconocida
  S-->>C: 404 Not Found<br/>{"code":"TASK_NOT_FOUND"}

Las tres propiedades que hay que saber de memoria#

Propiedad Significado normativo [rfc9110] Consecuencia práctica
Segura No se pide al servidor que cambie estado Un rastreador puede recorrerlo sin dañar nada
Idempotente Repetir la petición tiene el mismo efecto que hacerla una vez El cliente puede reintentar tras un fallo de red
Cacheable La respuesta puede almacenarse y reutilizarse Una capa intermedia puede responder sin llegar al origen [rfc9111]
Método Segura Idempotente Cacheable
GET
HEAD
PUT no no
DELETE no no
POST no no solo con indicación explícita
PATCH no no por definición [rfc5789] no

Que POST no sea idempotente es la razón de existir de Idempotency-Key en el contrato de este repositorio: sin una clave que el servidor recuerde, un reintento tras un tiempo de espera agotado crea un recurso duplicado.

Códigos de estado: elegir por semántica, no por costumbre#

Situación Código Por qué
Se creó un recurso 201 + Location La respuesta indica dónde vive lo creado
Se aceptó para procesar después 202 El trabajo aún no terminó
Operación correcta sin contenido 204 No hay cuerpo que devolver
El cuerpo no es JSON válido 400 El mensaje está mal formado
Falta o es inválida la credencial 401 Falta autenticación
Hay credencial pero no permiso 403 Falla la autorización, no la identidad
El recurso no existe 404 No hay representación
El método no aplica a ese recurso 405 + Allow El recurso existe, el verbo no
Conflicto con el estado actual 409 Por ejemplo, dos ediciones concurrentes
El cuerpo es válido pero viola una regla 422 Sintaxis correcta, semántica no
Se superó el ritmo permitido 429 + Retry-After El cliente debe esperar

El transporte cambia; la semántica no#

HTTP/1.1 [rfc9112], HTTP/2 [rfc9113] y HTTP/3 [rfc9114] cambian cómo viajan los mensajes —una conexión por petición, multiplexación sobre TCP, multiplexación sobre QUIC— pero no cambian qué significa GET ni qué significa 404. Por eso el módulo enseña primero la semántica: es la parte que no caduca. La latencia, en cambio, sí depende del transporte y del recorrido físico [grigorik-hpbn].

Anatomía comparada#

El mismo POST /tasks en tres niveles de abstracción:

Etapa Sin framework (runtime) Framework minimalista Framework con convenciones
Enrutar if (req.method === "POST" && url.pathname === "/tasks") app.post("/tasks", ...) Anotación o convención de archivo
Leer el cuerpo Acumular fragmentos del flujo y JSON.parse [rfc8259] Middleware de análisis Automático, con esquema
Validar Comprobaciones escritas a mano Función de validación llamada por ti Declarativa, ejecutada por el framework
Error Construir el objeto y el código Manejador de errores registrado Traducción automática de excepciones
Responder res.writeHead(...); res.end(...) res.status(201).json(...) Retorno del manejador

En las tres columnas se envía el mismo mensaje por el cable. Cambia cuánto código propio hace falta y cuánto comportamiento queda implícito. El coste del comportamiento implícito se paga al diagnosticar.

Implementación mínima#

El repositorio incluye la referencia completa en labs/01-http-contract/reference-node/server.mjs, escrita solo con módulos nativos [nodejs-docs]. Estos dos fragmentos son los que concentran las decisiones del módulo, y scripts/verify-contract.mjs comprueba que coincidan literalmente con el archivo real.

El emisor único de errores, según RFC 9457 [rfc9457]:

<!-- extracto-verificado: labs/01-http-contract/reference-node/server.mjs -->

function problem(response, code, { detail, instance, errors, headers } = {}) {
  const { status, title } = CATALOGO[code] ?? CATALOGO.INTERNAL_ERROR;
  const payload = { type: `${PROBLEM_BASE}/${kebab(code)}`, title, status, code };
  if (detail) payload.detail = detail;
  if (instance) payload.instance = instance;
  if (errors?.length) payload.errors = errors;
  send(response, status, payload, { "content-type": "application/problem+json; charset=utf-8", ...headers });
}

Y la lectura del cuerpo con su límite. Aquí hay una decisión que casi nunca se explica y que cuesta una tarde descubrir:

<!-- extracto-verificado: labs/01-http-contract/reference-node/server.mjs -->

function readBody(request) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let size = 0;
    let cortado = false;

    request.on("data", (chunk) => {
      if (cortado) return;
      size += chunk.length;
      if (size > MAX_BODY_BYTES) {
        cortado = true;
        request.pause();
        reject(Object.assign(new Error("body too large"), { code: "BODY_TOO_LARGE" }));
        return;
      }
      chunks.push(chunk);
    });

Se usan eventos y no for await a propósito: salir de un for await destruye el flujo, y destruir el flujo cierra el socket antes de que la respuesta 413 salga. El cliente vería una conexión cortada en lugar del error que explica qué pasó. Es un ejemplo exacto de lo que este módulo persigue: la diferencia entre rechazar una petición y rechazarla comunicando por qué.

Puedes ejecutar la referencia y sus pruebas sin instalar nada:

node --test labs/01-http-contract/reference-node/server.test.mjs
node scripts/run-acceptance.mjs reference-node

Pruebas compartidas#

Las pruebas de aceptación son ejecutables y viven en contracts/taskflow/acceptance.test.mjs, descritas caso a caso en ACCEPTANCE.md. Son las mismas para todas las implementaciones del programa y comprueban el contrato, no la implementación:

node scripts/run-acceptance.mjs reference-node

Los 20 casos cubren cuatro grupos, y cada grupo enseña algo distinto:

Grupo Casos Qué demuestra
Recorrido correcto 1–5 200, 201 con Location, recuperación por esa ruta
Idempotencia 6–9 Repetir no duplica; reutilizar la clave con otro cuerpo es 409
Validación 10–14 422 con errors[] por campo y límites inclusivos
Entradas hostiles 15–20 400, 413, 415, 404 y 405 con Allow

Dos comprobaciones merecen atención especial porque no se les suele hacer sitio:

test("un cuerpo mayor que el límite responde 413 y el servidor sigue vivo", async () => {
  const respuesta = await crear(null, { body: JSON.stringify({ title: "z".repeat(100_000) }) });
  await problema(respuesta, { status: 413, code: "BODY_TOO_LARGE" });
  // Rechazar la petición no basta: el servicio no puede quedar degradado.
  assert.equal((await fetch(`${BASE}/health`)).status, 200);
});

test("un método no admitido responde 405 y declara Allow", async () => {
  const respuesta = await fetch(`${BASE}/tasks`, { method: "DELETE" });
  await problema(respuesta, { status: 405, code: "METHOD_NOT_ALLOWED" });
  const allow = respuesta.headers.get("allow") ?? "";
  assert.ok(allow.includes("GET") && allow.includes("POST"));
});

El 405 sin Allow es un error frecuente: el recurso existe, el verbo no, y el cliente se queda sin saber cuál sí [rfc9110]. El 413 que tumba el proceso es peor todavía: se defendió del cuerpo grande y perdió el servicio.

Además, todo error pasa por una comprobación transversal —application/problem+json, los cuatro miembros obligatorios [rfc9457] y ninguna filtración de trazas o rutas—. Si una implementación necesita cambiar una prueba para pasar, la comparación deja de ser válida: se cambió el problema para favorecer la herramienta.

Seguridad y accesibilidad#

Errores frecuentes y diagnóstico#

Síntoma Causa Diagnóstico
Todo devuelve 200 con {"error": ...} dentro Se ignora la capa de estado de HTTP Revisa la tabla de códigos: el estado es parte del contrato [rfc9110]
Un reintento crea recursos duplicados POST tratado como idempotente Implementa y prueba Idempotency-Key
PUT parcial que borra campos Se confundió PUT con PATCH [rfc5789] PUT reemplaza; para cambios parciales usa PATCH, con JSON Patch si necesitas precisión [rfc6902]
500 ante un cuerpo malformado JSON.parse sin protección Envuelve el análisis y traduce a 400
Respuestas que envejecen mal en un intermediario Sin cabeceras de caché Declara Cache-Control de forma explícita [rfc9111]
«HTTP/2 hará la API más rápida» Se confunde transporte con semántica Mide: el transporte reduce el coste de conexión, no el del trabajo del servidor [grigorik-hpbn]
La documentación y el servidor no coinciden El contrato no se valida Genera las pruebas desde el contrato [openapi-spec]

Comprobación de recuerdo#

  1. ¿Qué diferencia hay entre «segura» e «idempotente»? Da un método que sea idempotente pero no seguro.
  2. ¿Por qué POST necesita una clave de idempotencia y PUT no?
  3. 401 frente a 403: ¿cuál es la diferencia y cómo la explicas a un cliente?
  4. ¿Qué debe llevar como mínimo un cuerpo de error para ser accionable?
  5. ¿Qué no cambia al pasar de HTTP/1.1 a HTTP/3?

Repaso espaciado. Repite estas preguntas al iniciar el módulo 05 y de nuevo antes del proyecto final.

Reto de transferencia#

Añade al contrato una operación nueva: listar tareas con paginación. Debes:

  1. decidir el modelo de paginación y justificarlo frente a una alternativa [richardson-amundsen-restful];
  2. escribirla primero en contracts/taskflow/openapi.yaml [openapi-spec];
  3. definir el comportamiento ante parámetros inválidos, con su código;
  4. añadir las pruebas de aceptación antes de implementar;
  5. implementarla en la referencia sin framework;
  6. documentar la política de caché de la respuesta [rfc9111].

Criterio de terminado: las pruebas nuevas fallan antes de tu implementación y pasan después, y el contrato describe exactamente lo que el servidor hace.

Criterios de evaluación#

Criterio Insuficiente Suficiente Sólido Ejemplar
Semántica HTTP Usa 200 para todo Usa códigos razonables Justifica cada código con la norma Detecta y corrige usos incorrectos en una API ajena
Contrato No existe Documenta después de implementar Escribe el contrato antes Genera pruebas desde el contrato
Errores Devuelve trazas Devuelve mensajes legibles Códigos estables y documentados Formato normalizado y accionable por campo
Robustez Cae ante entrada malformada Controla el caso obvio Límites de tamaño y análisis protegido Prueba el agotamiento de recursos

Fuentes#