Módulo 17: Fetch y APIs REST

Objetivos del módulo

  • Repasar cómo funciona HTTP: peticiones, respuestas, métodos, cabeceras, códigos de estado y JSON
  • Entender qué es una API REST y cómo se organizan sus recursos
  • Hacer peticiones con fetch y leer sus respuestas correctamente (ok, status, json(), text(), blob())
  • Saber por qué fetch no falla con un 404 y cómo gestionarlo
  • Enviar datos con POST, PUT, PATCH y DELETE
  • Construir URLs con URL y URLSearchParams
  • Escribir una función envoltorio peticion() con errores personalizados, tiempo máximo y cancelación
  • Lanzar peticiones en paralelo y mostrar estados de carga en la interfaz
  • Paginar resultados y hacer scroll infinito con IntersectionObserver
  • Comprender CORS y la autenticación con tokens Bearer
  • Construir un CRUD completo contra una API local con json-server y consumir una API pública real

1. Repaso de HTTP

Cada vez que el navegador carga una página, una imagen o unos datos, habla con un servidor usando HTTP (HyperText Transfer Protocol). Es una conversación de petición y respuesta:

📘 Concepto: El cliente (tu navegador o tu código) envía una petición (request) a una URL; el servidor la procesa y devuelve una respuesta (response). Es como pedir en una ventanilla: dices qué quieres (método + URL), entregas papeles si hace falta (cuerpo), y recibes lo que pediste o un aviso de por qué no (código de estado).

sequenceDiagram
    participant C as Cliente (navegador)
    participant S as Servidor (API)
    C->>S: GET /api/productos/7<br/>Accept: application/json
    S-->>C: 200 OK<br/>Content-Type: application/json<br/>{ "id": 7, "nombre": "Teclado" }

Una petición tiene:

POST /api/productos HTTP/1.1          ← método, ruta y versión
Host: tienda.ejemplo.es               ← cabeceras (metadatos)
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...

{ "nombre": "Ratón", "precio": 25 }   ← cuerpo (opcional)

Y una respuesta:

HTTP/1.1 201 Created                  ← versión y código de estado
Content-Type: application/json
Location: /api/productos/8

{ "id": 8, "nombre": "Ratón", "precio": 25 }

Métodos

Método Para qué ¿Lleva cuerpo? ¿Idempotente?
GET Obtener un recurso o una lista No Sí
POST Crear un recurso nuevo (o lanzar una acción) Sí No
PUT Sustituir un recurso completo Sí Sí
PATCH Modificar parte de un recurso Sí No necesariamente
DELETE Eliminar un recurso Normalmente no Sí

📘 Concepto: Una operación es idempotente si repetirla da el mismo resultado que hacerla una vez. Borrar el producto 7 dos veces deja el sistema igual que borrarlo una; crear un producto dos veces (POST) crea dos. Por eso es seguro reintentar un GET o un PUT que falló, pero hay que tener cuidado al reintentar un POST.

Cabeceras habituales

Cabecera Dirección Significado
Content-Type Petición y respuesta Formato del cuerpo: application/json, text/html, multipart/form-data…
Accept Petición Formato que el cliente prefiere recibir
Authorization Petición Credenciales, por ejemplo Bearer <token>
Cache-Control Ambas Reglas de caché
Location Respuesta URL del recurso recién creado o de una redirección
Access-Control-Allow-Origin Respuesta Qué orígenes pueden leer la respuesta (CORS, sección 13)

Códigos de estado

Rango Significado Códigos más comunes
2xx Éxito Todo fue bien 200 OK, 201 Created (creado), 204 No Content (bien, pero sin cuerpo)
3xx Redirección El recurso está en otro sitio 301 Moved Permanently, 304 Not Modified (usa tu caché)
4xx Error del cliente La petición es incorrecta 400 Bad Request (datos inválidos), 401 Unauthorized (falta identificarse), 403 Forbidden (no tienes permiso), 404 Not Found, 409 Conflict, 422 Unprocessable Content (validación), 429 Too Many Requests
5xx Error del servidor El servidor ha fallado 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable

💡 Consejo: Regla rápida para depurar: un 4xx dice “revisa lo que has enviado” (URL, datos, permisos) y un 5xx dice “el problema está en el servidor”. Los errores 5xx, 429 y los fallos de red son los candidatos a reintentar (con backoff, como en el módulo 12); los 4xx casi nunca.

JSON

Las APIs modernas intercambian datos en JSON (módulo 07): texto con la sintaxis de los objetos de JavaScript, pero con comillas dobles obligatorias y sin funciones, undefined, comentarios ni comas finales.

{
  "id": 7,
  "nombre": "Teclado mecánico",
  "precio": 89.9,
  "etiquetas": ["periféricos", "oferta"],
  "disponible": true,
  "proveedor": null
}

2. ¿Qué es una API REST?

Una API (Application Programming Interface) es la “carta del restaurante” de un servidor: la lista de cosas que le puedes pedir y cómo pedirlas. REST es un estilo de diseño de APIs sobre HTTP muy extendido:

  • Todo son recursos identificados por URLs con sustantivos en plural: /productos, /productos/7, /usuarios/3/pedidos.
  • La acción la indica el método HTTP, no la URL (nada de /borrarProducto?id=7).
  • Cada petición es independiente (stateless): lleva todo lo necesario, incluida la autenticación.
  • Las respuestas usan los códigos de estado con su significado.
Acción Método URL Respuesta típica
Listar tareas GET /tareas 200 + array
Ver una tarea GET /tareas/5 200 + objeto, o 404
Crear una tarea POST /tareas 201 + objeto creado (con su id)
Sustituir una tarea PUT /tareas/5 200 + objeto
Modificar una tarea PATCH /tareas/5 200 + objeto
Borrar una tarea DELETE /tareas/5 200 o 204
Filtrar / paginar GET /tareas?completada=true&_page=2 200 + array o página

A esas cinco operaciones básicas (crear, leer, actualizar, borrar) se las llama CRUD (Create, Read, Update, Delete).

En este módulo usaremos dos APIs públicas y gratuitas que no necesitan registro, y una API local:

API URL Para qué
JSONPlaceholder https://jsonplaceholder.typicode.com Datos falsos (usuarios, posts, tareas). Acepta POST/PUT/DELETE, pero no guarda los cambios
PokeAPI https://pokeapi.co/api/v2 Datos reales de Pokémon, solo lectura
json-server http://localhost:3000 API REST local creada a partir de un fichero JSON; sí guarda los cambios (sección 15)

3. fetch: la primera petición

fetch(url, opciones?) hace una petición HTTP y devuelve una promesa con un objeto Response. Funciona en todos los navegadores y en Node.js (sin instalar nada desde Node 18).

const respuesta = await fetch('https://jsonplaceholder.typicode.com/users/1');
const usuario = await respuesta.json();

console.log(usuario.name); // Leanne Graham
console.log(usuario.email); // Sincere@april.biz

📘 Concepto: ¿Por qué dos await? La promesa de fetch se cumple en cuanto llegan las cabeceras de la respuesta, sin esperar al cuerpo (que puede ser enorme). Después, respuesta.json() devuelve otra promesa que lee el cuerpo completo y lo convierte de JSON a objeto.

Con then (útil en código antiguo o si no estás en una función async):

fetch('https://jsonplaceholder.typicode.com/users/1')
  .then((respuesta) => respuesta.json())
  .then((usuario) => console.log(usuario.name))
  .catch((error) => console.error('Fallo de red:', error));

💡 Consejo: Ejecuta los ejemplos de este módulo en la consola del navegador (en una página https://, por ejemplo la propia jsonplaceholder.typicode.com) o guárdalos en un fichero .mjs y lánzalos con node. La consola del navegador permite await en el nivel superior, y la pestaña Red (Network) de las DevTools te enseña cada petición con sus cabeceras y su respuesta.


4. response.ok y status: fetch no rechaza en un 404

Esta es la trampa número uno de fetch:

const respuesta = await fetch('https://jsonplaceholder.typicode.com/users/9999');

console.log(respuesta.ok); // false
console.log(respuesta.status); // 404
console.log(await respuesta.json()); // {} ← ¡pero la promesa de fetch se CUMPLIÓ!

⚠️ Importante: La promesa de fetch solo se rechaza si no hay respuesta: sin conexión, el dominio no existe, la petición se cancela o CORS la bloquea. Un 404 o un 500 son respuestas válidas desde el punto de vista de HTTP, así que fetch las entrega como “éxito”. Tienes que comprobar respuesta.ok (que vale true para los códigos 200-299) en todas tus peticiones.

async function obtenerUsuario(id) {
  const respuesta = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  if (!respuesta.ok) {
    throw new Error(`Error ${respuesta.status} al obtener el usuario ${id}`);
  }
  return respuesta.json();
}

try {
  const usuario = await obtenerUsuario(9999);
} catch (error) {
  console.error(error.message); // Error 404 al obtener el usuario 9999
}
Situación ¿Se rechaza la promesa de fetch? respuesta.ok
200, 201, 204 No true
404, 401, 500… No false
Sin conexión, DNS inexistente Sí: TypeError (“Failed to fetch” en Chrome, “fetch failed” en Node) —
Bloqueada por CORS Sí: TypeError —
Cancelada con AbortController Sí: AbortError —
Tiempo agotado con AbortSignal.timeout Sí: TimeoutError —

5. Leer el cuerpo de la respuesta

Método Devuelve Para
respuesta.json() Promesa con el objeto APIs JSON
respuesta.text() Promesa con una cadena HTML, texto plano, CSV
respuesta.blob() Promesa con un Blob Imágenes, PDF, ficheros para descargar
respuesta.arrayBuffer() Promesa con bytes Datos binarios
respuesta.formData() Promesa con un FormData Respuestas de formulario (poco común)
// Descargar una imagen y mostrarla
const respuesta = await fetch('https://picsum.photos/id/237/400/300');
const imagen = await respuesta.blob();
const url = URL.createObjectURL(imagen); // URL temporal que apunta al Blob en memoria
document.querySelector('#foto').src = url;
// Cuando ya no la necesites: URL.revokeObjectURL(url);

Además del cuerpo, la respuesta tiene otras propiedades: status, statusText, ok, headers, url (la final, tras redirecciones) y redirected.

⚠️ Importante: El cuerpo solo se puede leer una vez. Si llamas dos veces a respuesta.json(), la segunda lanza TypeError: Body is unusable: Body has already been read (el texto varía según el navegador). Si necesitas leerlo dos veces, usa respuesta.clone() antes. Y si el servidor no devuelve JSON válido (por ejemplo, una página HTML de error), json() lanza un SyntaxError.


6. Cabeceras

Enviar cabeceras

const respuesta = await fetch('https://jsonplaceholder.typicode.com/posts', {
  headers: {
    Accept: 'application/json',
    'Accept-Language': 'es-ES',
  },
});

Leer cabeceras de la respuesta

respuesta.headers es un objeto Headers (los nombres no distinguen mayúsculas):

const respuesta = await fetch('https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10');

console.log(respuesta.headers.get('content-type')); // "application/json; charset=utf-8"
console.log(respuesta.headers.get('x-total-count')); // "100" ← total de posts, útil para paginar

for (const [nombre, valor] of respuesta.headers) {
  console.log(`${nombre}: ${valor}`);
}

💡 Consejo: Por seguridad, en peticiones a otro origen el navegador solo deja leer a JavaScript unas pocas cabeceras “seguras” (Content-Type, Content-Length, Cache-Control…). Si puedes leer X-Total-Count de JSONPlaceholder es porque su servidor lo permite expresamente con la cabecera Access-Control-Expose-Headers.


7. Enviar datos: POST, PUT, PATCH y DELETE

Para enviar JSON necesitas tres cosas: el método, la cabecera Content-Type: application/json y el cuerpo convertido con JSON.stringify:

const BASE = 'https://jsonplaceholder.typicode.com';

// POST: crear
const respuesta = await fetch(`${BASE}/posts`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Hola', body: 'Mi primer post', userId: 1 }),
});
console.log(respuesta.status); // 201
console.log(await respuesta.json()); // { title: 'Hola', body: 'Mi primer post', userId: 1, id: 101 }
// PUT: sustituir el recurso COMPLETO (lo que no envíes, se pierde)
await fetch(`${BASE}/posts/1`, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ id: 1, title: 'Nuevo título', body: 'Nuevo texto', userId: 1 }),
});

// PATCH: modificar SOLO los campos enviados
await fetch(`${BASE}/posts/1`, {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Solo cambio el título' }),
});

// DELETE: borrar
const borrado = await fetch(`${BASE}/posts/1`, { method: 'DELETE' });
console.log(borrado.ok); // true

⚠️ Importante: Si olvidas JSON.stringify, fetch convertirá el objeto en la cadena "[object Object]". Y si olvidas la cabecera Content-Type, muchos servidores no sabrán interpretar el cuerpo y responderán 400 o ignorarán los datos.

Opción de fetch Ejemplo Uso
method 'POST' Método HTTP (por defecto 'GET')
headers { 'Content-Type': 'application/json' } Cabeceras de la petición
body JSON.stringify(datos), FormData, URLSearchParams, Blob Cuerpo
signal AbortSignal.timeout(5000) Cancelación y tiempo máximo
credentials 'include' Enviar cookies a otro origen
cache 'no-store' Control de la caché HTTP del navegador

8. Parámetros de consulta: URL y URLSearchParams

Construir URLs concatenando cadenas es una fuente de errores: espacios, &, tildes y otros caracteres especiales deben codificarse. Las clases URL y URLSearchParams lo hacen por ti:

const url = new URL('/posts', 'https://jsonplaceholder.typicode.com');
url.searchParams.set('userId', 1);
url.searchParams.set('_limit', 3);
url.searchParams.append('q', 'sol y mar & más');

console.log(url.href);
// https://jsonplaceholder.typicode.com/posts?userId=1&_limit=3&q=sol+y+mar+%26+m%C3%A1s

const respuesta = await fetch(url); // fetch acepta directamente un objeto URL
// URLSearchParams a partir de un objeto
const parametros = new URLSearchParams({ nombre: 'Ana María', edad: 30 });
console.log(parametros.toString()); // nombre=Ana+Mar%C3%ADa&edad=30

// Y para leer los parámetros de la página actual (?pagina=2&orden=precio)
const actuales = new URLSearchParams(window.location.search);
console.log(actuales.get('pagina')); // "2" (siempre cadenas, o null si no existe)

💡 Consejo: Si concatenas a mano, usa al menos encodeURIComponent(texto) en cada valor. Pero URL y URLSearchParams son más legibles y no se olvidan de nada.


9. Gestión de errores robusta: la función peticion()

Repetir en cada petición headers, JSON.stringify, if (!respuesta.ok), el tiempo máximo… es tedioso y propenso a errores. Lo profesional es escribir una función envoltorio que lo haga siempre igual, con errores personalizados (módulo 10) que permitan distinguir qué ha pasado.

// api.js — envoltorio de fetch con errores claros, JSON y tiempo máximo

export class ErrorHttp extends Error {
  constructor(respuesta, cuerpo) {
    super(`Error HTTP ${respuesta.status} al pedir ${respuesta.url}`);
    this.name = 'ErrorHttp';
    this.status = respuesta.status;
    this.cuerpo = cuerpo; // lo que respondió el servidor (puede traer detalles del error)
  }
}

export class ErrorRed extends Error {
  constructor(mensaje, opciones) {
    super(mensaje, opciones);
    this.name = 'ErrorRed';
  }
}

export async function peticion(url, { metodo = 'GET', datos, token, signal, tiempoMaximo = 8000 } = {}) {
  const cabeceras = { Accept: 'application/json' };
  const opciones = { method: metodo, headers: cabeceras };

  if (datos !== undefined) {
    cabeceras['Content-Type'] = 'application/json';
    opciones.body = JSON.stringify(datos);
  }
  if (token) cabeceras.Authorization = `Bearer ${token}`;

  // Tiempo máximo + (opcionalmente) la señal de cancelación de quien llama
  const senales = [AbortSignal.timeout(tiempoMaximo)];
  if (signal) senales.push(signal);
  opciones.signal = AbortSignal.any(senales);

  let respuesta;
  try {
    respuesta = await fetch(url, opciones);
  } catch (error) {
    if (error.name === 'AbortError') throw error; // cancelación voluntaria: la dejamos pasar tal cual
    if (error.name === 'TimeoutError') {
      throw new ErrorRed(`El servidor no respondió en ${tiempoMaximo / 1000} s`, { cause: error });
    }
    throw new ErrorRed('No se pudo conectar con el servidor', { cause: error });
  }

  // Leemos el cuerpo según su tipo (204 No Content no tiene cuerpo)
  const tipo = respuesta.headers.get('Content-Type') ?? '';
  let cuerpo = null;
  if (respuesta.status !== 204) {
    cuerpo = tipo.includes('application/json') ? await respuesta.json() : await respuesta.text();
  }

  if (!respuesta.ok) throw new ErrorHttp(respuesta, cuerpo);
  return cuerpo;
}

// Atajos para cada método
export const api = {
  get: (url, opciones) => peticion(url, opciones),
  post: (url, datos, opciones) => peticion(url, { ...opciones, metodo: 'POST', datos }),
  put: (url, datos, opciones) => peticion(url, { ...opciones, metodo: 'PUT', datos }),
  patch: (url, datos, opciones) => peticion(url, { ...opciones, metodo: 'PATCH', datos }),
  delete: (url, opciones) => peticion(url, { ...opciones, metodo: 'DELETE' }),
};

Uso (salida real ejecutándolo con Node):

import { api, ErrorHttp, ErrorRed } from './api.js';

const BASE = 'https://jsonplaceholder.typicode.com';

const nuevo = await api.post(`${BASE}/posts`, { title: 'Hola', body: 'Texto', userId: 1 });
console.log(nuevo); // { title: 'Hola', body: 'Texto', userId: 1, id: 101 }

try {
  await api.get(`${BASE}/posts/0`);
} catch (error) {
  if (error instanceof ErrorHttp && error.status === 404) {
    console.log('Ese post no existe'); // ← se ejecuta esto
  } else if (error instanceof ErrorRed) {
    console.log('Problema de conexión:', error.message);
  } else {
    throw error; // errores inesperados: que no se pierdan
  }
}

try {
  await api.get(`${BASE}/photos`, { tiempoMaximo: 1 }); // 1 ms: imposible
} catch (error) {
  console.log(error.name, '-', error.message); // ErrorRed - El servidor no respondió en 0.001 s
  console.log(error.cause.name); // TimeoutError
}

💡 Consejo: Con los errores separados por tipo, la interfaz puede reaccionar con precisión: un ErrorRed merece un botón “Reintentar”; un ErrorHttp con status === 401, redirigir al inicio de sesión; un 422, mostrar los errores de validación que vienen en error.cuerpo junto a cada campo del formulario (módulo 16).


10. Tiempos máximos y cancelación

Ya lo has visto en el módulo 12 y en peticion(), pero merece un caso práctico: el buscador que lanza una petición en cada tecla. Si el usuario escribe “pika”, se lanzan peticiones para “p”, “pi”, “pik” y “pika”, y pueden llegar desordenadas: si la de “pi” tarda más, pisaría los resultados de “pika”. La solución es cancelar la petición anterior al lanzar una nueva:

let controladorActual = null;

async function buscar(texto) {
  controladorActual?.abort(); // cancela la búsqueda anterior, si sigue en marcha
  controladorActual = new AbortController();

  try {
    const url = new URL('https://jsonplaceholder.typicode.com/posts');
    url.searchParams.set('q', texto);
    const resultados = await api.get(url, { signal: controladorActual.signal });
    pintarResultados(resultados);
  } catch (error) {
    if (error.name === 'AbortError') return; // la cancelamos nosotros: no es un error
    mostrarError(error);
  }
}

Combínalo con el debounce del módulo 15 para no lanzar una petición por tecla, sino solo cuando el usuario se detiene.

⚠️ Importante: Al cancelar con abort(), la promesa se rechaza con un AbortError. No lo muestres como un error al usuario: él no ha hecho nada mal. Filtra siempre error.name === 'AbortError'.


11. Peticiones en paralelo

Si necesitas varios datos independientes, pídelos a la vez (módulo 12):

const BASE = 'https://jsonplaceholder.typicode.com';

console.time('paralelo');
const [usuario, posts, tareas] = await Promise.all([
  api.get(`${BASE}/users/1`),
  api.get(`${BASE}/posts?userId=1`),
  api.get(`${BASE}/todos?userId=1`),
]);
console.timeEnd('paralelo'); // ~ lo que tarde la más lenta, no la suma de las tres

console.log(`${usuario.name}: ${posts.length} posts, ${tareas.filter((t) => t.completed).length} de ${tareas.length} tareas hechas`);
// Leanne Graham: 10 posts, 11 de 20 tareas hechas

Y cuando quieras mostrar lo que haya llegado aunque algo falle, Promise.allSettled:

const ids = [1, 2, 9999];
const resultados = await Promise.allSettled(ids.map((id) => api.get(`${BASE}/users/${id}`)));

for (const [i, r] of resultados.entries()) {
  console.log(ids[i], r.status === 'fulfilled' ? r.value.name : `falló (${r.reason.status})`);
}
// 1 Leanne Graham
// 2 Ervin Howell
// 9999 falló (404)

12. Estados de carga en la interfaz

Una petición puede tardar milisegundos o muchos segundos. La interfaz debe reflejar siempre en qué punto está:

Estado Qué mostrar
Cargando Indicador (texto “Cargando…”, spinner o esqueletos grises con la forma del contenido), aria-busy="true" en el contenedor
Éxito con datos Los datos
Éxito sin datos Un mensaje de lista vacía (“Aún no hay tareas”), nunca una pantalla en blanco
Error Un mensaje comprensible y un botón Reintentar

Un patrón reutilizable:

async function cargarEn(contenedor, obtenerDatos, pintar) {
  contenedor.setAttribute('aria-busy', 'true');
  contenedor.replaceChildren(Object.assign(document.createElement('p'), { textContent: 'Cargando...' }));

  try {
    const datos = await obtenerDatos();
    if (Array.isArray(datos) && datos.length === 0) {
      contenedor.replaceChildren(Object.assign(document.createElement('p'), { textContent: 'No hay resultados.' }));
    } else {
      pintar(datos);
    }
  } catch (error) {
    const mensaje = Object.assign(document.createElement('p'), { textContent: `No se pudo cargar: ${error.message}` });
    const reintentar = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Reintentar' });
    reintentar.addEventListener('click', () => cargarEn(contenedor, obtenerDatos, pintar), { once: true });
    contenedor.replaceChildren(mensaje, reintentar);
  } finally {
    contenedor.setAttribute('aria-busy', 'false');
  }
}

💡 Consejo: Para probar estos estados no hace falta una conexión mala: en la pestaña Red de las DevTools puedes simular una red lenta (throttling, por ejemplo “3G lenta”) o desconectarte (“Sin conexión”).


13. Paginación y scroll infinito (adelanto)

Las APIs no devuelven miles de elementos de golpe: los entregan por páginas. JSONPlaceholder usa _page y _limit, y devuelve el total en la cabecera X-Total-Count:

async function obtenerPagina(pagina, porPagina = 10) {
  const url = new URL('https://jsonplaceholder.typicode.com/posts');
  url.searchParams.set('_page', pagina);
  url.searchParams.set('_limit', porPagina);

  const respuesta = await fetch(url);
  if (!respuesta.ok) throw new Error(`Error ${respuesta.status}`);
  const total = Number(respuesta.headers.get('X-Total-Count')); // 100
  return { posts: await respuesta.json(), totalPaginas: Math.ceil(total / porPagina) };
}

Con eso puedes hacer botones “Anterior/Siguiente” o un scroll infinito: cargar la siguiente página cuando el usuario llega al final. Para detectar “ha llegado al final” sin escuchar scroll cientos de veces, se usa IntersectionObserver, que avisa cuando un elemento entra en pantalla (lo verás a fondo en el módulo 18):

<ul id="posts"></ul>
<p id="centinela">Cargando más...</p>
const listaPosts = document.querySelector('#posts');
const centinela = document.querySelector('#centinela');
let paginaActual = 0;
let cargando = false;

const observador = new IntersectionObserver(async ([entrada]) => {
  if (!entrada.isIntersecting || cargando) return;
  cargando = true;
  try {
    const { posts, totalPaginas } = await obtenerPagina(++paginaActual);
    listaPosts.append(...posts.map((p) => Object.assign(document.createElement('li'), { textContent: `${p.id}. ${p.title}` })));
    if (paginaActual >= totalPaginas) {
      observador.disconnect(); // no hay más páginas
      centinela.textContent = 'No hay más posts.';
    }
  } catch (error) {
    paginaActual--; // para reintentar esa página
    centinela.textContent = `Error al cargar: ${error.message}`;
  } finally {
    cargando = false;
  }
});

observador.observe(centinela); // el centinela es un elemento al final de la lista

14. CORS explicado

Qué es

📘 Concepto: Por seguridad, los navegadores aplican la política del mismo origen (same-origin policy): el JavaScript de una página solo puede leer las respuestas de su mismo origen. El origen es la combinación de protocolo + dominio + puerto: https://mitienda.es y https://api.mitienda.es son orígenes distintos, igual que http://localhost:5500 y http://localhost:3000. CORS (Cross-Origin Resource Sharing) es el mecanismo con el que un servidor autoriza a otros orígenes a leer sus respuestas.

¿Por qué existe? Imagina que estás conectado a tu banco y visitas una web maliciosa. Sin esta política, su JavaScript podría hacer fetch('https://mibanco.es/api/movimientos'): el navegador enviaría tus cookies y la web maliciosa leería tus movimientos. Con la política del mismo origen, el navegador no le deja leer la respuesta salvo que el banco lo autorice expresamente.

Cómo funciona

El servidor autoriza con cabeceras en su respuesta, sobre todo Access-Control-Allow-Origin:

Access-Control-Allow-Origin: https://mitienda.es     ← solo ese origen
Access-Control-Allow-Origin: *                       ← cualquiera (APIs públicas)

Para peticiones “no simples” (métodos como PUT/DELETE/PATCH, cabeceras como Authorization o Content-Type: application/json), el navegador envía antes una petición de comprobación (preflight) con el método OPTIONS:

sequenceDiagram
    participant N as Navegador (http://localhost:5500)
    participant S as API (http://localhost:3000)
    N->>S: OPTIONS /tareas<br/>Origin: http://localhost:5500<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: content-type
    S-->>N: 204 No Content<br/>Access-Control-Allow-Origin: *<br/>Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE<br/>Access-Control-Allow-Headers: content-type
    Note over N: ✅ Autorizado: envía la petición real
    N->>S: POST /tareas (con el JSON)
    S-->>N: 201 Created + Access-Control-Allow-Origin: *

Si el servidor no responde con las cabeceras adecuadas, el navegador bloquea la respuesta y verás en la consola algo como:

Access to fetch at 'https://api.otrodominio.com/datos' from origin 'http://localhost:5500'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Y en tu código, fetch se rechaza con un TypeError genérico (“Failed to fetch”), sin más detalles (a propósito, por seguridad).

Qué puede y qué no puede arreglar el front

Situación ¿Lo arreglas desde el front? Solución
La API es tuya No en el front: en el servidor Configurar el servidor para que envíe Access-Control-Allow-Origin (y compañía) para tus orígenes
La API es de otro y no permite CORS No Llamarla desde tu propio servidor (un proxy o backend), donde CORS no existe
Desarrollo local con front y API en puertos distintos En parte Que la API permita localhost, o usar el proxy del servidor de desarrollo (Vite, que verás en el módulo 20, lo ofrece con su opción server.proxy)
“Lo arreglo con mode: 'no-cors'” No Ese modo hace que la petición salga, pero la respuesta es opaca: no puedes leer ni el cuerpo ni el estado
“Lo arreglo con una extensión que desactiva CORS” Solo en tu navegador Tus usuarios no tendrán esa extensión: no es una solución

⚠️ Importante: CORS es una protección del navegador. No existe en Node.js, ni en curl, ni en Postman: por eso una petición “funciona en Postman pero no en la web”. Y no protege a la API de nada: solo protege a los usuarios de páginas maliciosas. La seguridad de la API (autenticación, permisos) es otra cosa.


15. Autenticación con token Bearer (concepto)

Muchas APIs exigen identificarse. El esquema más común en APIs REST es el token Bearer (“al portador”):

  1. El usuario inicia sesión enviando sus credenciales: POST /login con correo y contraseña.
  2. Si son correctas, el servidor responde con un token: una cadena larga y firmada (a menudo un JWT, JSON Web Token) que caduca al cabo de un tiempo.
  3. En cada petición posterior, el cliente envía el token en la cabecera Authorization.
  4. El servidor comprueba el token: si falta o ha caducado responde 401; si es válido pero no tienes permiso para ese recurso, 403.
// 1-2. Iniciar sesión (URL de ejemplo: depende de cada API)
const { token } = await api.post('https://api.ejemplo.es/login', { email, clave });

// 3. Usar el token: nuestra función peticion() ya lo admite
const pedidos = await api.get('https://api.ejemplo.es/mis-pedidos', { token });
// Envía la cabecera → Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

¿Dónde guardar el token? No hay una respuesta perfecta:

Lugar Ventaja Riesgo
Variable en memoria Lo más seguro frente a XSS Se pierde al recargar la página
sessionStorage / localStorage Sobrevive a la recarga Cualquier script inyectado (XSS) puede leerlo
Cookie HttpOnly gestionada por el servidor JavaScript no puede leerla Requiere protección CSRF y configuración del servidor (credentials: 'include')

💡 Consejo: El token es como la llave de casa: quien lo tenga, entra como si fuera tú. Usa siempre HTTPS, no lo escribas en la URL ni en los registros, y protege tu página contra XSS (módulo 14). Si la API es tuya, la cookie HttpOnly suele ser la opción más segura.


16. CRUD completo con json-server

json-server crea en segundos una API REST local y funcional a partir de un fichero JSON: perfecto para practicar y para prototipar el front antes de que exista el backend real. A diferencia de JSONPlaceholder, sí guarda los cambios (en el propio fichero).

Puesta en marcha

Crea una carpeta para el proyecto con esta estructura:

tareas-api/
├── db.json          ← la "base de datos"
└── public/          ← json-server sirve esta carpeta como web estática
    ├── index.html
    ├── api.js       ← la función peticion() de la sección 9
    └── app.js

db.json:

{
  "tareas": [
    { "id": "1", "titulo": "Aprender fetch", "completada": false },
    { "id": "2", "titulo": "Montar json-server", "completada": true }
  ]
}

Arranca el servidor desde la carpeta del proyecto (necesitas Node.js 22.12 o superior para la versión actual):

npx json-server db.json
JSON Server started on PORT :3000
Press CTRL-C to stop
Watching db.json...

Index:
http://localhost:3000/

Static files:
Serving ./public directory if it exists

Endpoints:
http://localhost:3000/tareas

Ya tienes una API completa en http://localhost:3000/tareas. Puedes probarla en el navegador o con curl:

curl http://localhost:3000/tareas/1

⚠️ Importante: La versión actual de json-server es la 1.0 beta (el propio proyecto avisa de que puede tener cambios incompatibles). Diferencias con la antigua 0.17, que verás en muchos tutoriales: los id son siempre cadenas y se generan solos si no los envías ("id": "unF5r0D8PLk"), la paginación usa _page y _per_page (no _limit), y ya no existe la opción --delay. Para simular lentitud usa el throttling de las DevTools. Si quieres fijar una versión concreta, instálala en el proyecto (npm install json-server) en lugar de usar npx a secas.

Rutas y consultas disponibles

Petición Efecto
GET /tareas Lista todas
GET /tareas/1 Una tarea (o 404)
POST /tareas Crea (responde 201 con el id generado)
PUT /tareas/1 Sustituye
PATCH /tareas/1 Modifica parte
DELETE /tareas/1 Borra (responde 200 con la tarea borrada)
GET /tareas?completada=true Filtra por igualdad
GET /tareas?titulo:contains=fetch Filtra con operadores: eq, ne, lt, lte, gt, gte, in, contains, startsWith, endsWith
GET /tareas?_sort=-titulo Ordena (el - invierte el orden)
GET /tareas?_page=1&_per_page=10 Pagina; responde { first, prev, next, last, pages, items, data }

Si el puerto 3000 está ocupado, usa otro con npx json-server db.json --port 3001. Verás también que, al guardar el primer cambio, json-server añade al db.json una línea "$schema" (sirve para que el editor valide el fichero): es normal.

json-server ya envía las cabeceras CORS (Access-Control-Allow-Origin: *), así que también funcionaría si sirvieras el front desde otro puerto (por ejemplo, con Live Server en el 5500). Pero como sirve la carpeta public, lo más sencillo es abrir directamente http://localhost:3000: front y API en el mismo origen, sin CORS.

La interfaz: public/index.html

<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Tareas (CRUD con json-server)</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 2rem auto; padding: 0 1rem; }
      form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
      form input { flex: 1; padding: 0.5rem; }
      #lista { list-style: none; padding: 0; }
      .tarea { display: flex; gap: 0.5rem; align-items: center; padding: 0.4rem 0; border-bottom: 1px solid #eee; }
      .tarea .titulo { flex: 1; }
      .tarea.hecha .titulo { text-decoration: line-through; color: #888; }
      #estado.error { color: crimson; }
      [aria-busy="true"] { opacity: 0.6; }
    </style>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <h1>Mis tareas</h1>
    <form id="form-nueva">
      <input name="titulo" placeholder="Nueva tarea" required aria-label="Nueva tarea">
      <button>Añadir</button>
    </form>
    <form id="form-buscar" role="search">
      <input name="q" type="search" placeholder="Buscar..." aria-label="Buscar tareas">
    </form>
    <p id="estado" role="status"></p>
    <ul id="lista" aria-live="polite"></ul>
  </body>
</html>

La lógica: public/app.js

import { api, ErrorHttp } from './api.js';

const URL_TAREAS = '/tareas'; // ruta relativa: mismo origen que la página
const lista = document.querySelector('#lista');
const estado = document.querySelector('#estado');
const formNueva = document.querySelector('#form-nueva');
const formBuscar = document.querySelector('#form-buscar');

function mostrarEstado(texto, esError = false) {
  estado.textContent = texto;
  estado.className = esError ? 'error' : '';
}

function crearElementoTarea(tarea) {
  const li = document.createElement('li');
  li.className = 'tarea';
  li.classList.toggle('hecha', tarea.completada);
  li.dataset.id = tarea.id;

  const check = Object.assign(document.createElement('input'), { type: 'checkbox', checked: tarea.completada });
  check.dataset.accion = 'alternar';
  check.setAttribute('aria-label', `Completada: ${tarea.titulo}`);

  const titulo = Object.assign(document.createElement('span'), { className: 'titulo', textContent: tarea.titulo });

  const editar = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Editar' });
  editar.dataset.accion = 'editar';
  const borrar = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Borrar' });
  borrar.dataset.accion = 'borrar';

  li.append(check, titulo, editar, borrar);
  return li;
}

// READ: carga (y filtra) las tareas desde la API
async function cargarTareas(busqueda = '') {
  const url = new URL(URL_TAREAS, window.location.origin);
  if (busqueda) url.searchParams.set('titulo:contains', busqueda);

  lista.setAttribute('aria-busy', 'true');
  mostrarEstado('Cargando...');
  try {
    const tareas = await api.get(url);
    lista.replaceChildren(...tareas.map(crearElementoTarea));
    const pendientes = tareas.filter((t) => !t.completada).length;
    mostrarEstado(tareas.length ? `${pendientes} pendientes de ${tareas.length}` : 'No hay tareas.');
  } catch (error) {
    mostrarEstado(`No se pudieron cargar las tareas: ${error.message}`, true);
  } finally {
    lista.setAttribute('aria-busy', 'false');
  }
}

// CREATE
formNueva.addEventListener('submit', async (e) => {
  e.preventDefault();
  const titulo = formNueva.elements.titulo.value.trim();
  if (!titulo) return;

  const boton = formNueva.querySelector('button');
  boton.disabled = true;
  try {
    const creada = await api.post(URL_TAREAS, { titulo, completada: false }); // el id lo genera el servidor
    lista.append(crearElementoTarea(creada));
    formNueva.reset();
    mostrarEstado(`Tarea "${creada.titulo}" creada.`);
  } catch (error) {
    mostrarEstado(`No se pudo crear: ${error.message}`, true);
  } finally {
    boton.disabled = false;
    formNueva.elements.titulo.focus();
  }
});

// UPDATE (PATCH y PUT) y DELETE: un único manejador para toda la lista (delegación)
lista.addEventListener('click', async (e) => {
  const control = e.target.closest('[data-accion]');
  if (!control) return;
  const li = control.closest('.tarea');
  const id = li.dataset.id; // en json-server 1.x los id son cadenas
  const urlTarea = `${URL_TAREAS}/${encodeURIComponent(id)}`;
  const tituloActual = li.querySelector('.titulo').textContent;

  try {
    if (control.dataset.accion === 'alternar') {
      // PATCH: solo cambiamos un campo
      const actualizada = await api.patch(urlTarea, { completada: control.checked });
      li.replaceWith(crearElementoTarea(actualizada));
    }

    if (control.dataset.accion === 'editar') {
      const nuevoTitulo = prompt('Nuevo título:', tituloActual)?.trim();
      if (!nuevoTitulo || nuevoTitulo === tituloActual) return;
      // PUT: enviamos el recurso completo
      const completada = li.querySelector('[data-accion="alternar"]').checked;
      const actualizada = await api.put(urlTarea, { titulo: nuevoTitulo, completada });
      li.replaceWith(crearElementoTarea(actualizada));
    }

    if (control.dataset.accion === 'borrar') {
      if (!confirm(`¿Borrar "${tituloActual}"?`)) return;
      await api.delete(urlTarea);
      li.remove();
    }
    mostrarEstado('Cambios guardados.');
  } catch (error) {
    if (error instanceof ErrorHttp && error.status === 404) {
      mostrarEstado('Esa tarea ya no existe (¿la borraste en otra pestaña?). Recargando...', true);
      await cargarTareas();
    } else {
      mostrarEstado(`No se pudo guardar: ${error.message}`, true);
      if (control.type === 'checkbox') control.checked = !control.checked; // deshacemos el cambio visual
    }
  }
});

// Búsqueda en el servidor con debounce (módulo 15)
let temporizador;
formBuscar.addEventListener('input', () => {
  clearTimeout(temporizador);
  temporizador = setTimeout(() => cargarTareas(formBuscar.elements.q.value.trim()), 300);
});
formBuscar.addEventListener('submit', (e) => e.preventDefault());

cargarTareas();

Copia también api.js (la función peticion() de la sección 9) en public/, abre http://localhost:3000 y prueba a crear, marcar, editar y borrar. Abre db.json en el editor: verás cómo cambia con cada operación. Y en la pestaña Red de las DevTools verás cada petición con su método, su cuerpo y su código de estado.

💡 Consejo: prompt y confirm son la forma más rápida de pedir un dato o una confirmación, pero bloquean la página y no se pueden diseñar. En una aplicación real usarías un dialog (como la galería del módulo 15) o una edición en línea.


17. Consumir una API pública real: buscador de Pokémon

Ahora una API real y gratuita: PokeAPI (https://pokeapi.co), con datos de más de mil Pokémon, sin registro y con CORS abierto. Su recurso GET /api/v2/pokemon/{nombre o número} devuelve un objeto muy grande, del que usaremos id, name, height (en decímetros), weight (en hectogramos), types, stats y sprites. Si el Pokémon no existe, responde 404.

<!doctype html>
<html lang="es">
  <head>
    <meta charset="utf-8">
    <title>Pokédex</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 26rem; margin: 2rem auto; padding: 0 1rem; }
      .ficha { border: 2px solid #ddd; border-radius: 1rem; padding: 1rem; text-align: center; }
      .ficha img { width: 12rem; height: 12rem; }
      .tipo { display: inline-block; padding: 0.1rem 0.6rem; margin: 0.1rem; border-radius: 1rem; background: #eee; }
      .stat { display: grid; grid-template-columns: 9rem 2rem 1fr; gap: 0.5rem; align-items: center; text-align: left; }
      .barra { height: 0.5rem; border-radius: 1rem; background: steelblue; width: calc(var(--valor) / 255 * 100%); }
    </style>
    <script type="module" src="pokedex.js"></script>
  </head>
  <body>
    <h1>Pokédex</h1>
    <form id="buscador" role="search">
      <input name="nombre" placeholder="pikachu, 25, eevee..." required aria-label="Nombre o número">
      <button>Buscar</button>
      <button type="button" id="aleatorio">Aleatorio</button>
    </form>
    <p id="estado" role="status"></p>
    <section id="resultado" aria-live="polite"></section>
  </body>
</html>
// pokedex.js
import { api, ErrorHttp } from './api.js';

const BASE = 'https://pokeapi.co/api/v2/pokemon';
const NOMBRES_STATS = {
  hp: 'PS', attack: 'Ataque', defense: 'Defensa',
  'special-attack': 'At. especial', 'special-defense': 'Def. especial', speed: 'Velocidad',
};

const formulario = document.querySelector('#buscador');
const estado = document.querySelector('#estado');
const resultado = document.querySelector('#resultado');
let controlador = null;

function crear(etiqueta, propiedades = {}, ...hijos) {
  const elemento = Object.assign(document.createElement(etiqueta), propiedades);
  elemento.append(...hijos);
  return elemento;
}

function pintarFicha(pokemon) {
  const imagen = crear('img', {
    src: pokemon.sprites.other['official-artwork'].front_default ?? pokemon.sprites.front_default,
    alt: `Ilustración de ${pokemon.name}`,
  });
  const tipos = crear('p', {}, ...pokemon.types.map((t) => crear('span', { className: 'tipo', textContent: t.type.name })));
  const datos = crear('p', { textContent: `Altura: ${pokemon.height / 10} m · Peso: ${pokemon.weight / 10} kg` });

  const stats = pokemon.stats.map((s) => {
    const barra = crear('div', { className: 'barra' });
    barra.style.setProperty('--valor', s.base_stat); // variable CSS (módulo 14)
    return crear('div', { className: 'stat' },
      crear('span', { textContent: NOMBRES_STATS[s.stat.name] ?? s.stat.name }),
      crear('strong', { textContent: s.base_stat }),
      barra);
  });

  resultado.replaceChildren(
    crear('article', { className: 'ficha' },
      crear('h2', { textContent: `#${pokemon.id} ${pokemon.name}` }), imagen, tipos, datos, ...stats),
  );
}

async function buscar(nombreONumero) {
  controlador?.abort(); // cancelamos la búsqueda anterior si seguía en curso
  controlador = new AbortController();
  estado.textContent = 'Buscando...';
  resultado.setAttribute('aria-busy', 'true');

  try {
    const consulta = encodeURIComponent(String(nombreONumero).trim().toLowerCase());
    const pokemon = await api.get(`${BASE}/${consulta}`, { signal: controlador.signal });
    pintarFicha(pokemon);
    estado.textContent = '';
  } catch (error) {
    if (error.name === 'AbortError') return; // otra búsqueda ha tomado el relevo
    resultado.replaceChildren();
    estado.textContent = error instanceof ErrorHttp && error.status === 404
      ? `No existe ningún Pokémon llamado "${nombreONumero}".`
      : `Error al buscar: ${error.message}`;
  } finally {
    resultado.setAttribute('aria-busy', 'false');
  }
}

formulario.addEventListener('submit', (e) => {
  e.preventDefault();
  buscar(formulario.elements.nombre.value);
});

document.querySelector('#aleatorio').addEventListener('click', () => {
  const numero = Math.floor(Math.random() * 1025) + 1; // los primeros 1025 tienen número de Pokédex
  formulario.elements.nombre.value = numero;
  buscar(numero);
});

buscar('pikachu');

Al cargar, verás la ficha de Pikachu: tipo electric, altura 0.4 m, peso 6 kg y sus estadísticas (PS 35, Ataque 55, Defensa 40, At. especial 50, Def. especial 50, Velocidad 90) con barras proporcionales.

✏️ Ejercicio: Añade una lista de sugerencias con un datalist cargado desde https://pokeapi.co/api/v2/pokemon?limit=2000 (devuelve { count, next, previous, results: [{ name, url }] }). Haz esa petición una sola vez al cargar la página y guarda el resultado en una variable para no repetirla.


18. ¿Y axios?

axios es una librería muy popular para hacer peticiones HTTP. La verás en muchos proyectos y tutoriales:

import axios from 'axios';

const { data } = await axios.get('https://jsonplaceholder.typicode.com/users/1');
console.log(data.name);
Característica fetch axios
Instalación Nativo en navegadores y Node Paquete de npm (npm install axios)
JSON await respuesta.json() y JSON.stringify a mano Automático en ambos sentidos
Errores HTTP (404, 500) No rechaza: hay que mirar ok Rechaza automáticamente
Tiempo máximo y cancelación AbortSignal.timeout, AbortController Opción timeout, y también signal
Interceptores (añadir el token a todas las peticiones…) Con tu función envoltorio Integrados
Progreso de subida No directamente Sí

💡 Consejo: Todo lo que hace axios en el día a día lo resuelve tu función peticion() de unas 40 líneas, sin dependencias. En proyectos nuevos, fetch con un envoltorio propio suele bastar. axios sigue siendo una buena opción si el proyecto ya lo usa o necesitas el progreso de subida de ficheros. Lo importante es entender fetch: axios y el resto de librerías se construyen sobre los mismos conceptos.


19. Ejercicios

Ejercicio 1: Explorador de usuarios

Con JSONPlaceholder, carga /users y muestra una lista de nombres. Al hacer clic en uno, carga en paralelo sus posts (/posts?userId=) y sus tareas (/todos?userId=) y muestra cuántos posts tiene y qué porcentaje de tareas ha completado. Gestiona los estados de carga y error.

Ejercicio 2: Probador de códigos de estado

Escribe una función diagnosticar(url) que haga la petición y muestre: el código y su significado (con una tabla tuya de códigos frecuentes), el Content-Type, el tamaño del cuerpo en caracteres y si fetch se rechazó o no. Pruébala con una URL que exista, una que dé 404, un dominio inventado y una petición con AbortSignal.timeout(1).

Ejercicio 3: Paginación con botones

Muestra los posts de JSONPlaceholder de 10 en 10 con botones “Anterior” y “Siguiente” y un texto “Página X de Y” (calcula Y con la cabecera X-Total-Count). Deshabilita los botones en los extremos y guarda la página actual en la URL con URLSearchParams y history.replaceState, de modo que al recargar se mantenga.

Ejercicio 4: Amplía el CRUD

Añade al CRUD de json-server: (a) un campo prioridad (baja, media, alta) elegido con un select al crear; (b) un select para ordenar la lista con _sort; (c) un filtro “Solo pendientes” que use completada=false; (d) paginación con _page y _per_page usando los campos next y prev de la respuesta.

Ejercicio 5: Interfaz optimista

Modifica la acción “alternar” del CRUD para que sea optimista: actualiza la interfaz al momento, sin esperar al servidor, y si la petición falla, revierte el cambio y muestra un aviso. Simula el fallo deteniendo json-server (Ctrl+C) mientras usas la página.

Ejercicio 6: Buscador con cancelación

Crea un buscador de posts de JSONPlaceholder que filtre por título en el servidor mientras se escribe. Usa debounce (300 ms) y cancela la petición anterior con AbortController. Muestra en un contador cuántas peticiones se han cancelado.

Ejercicio 7: Comparador de Pokémon

Con PokeAPI, crea un formulario con dos campos para comparar dos Pokémon. Pide los dos en paralelo con Promise.allSettled, muestra sus fichas lado a lado y resalta en cada estadística cuál de los dos es mayor. Si uno no existe, muestra el otro igualmente con un aviso.

Ejercicio 8: Reintentos en peticion()

Amplía peticion() con una opción reintentos (por defecto 0) que reintente con backoff exponencial (módulo 12) solo si el error es un ErrorRed o un ErrorHttp con código 5xx o 429, y nunca para POST. Pruébalo con una URL de un servidor que no esté arrancado.


Resumen

Concepto Sintaxis / clave
Petición y respuesta Método + URL + cabeceras + cuerpo → código de estado + cabeceras + cuerpo
Métodos REST GET leer, POST crear, PUT sustituir, PATCH modificar, DELETE borrar
Códigos 2xx éxito, 3xx redirección, 4xx error del cliente, 5xx error del servidor
GET básico const r = await fetch(url); const datos = await r.json();
Comprobar errores HTTP if (!r.ok) throw new Error(...): fetch no rechaza en 404/500
Leer el cuerpo json(), text(), blob() (una sola vez; clone() para repetir)
Cabeceras headers: { ... }; r.headers.get('content-type')
Enviar JSON { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(datos) }
URLs new URL(ruta, base), url.searchParams.set(k, v), new URLSearchParams(obj)
Envoltorio peticion() con ErrorHttp, ErrorRed, JSON automático y tiempo máximo
Tiempo máximo signal: AbortSignal.timeout(ms), combinable con AbortSignal.any([...])
Cancelar controlador.abort() → AbortError (no mostrarlo como error)
Paralelo await Promise.all([...]), Promise.allSettled si algunas pueden fallar
Estados de UI Cargando, datos, vacío, error con “Reintentar”; aria-busy
Paginación _page/_limit (JSONPlaceholder), _page/_per_page (json-server 1.x), IntersectionObserver
CORS El servidor autoriza con Access-Control-Allow-Origin; el front no puede saltárselo
Autenticación Authorization: Bearer <token>; 401 sin identificar, 403 sin permiso
json-server npx json-server db.json → API REST en http://localhost:3000, sirve ./public
axios Librería alternativa; fetch + envoltorio suele bastar