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
fetchy leer sus respuestas correctamente (ok,status,json(),text(),blob()) - Saber por qué
fetchno falla con un 404 y cómo gestionarlo - Enviar datos con
POST,PUT,PATCHyDELETE - Construir URLs con
URLyURLSearchParams - 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 unGETo unPUTque falló, pero hay que tener cuidado al reintentar unPOST.
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 defetchse 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 propiajsonplaceholder.typicode.com) o guárdalos en un fichero.mjsy lánzalos connode. La consola del navegador permiteawaiten 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
fetchsolo 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í quefetchlas entrega como “éxito”. Tienes que comprobarrespuesta.ok(que valetruepara 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 lanzaTypeError: Body is unusable: Body has already been read(el texto varía según el navegador). Si necesitas leerlo dos veces, usarespuesta.clone()antes. Y si el servidor no devuelve JSON válido (por ejemplo, una página HTML de error),json()lanza unSyntaxError.
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 leerX-Total-Countde JSONPlaceholder es porque su servidor lo permite expresamente con la cabeceraAccess-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,fetchconvertirá el objeto en la cadena"[object Object]". Y si olvidas la cabeceraContent-Type, muchos servidores no sabrán interpretar el cuerpo y responderán400o 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. PeroURLyURLSearchParamsson 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
ErrorRedmerece un botón “Reintentar”; unErrorHttpconstatus === 401, redirigir al inicio de sesión; un422, mostrar los errores de validación que vienen enerror.cuerpojunto 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 unAbortError. No lo muestres como un error al usuario: él no ha hecho nada mal. Filtra siempreerror.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.esyhttps://api.mitienda.esson orígenes distintos, igual quehttp://localhost:5500yhttp://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”):
- El usuario inicia sesión enviando sus credenciales:
POST /logincon correo y contraseña. - 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.
- En cada petición posterior, el cliente envía el token en la cabecera
Authorization. - 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
HttpOnlysuele 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
idson siempre cadenas y se generan solos si no los envías ("id": "unF5r0D8PLk"), la paginación usa_pagey_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 usarnpxa 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:
promptyconfirmson 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 undialog(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
datalistcargado desdehttps://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,fetchcon 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 entenderfetch: 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 |