← Servidor web y API con Node

Sesión 4

La web y su API

Tres horas. Media hora para ver de dónde puede salir el HTML y qué recorrido hace un envío, y dos horas y media conectando tu interfaz de la UD4 con la API que escribiste ayer.

Hoy · Hoja de ruta

  1. 1. Aprende: Cuándo conviene generar el HTML en el servidor, cómo consume el cliente su propia API y qué ocurre entre pulsar «Enviar» y ver el resultado.
  2. 2. Haz: Sirve el catálogo generado en el servidor, alimenta la interfaz con tu API y monta el alta y el borrado reales.
  3. 3. Comprueba: Un nombre con etiquetas no ejecuta nada, y cada error del servidor aparece junto al campo que lo causó.

Antes de empezar · 5 minutos, sin apuntes

  1. En la UD4 el catálogo se pintaba en el navegador. ¿Qué ve alguien con JavaScript desactivado, y qué ve un buscador que indexa tu web?
  2. ¿Qué pasaría si el nombre de un producto contuviera una etiqueta?
  3. Enumera todo lo que ocurre entre pulsar «Enviar» y ver el resultado.

Se explica

25 minutos · conceptos y demostración

Hoy se juntan las dos mitades del módulo: la interfaz de la UD4 y la API de esta unidad. Todo lo que aparece responde a tres decisiones: dónde se genera el HTML, dónde se filtran los datos y qué pasa cuando el servidor dice que no.

Las dos formas de pintar

Dónde se genera Ventajas Inconvenientes
En el servidor Llega listo, funciona sin JavaScript, se indexa Cada cambio recarga la página
En el cliente Interacción inmediata, menos trabajo del servidor Depende de que el código se ejecute

La elección no es permanente: lo habitual es que la primera carga llegue hecha del servidor y la interacción se resuelva en el cliente. Es lo que vas a montar hoy.

export function paginaCatalogo(productos) {
  return `<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <title>${escapar("Catálogo")}</title>
  <link rel="stylesheet" href="/css/styles.css">
  <script type="module" src="/js/main.js"></script>
</head>
<body>
  <main>
    <h1>Catálogo</h1>
    <ul class="catalogo" data-js="catalogo">
      ${productos.map(tarjeta).join("")}
    </ul>
  </main>
</body>
</html>`;
}

function tarjeta(producto) {
  return `<li class="producto" data-id="${producto.id}">
    <h3>${escapar(producto.nombre)}</h3>
    <p class="precio">${escapar(formatearPrecio(producto.precio))}</p>
  </li>`;
}

Las plantillas del lenguaje, las de la UD3, bastan para esto sin instalar un motor de plantillas. Un motor aporta herencia de plantillas y sintaxis propia; a cambio, una dependencia más y un lenguaje más que aprender.

const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };

export function escapar(valor) {
  return String(valor).replace(/[&<>"']/g, (caracter) => ESCAPES[caracter]);
}

Todo dato que entra en el HTML se escapa

Si un producto se llama Teclado <script>…</script> y se inserta sin escapar, ese código se ejecuta en el navegador de quien visite tu página, con sus permisos y sus datos de sesión. Esa vulnerabilidad se denomina cross-site scripting y es la más extendida de la web.

Es el mismo problema del innerHTML de la UD4, ahora del lado del servidor y con más alcance: allí afectaba a quien lo escribía, aquí a todo el que visite la página.

Importa además el sitio donde insertas: escapar sirve para el contenido y para los atributos entrecomillados. Meter datos de fuera dentro de un bloque de código de la página o en una URL requiere reglas distintas, y lo sensato es no hacerlo.

El cliente consume su propia API

const productos = await pedir("/api/productos");

Como el cliente se sirve desde el mismo servidor que la API, la petición es relativa y no cruza de origen: sin dominio, sin puerto y sin CORS. Es una de las razones prácticas de servir ambas cosas juntas mientras el proyecto es pequeño.

Dónde filtrar Cuándo conviene
En el cliente Pocos datos, ya descargados: respuesta instantánea
En el servidor Muchos datos, o filtros que dependen de reglas o permisos

El lugar del filtrado es una decisión de diseño

Con doscientos productos, descargarlos una vez y filtrar en el navegador ofrece mejor experiencia, porque no hay espera. Con doscientos mil, o cuando el filtro depende de quién pregunta, la única opción es el servidor.

Lo que no vale es hacerlo en los dos sitios con reglas distintas: entonces el mismo filtro da resultados diferentes según por dónde pase, y ese fallo resulta muy difícil de encontrar. Toma la decisión, escríbela en tus notas y manténla.

El estado que montaste en la UD4 ya tenía sitio para cargando y error. Hoy esos campos dejan de ser una simulación:

async function cargar() {
  estado.cargando = true;
  estado.error = null;
  actualizar();

  try {
    estado.productos = await pedir("/api/productos");
  } catch (error) {
    estado.error = error.mensaje;
  } finally {
    estado.cargando = false;
    actualizar();
  }
}

Un envío, de punta a punta

De un campo al fichero y de vuelta
  1. Se envía el formulario; se cancela el comportamiento por defecto
  2. Se leen y convierten los valores
  3. Validación del cliente: si falla, no se envía nada
  4. Petición POST con el cuerpo JSON
  5. El servidor valida otra vez y responde 201 o 400
  6. Con 201: se actualiza el estado y se vuelve a pintar
  7. Con 400: se muestran los detalles junto a cada campo
formulario.addEventListener("submit", async (evento) => {
  evento.preventDefault();
  limpiarErrores();
  boton.disabled = true;

  try {
    const creado = await pedir("/api/productos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(formulario)))
    });

    estado.productos.push(creado);
    formulario.reset();
    anunciar(`${creado.nombre} añadido`);
    actualizar();
  } catch (error) {
    if (error.detalles?.length) mostrarErroresDeCampo(error.detalles);
    else anunciar(error.mensaje, "error");
  } finally {
    boton.disabled = false;
  }
});
function mostrarErroresDeCampo(detalles) {
  for (const { campo, mensaje } of detalles) {
    const control = formulario.elements[campo];
    if (control) mostrarError(control, mensaje);
  }
  formulario.elements[detalles[0].campo]?.focus();
}

El campo de la respuesta es lo que hace esto posible

Por eso el contrato de errores incluye campo en cada detalle: sin él, el cliente solo puede enseñar un mensaje suelto y quien lo lee tiene que adivinar cuál de los seis campos está mal.

Aquí se ve para qué servía diseñar el contrato antes de escribir la primera ruta.

Dos detalles se olvidan casi siempre. Deshabilitar el botón mientras se envía evita el doble envío, que con POST crea dos productos. Anunciar el resultado en la región activa de la UD4 consigue que el éxito no sea solo un cambio visual que algunas personas no perciben.

Se trabaja

150 minutos · la aplicación completa

Los tres primeros pasos montan la aplicación entera. Los dos últimos la atacan: uno intenta ejecutar código en tu página, el otro rompe la red a propósito.

Paso 1 · La página del servidor · 40 min

  1. Escribe src/vistas/catalogo.js que genere la página completa.
  2. Escribe la función de escapado y úsala en todos los datos que entren en el HTML.
  3. Sirve la página en GET / desde su router, usando el servicio de ayer para obtener los productos.
  4. Comprueba con «ver código fuente» que el HTML llega hecho, no vacío.
  5. Añade la página de detalle de un producto, con su propio 404 en HTML y no en JSON.
  6. Valida el HTML generado en el W3C, con datos reales dentro.

Antes de continuar: desactiva JavaScript en el navegador y recarga. El catálogo debe seguir viéndose.

Paso 2 · Conectar el cliente · 40 min

  1. Sustituye los datos escritos a mano de la UD4 por una llamada a tu API.
  2. Usa la función pedir de la sesión 3 para todas las llamadas, sin ningún fetch suelto.
  3. Decide dónde filtras, impleméntalo en un solo sitio y déjalo escrito en tus notas con la razón.
  4. Añade el botón de reintentar cuando la carga falla.
  5. Comprueba en la pestaña de red qué se envía y qué vuelve en cada filtro.
  6. Añade el borrado desde la lista, con confirmación previa.

Paso 3 · El formulario real · 40 min

  1. Añade el formulario de alta a tu página, con su marcado accesible: etiquetas asociadas, tipos correctos y mensajes vinculados.
  2. Envía con fetch y trata las dos respuestas posibles.
  3. Muestra los errores del servidor en su campo y lleva el foco al primero.
  4. Deshabilita el botón durante el envío y devuélvelo a su estado después, también cuando falla.
  5. Anuncia el resultado en la región activa.
  6. Añade la edición reutilizando el mismo formulario, con PATCH en lugar de POST.

Paso 4 · El ataque que no debe ocurrir · 15 min

Crea productos cuyos nombres sean cada una de estas cadenas y observa qué ocurre en la página generada por el servidor y en la lista pintada por el cliente.

Nombre del producto Qué se ve ¿Se ejecuta algo?
Teclado <b>rebajado</b>
Teclado <img src=x onerror="alert(1)">
Teclado " onmouseover="alert(1)
Teclado & ratón
  1. Rellena la tabla con lo observado.
  2. La tercera fila es la que distingue escapar el contenido de escapar un atributo. Comprueba dónde acaba esa cadena en tu HTML.
  3. Quita el escapado en una copia de la vista, repite las cuatro filas y anota la diferencia.
  4. Devuelve el escapado a su sitio.
  5. Escribe en dos líneas por qué validar el nombre al crearlo no sustituye a escaparlo al mostrarlo.

Paso 5 · Cuando la red falla · 15 min

  1. Apaga el servidor con la página abierta y pulsa recargar la lista. Anota qué ve la persona.
  2. Simula red lenta desde las herramientas del navegador y comprueba que el estado de carga se ve de verdad.
  3. Envía el formulario pulsando el botón dos veces seguidas, deprisa. Cuenta los productos creados.
  4. Envía un producto inválido y comprueba que los mensajes aparecen en sus campos y que el foco va al primero.
  5. Rellena la tabla con los cuatro estados.
Estado Cómo lo has provocado Qué se ve Qué se anuncia
Cargando
Error
Vacío
Con datos

El estado vacío es el que suele quedar sin diseñar, y aparece el primer día que alguien filtra por algo que no existe.

Ampliación si has completado el trabajo

Primero termina y comprueba los cinco pasos. El primer reto elimina el trabajo duplicado entre servidor y cliente; el segundo cambia el orden entre lo que se ve y lo que se confirma.

Reto 1 · La primera carga, sin pedir dos veces

Tu página llega renderizada del servidor y, acto seguido, el cliente pide los mismos productos por la API. Se pintan dos veces los mismos datos.

  1. Mide el coste: cuenta las peticiones y los bytes de la primera carga en la pestaña de red.
  2. Haz que el servidor incluya los datos que ya ha usado dentro de la propia página, y que el cliente arranque su estado desde ahí en lugar de pedirlos.
  3. Elige cómo viajan esos datos. Hay al menos dos formas, y una de ellas vuelve a abrir el problema del escapado con reglas distintas a las del contenido. Busca cuál y qué precaución exige.
  4. Comprueba que la interfaz sigue funcionando: filtrar, crear y borrar deben seguir igual.
  5. Provoca la incoherencia: modifica un producto desde otra pestaña y recarga. Decide qué gana, lo embebido o lo pedido, y qué ocurre si el estado inicial está caducado.
  6. Desactiva JavaScript y comprueba que la página sigue completa.
  7. Explica en tres líneas qué has ganado y qué has complicado. Los mismos datos descritos en dos sitios tienen un coste que conviene nombrar.

Reto 2 · Pintar antes de saber

Al borrar un producto, tu interfaz espera la respuesta del servidor antes de quitarlo de la lista. Con red lenta, esa espera resulta perceptible.

  1. Invierte el orden: quita el producto de la lista en cuanto se pulsa, y envía la petición después.
  2. Si el servidor responde con error, devuelve el producto a su sitio en la misma posición y explica el fallo.
  3. Provoca el caso con el servidor apagado y comprueba que la lista queda como estaba.
  4. Aplica lo mismo al alta. Aquí aparece un problema que el borrado no tiene: el identificador todavía no existe. Decide cómo representas un elemento que aún no está confirmado.
  5. Decide qué operaciones no deberían pintarse de forma optimista, y por qué. Un cobro no se muestra como hecho antes de estarlo.
  6. Anuncia la reversión en la región activa. Un cambio que se deshace sin aviso resulta más confuso que la espera que querías evitar.
  7. Escribe en tres líneas qué compromiso has aceptado: la interfaz enseña durante unos instantes algo que todavía puede resultar falso.
Objetivo mínimoCatálogo generado en el servidor y escapado, interfaz alimentada por tu API con los cuatro estados, y alta y borrado funcionando de punta a punta.
Si lo tienesLa tabla del ataque rellenada sin ninguna ejecución, y la edición reutilizando el formulario del alta.
RetoLa primera carga sin petición duplicada, o el borrado optimista con su reversión.

Cierre

5 minutos · comprobación y recuerdo

Lista de verificación de la sesión

  • Generas HTML en el servidor con plantillas del lenguaje, y es válido.
  • Escapas todo dato antes de insertarlo en la página.
  • El cliente consume tu API con rutas relativas y una sola función.
  • Los cuatro estados se ven de verdad, incluido el vacío.
  • Los formularios crean, editan y borran datos reales.
  • Los errores del servidor llegan al campo correcto y el foco va al primero.

Antes de cerrar · 3 minutos, sin mirar

  1. ¿Qué gana una página generada en el servidor frente a una pintada en el navegador?
  2. ¿Qué hace la función de escapado y qué ocurre si falta?
  3. ¿Por qué el cliente no tiene problemas de CORS con su propia API?
  4. ¿Cuándo filtrarías en el cliente y cuándo en el servidor?
  5. ¿Por qué el contrato de errores incluye el nombre del campo que falla?
  6. ¿Qué evita deshabilitar el botón durante el envío?
Ver respuestas

1 · Que llega hecha y funciona sin JavaScript, y que los buscadores la leen.

2 · Sustituye los caracteres con significado en HTML por sus entidades; sin ella, un dato puede ejecutarse como código en el navegador de cualquier visitante.

3 · Porque el cliente y la API comparten origen.

4 · En el cliente con pocos datos ya descargados; en el servidor con muchos, o cuando el filtro depende de permisos.

5 · Para poder mostrar cada mensaje junto al campo que lo provoca.

6 · El doble envío, que con POST crea dos productos.

Microprueba semanal 4 · 5–10 minutos

Individual, sin IA y sin apuntes.

  1. Escribe qué hace la función de escapado y qué ocurre si falta.
  2. ¿Cuándo filtrarías en el cliente y cuándo en el servidor?
  3. ¿Por qué el contrato de errores incluye el nombre del campo que falla?