#fastapi#angular#full-stack#IA

Armar una Pokédex desde cero: de FastAPI a Angular 22 Gemini vs Claude

Un backend en FastAPI que traduce la PokéAPI al español y un frontend en Angular 22 sin Zone.js: contrato REST limpio, catálogo completo cacheado en el cliente y tres hallazgos al verificar los datos.

🔴 Ver la Pokédex en vivo

Arranqué el backend con Gemini, seguí el frontend con Claude, y terminé con una Pokédex completa en español —los 1351 Pokémon, las 68 bayas— con el rojo de siempre.

El objetivo

La idea era simple de enunciar y bastante menos simple de cumplir: una Pokédex en español, navegable, con información real de todos los Pokémon —formas regionales, mega evoluciones, variantes de evento incluidas— y de todas las bayas, con el rojo característico del dispositivo original como identidad visual. Sin atajos: nada de mostrar solo los primeros 151, nada de dejar afuera las bayas por ser menos vistosas.

El backend ya estaba armado con Gemini cuando arranqué esta etapa con Claude —una API en FastAPI que consulta la PokéAPI pública—, y la regla que le puse fue clara: no tocarlo salvo que apareciera un bug real. Esa restricción, que en el papel sonaba incómoda, terminó siendo productiva: obligó a resolver todo del lado del cliente y dejó al descubierto, más adelante, algunos supuestos que el backend daba por sentados.

Dos piezas, un contrato

El proyecto vive en dos repositorios independientes que solo se conocen a través de un contrato REST en JSON. El backend no sabe que existe Angular; el frontend no sabe que existe PokéAPI. Esa separación es la que después permitió desplegar cada pieza en una plataforma distinta sin fricción.

┌──────────────────────┐        ┌──────────────────────┐        ┌──────────────────────┐
│      Angular 22      │        │       FastAPI        │        │       PokéAPI        │
│  Vercel · estático   │ ─────▶ │   Render · Python    │ ─────▶ │      pokeapi.co      │
└──────────────────────┘  JSON  └──────────────────────┘  JSON  └──────────────────────┘
  • Angular (Vercel): cachea el catálogo completo en el cliente.
  • FastAPI (Render): traduce y recorta el payload.
  • PokéAPI: fuente de datos original.

El backend: un traductor, no un espejo

Este lo construí con Gemini, en varias vueltas: arrancó como un único endpoint de detalle, después sumé la lista paginada, lo refactoricé a programación orientada a objetos con una clase cliente, y terminé separándolo en dos servicios —uno para Pokémon y tipos, otro para bayas— detrás de cinco endpoints en total.

Cada endpoint hace, en esencia, lo mismo: pedirle datos en inglés a la PokéAPI, traducirlos y devolver solo lo que la interfaz necesita. Nada de reenviar el JSON crudo de PokéAPI, que trae decenas de campos que la app nunca usa. Ahí vive la mayor parte de la lógica de curaduría: un diccionario traduce los 18 tipos elementales (firefuego, psychicpsíquico), otro hace lo mismo con los cinco sabores de las bayas, y la biografía de cada Pokémon se busca específicamente en español dentro de la lista de textos que trae la API, con un mensaje de reserva cuando esa traducción no existe.

5 endpoints en total
2 servicios (Pokémon / Bayas)
1351 formas de Pokémon servidas

El frontend: Angular sin Zone.js

Para esta parte recurrí a Claude. La primera decisión estructural vino de Angular 22: componentes standalone por defecto, estado con signal() y computed() en vez de propiedades sueltas y, lo más notorio, sin zone.js instalado. En un proyecto zoneless, si algo no pasa por un signal, la vista no se entera de que cambió. Esa restricción obligó a que todo el estado de carga, error y resultados de cada pantalla viviera explícitamente en signals desde el primer commit.

El código quedó organizado en tres capas: core/ con los modelos, servicios HTTP y datos estáticos (colores por tipo, traducciones que no viven en el backend); shared/ con componentes sin estado de negocio —el buscador, la paginación, la insignia de tipo—; y features/ con una carpeta por pantalla, cada una cargada con lazy loading por ruta.

Un límite del backend, resuelto en el cliente

El endpoint de lista acepta como máximo 100 elementos por página (Query(..., le=100)), pero quería que la búsqueda se sintiera instantánea sobre los 1351 Pokémon. La solución fue pedir el catálogo completo una sola vez, en paralelo y en bloques de 100, cachearlo, y resolver búsqueda y paginación enteramente en el cliente:

// pokemon.service.ts — descarga el catálogo completo respetando el límite del backend
for (let offset = 100; offset < total; offset += 100) {
  paginasRestantes.push(this.obtenerLista(100, offset));
}
return forkJoin(paginasRestantes).pipe(
  map(paginas => ({ total, resultados: [primeraPagina, ...paginas].flatMap(p => p.resultados) }))
);

Después de esa primera carga, buscar "pikachu" o pasar de la página 1 a la 57 no dispara ninguna petición nueva: es un computed() filtrando un array en memoria.

Cuando los datos no cooperan

Antes de dar por cerrado el trabajo, con Claude corrimos una verificación automatizada contra los 1351 Pokémon y las 68 bayas, endpoint por endpoint. Aparecieron tres hallazgos: un bug real, un caso límite legítimo y —releyendo después la conversación con la que había armado el backend con Gemini— un tercero que todavía no había roto nada en producción.

El bug: firmeza nula

Las bayas Kee y Maranga devolvían 404 como si no existieran. La causa estaba en berry_service.py: la PokéAPI entrega firmness: null para esas dos, y data.get("firmness", {}) solo aplica el valor por defecto cuando la clave falta, no cuando vale null. El .get("name") encadenado explotaba sobre None, y el manejador genérico de errores lo disfrazaba de "no encontrada".

- firmeza_original = data.get("firmness", {}).get("name", "desconocida")
+ firmeza_original = (data.get("firmness") or {}).get("name", "desconocida")

El caso límite: sin artwork oficial

Doce formas muy especiales —Mimikyu-busted, las cuatro formas de Koraidon, las cuatro de Miraidon, dos variantes de Tatsugiri— no tienen artwork oficial publicado en la PokéAPI. Ahí el backend ya respondía bien, con imagen: null; lo que faltaba era un respaldo visual en el frontend para no mostrar una imagen rota.

El mismo patrón, una vez más

Después del fix de las bayas, releí con Claude la conversación original con la que había construido el backend con Gemini. Ahí quedó claro que dict.get(clave, {}).get(subclave, default) no fue un descuido puntual: es un patrón que Gemini repitió a propósito en todo el código, pensado como red de seguridad contra claves ausentes. Esa red tiene el mismo agujero en cualquier lugar donde se use, así que valía la pena auditar el resto del código con eso en mente. Apareció un caso más, en el grito de cada Pokémon —sin haberse disparado nunca en producción, porque ningún Pokémon actual trae cries: null—, pero con la misma fragilidad latente:

- "grito": data_pokemon.get("cries", {}).get("latest")
+ "grito": (data_pokemon.get("cries") or {}).get("latest")

Los tres hallazgos vinieron de tratar los datos externos como lo que son: territorio impredecible. Ninguno se habría visto navegando manualmente unos minutos la app.

De localhost a internet

Desplegué el backend en Render (plan gratuito, con el conocido costo de "despertar" tras un rato sin tráfico) y el frontend en Vercel, que compila y sirve el build estático de Angular directamente desde el repositorio. La única pieza que cambia entre entornos es una constante: la URL del backend en environment.ts, que reemplacé una vez que Render asignó su dominio.

El CORS del backend ya venía abierto (allow_origins=["*"]) desde el primer commit con Gemini, así que conectar ambos servicios en producción no me hizo tocar una sola línea más.

Lo que queda

Lo interesante de este proyecto no fue "hacer una Pokédex" sino sostener, primero con Gemini y después con Claude, un contrato limpio entre dos sistemas que no comparten lenguaje ni runtime: el backend traduce y cura, el frontend cachea y da resiliencia visual cuando los datos vienen incompletos. Ninguna de las dos partes intenta compensar a la otra por fuera de ese contrato, y cuando algo se rompió, romperse fue suficientemente ruidoso como para encontrarlo antes de publicarlo.