Module 2: Path Operations
GET: Listar y Obtener Recursos
Descripción de la cápsula
En la cápsula anterior definiste el roadmap CRUD y entendiste la idea de usar una lista de diccionarios como "base de datos" en memoria. Ahora vas a implementar las dos operaciones GET más comunes en cualquier API REST: listar todos los recursos y obtener uno por su ID. Parece simple, pero aquí se esconden decisiones importantes — cómo estructuras tus datos, cómo buscas un recurso por ID en una lista, y qué retornas cuando el recurso no existe.
Además, vas a enfrentarte a un pitfall clásico de FastAPI que atrapa incluso a developers con experiencia: el orden de evaluación de rutas. Si defines /books/{book_id} antes de /books/latest, FastAPI interpreta "latest" como un ID y todo explota. Entender por qué ocurre y cómo evitarlo te ahorra horas de debugging en proyectos reales.
Al terminar tendrás una API con datos realistas de libros, dos endpoints GET funcionando, manejo básico de errores, y una comprensión sólida de cómo FastAPI resuelve rutas.
Datos en memoria: Tu colección de libros
Antes de escribir endpoints, necesitas datos. En un proyecto real usarías una base de datos, pero para aprender routing una lista de diccionarios es suficiente.
Ejemplo básico
Crea (o reemplaza) app/main.py con esta estructura:
from fastapi import FastAPI
app = FastAPI()
# Almacenamiento en memoria — se reinicia con cada restart del servidor
books = [
{"id": 1, "title": "Cien años de soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico"},
{"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novela"},
{"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental"},
{"id": 4, "title": "La casa de los espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico"},
{"id": 5, "title": "Ficciones", "author": "Jorge Luis Borges", "year": 1944, "genre": "Cuentos"},
]
Tres decisiones de diseño: IDs numéricos secuenciales (simula auto-increment de una base de datos real), campos consistentes (hace predecible la respuesta), y datos realistas (te acostumbran a trabajar con datos que parecen producción).
Ejemplo intermedio
Agrega un endpoint raíz para verificar que el servidor corre:
@app.get("/")
def root():
return {
"message": "Books API",
"version": "1.0.0",
"total_books": len(books),
}
Levanta el servidor (uvicorn app.main:app --reload) y verifica:
curl http://127.0.0.1:8000/
# → {"message": "Books API", "version": "1.0.0", "total_books": 5}
len(books) es dinámico — si agregas o eliminas libros, el conteo se actualiza solo.
GET /books — Listar todos los recursos
El endpoint más simple de una API REST: retornar la lista completa de recursos.
Ejemplo básico
Agrega este endpoint a app/main.py:
@app.get("/books")
def get_books():
return books
Eso es todo. FastAPI toma la lista de diccionarios y la convierte a JSON array automáticamente.
curl http://127.0.0.1:8000/books
Output esperado (un array JSON con los 5 libros):
[
{"id": 1, "title": "Cien años de soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico"},
{"id": 2, "title": "Don Quijote de la Mancha", ...},
{"id": 3, "title": "Rayuela", ...},
{"id": 4, "title": "La casa de los espíritus", ...},
{"id": 5, "title": "Ficciones", ...}
]
Abre http://127.0.0.1:8000/docs, haz clic en GET /books → "Try it out" → "Execute". Acostúmbrate a este ciclo: escribir endpoint → probar en /docs → verificar.
GET /books/{book_id} — Obtener un recurso por ID
Listar todos los libros es útil, pero la mayoría de las veces necesitas uno específico.
Ejemplo básico
@app.get("/books/{book_id}")
def get_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
return {"error": "Book not found"}
El type hint book_id: int hace que FastAPI valide automáticamente que el valor sea un entero. Si alguien envía /books/abc, FastAPI retorna error 422 sin que escribas validación.
curl http://127.0.0.1:8000/books/1
Output esperado:
{"id": 1, "title": "Cien años de soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico"}
curl http://127.0.0.1:8000/books/3
Output esperado:
{"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental"}
¿Qué pasa cuando el libro no existe?
curl http://127.0.0.1:8000/books/99
Output esperado:
{"error": "Book not found"}
Funciona, pero el status code HTTP sigue siendo 200 (OK) — eso es confuso. En el Módulo 5 aprenderás HTTPException para retornar un 404 real. Por ahora, esta solución básica es suficiente.
Ejemplo intermedio: Usando next() de Python
El loop for funciona, pero Python tiene una forma más idiomática de buscar en listas:
@app.get("/books/{book_id}")
def get_book(book_id: int):
# next() retorna el primer elemento que cumple la condición, o None
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
next() con un generator expression es un patrón común. El segundo argumento (None) es el valor default si no hay coincidencia — sin él, next() lanzaría StopIteration. Ambas versiones producen el mismo resultado; usa la que te resulte más legible.
Ejemplo avanzado: Respuesta de error enriquecida
Puedes dar información contextual cuando el recurso no existe:
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {
"error": "Book not found",
"requested_id": book_id,
"available_ids": [b["id"] for b in books],
}
return book
curl http://127.0.0.1:8000/books/99
Output esperado:
{"error": "Book not found", "requested_id": 99, "available_ids": [1, 2, 3, 4, 5]}
Esto le da al consumidor de tu API suficiente información para corregir su request.
Orden de evaluación de rutas en FastAPI
Esta sección es crítica. Es el pitfall número uno para developers que empiezan con FastAPI.
El problema
Imagina que quieres dos endpoints:
GET /books/latest→ retorna el libro más recienteGET /books/{book_id}→ retorna un libro por ID
Parece directo. Pero el orden en que los defines importa.
La trampa: ruta dinámica primero
Usando la misma lista books, imagina este código:
# ❌ INCORRECTO: La ruta dinámica va primero
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
@app.get("/books/latest")
def get_latest_book():
return max(books, key=lambda b: b["year"])
¿Qué pasa cuando accedes a /books/latest?
curl http://127.0.0.1:8000/books/latest
Output:
{
"detail": [
{
"type": "int_parsing",
"loc": ["path", "book_id"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "latest"
}
]
}
Error 422. FastAPI intentó convertir "latest" a int porque la primera ruta que coincide con /books/algo es /books/{book_id}.
¿Por qué ocurre?
FastAPI evalúa las rutas en el orden en que las defines en tu código. Cuando llega un request a /books/latest:
- Recorre sus rutas de arriba a abajo
- Encuentra
/books/{book_id}— ¿coincide? Sí,"latest"puede ser un path parameter - Intenta convertir
"latest"aint(por el type hint) → falla → error 422 - Nunca llega a
/books/latest
La solución: rutas estáticas primero
# ✅ CORRECTO: Ruta estática va primero
@app.get("/books/latest")
def get_latest_book():
return max(books, key=lambda b: b["year"])
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
Ahora cuando llega /books/latest:
- Encuentra
/books/latest— ¿coincide exactamente? Sí → ejecutaget_latest_book()
Y cuando llega /books/3:
- Encuentra
/books/latest— ¿coincide? No,"3"no es"latest" - Encuentra
/books/{book_id}— ¿coincide? Sí → ejecutaget_book(book_id=3)
curl http://127.0.0.1:8000/books/latest
Output esperado:
{"id": 4, "title": "La casa de los espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico"}
curl http://127.0.0.1:8000/books/3
Output esperado:
{"id": 3, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Novela experimental"}
La regla de oro
Rutas estáticas siempre van antes que rutas dinámicas que comparten el mismo prefijo.
# Todas estas deben ir ANTES de /books/{book_id}
@app.get("/books/latest")
@app.get("/books/count")
@app.get("/books/random")
# Esta va AL FINAL
@app.get("/books/{book_id}")
Ejemplo con múltiples rutas estáticas
import random
# Todas las estáticas van antes de la dinámica
@app.get("/books/latest")
def get_latest_book():
return max(books, key=lambda b: b["year"])
@app.get("/books/count")
def get_books_count():
return {"total": len(books)}
@app.get("/books/random")
def get_random_book():
return random.choice(books)
# Ruta dinámica — siempre al final
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
curl http://127.0.0.1:8000/books/latest # → La casa de los espíritus (1982)
curl http://127.0.0.1:8000/books/count # → {"total": 5}
curl http://127.0.0.1:8000/books/random # → Un libro aleatorio
curl http://127.0.0.1:8000/books/2 # → Don Quijote de la Mancha
Comparación: Lista directa vs objeto envolvente
| Aspecto | Lista directa [{...}] | Objeto envolvente {"total": 5, "books": [...]} |
|---|---|---|
| Simplicidad | Más simple de consumir | Requiere acceder a la key del array |
| Metadatos | No hay espacio para info extra | Puedes agregar total, page, limit |
| Extensibilidad | Cambiar formato después rompe clientes | Agregar campos no rompe nada |
| Uso típico | APIs internas, prototipos | APIs públicas, listas paginadas |
Para esta guía usa la lista directa. Cuando agregues query parameters para filtrar y paginar (Módulo 3), el wrapper cobra más sentido.
Conexión con Proyecto
Los endpoints GET de esta cápsula son la base del proyecto CRUD (Cápsula 05) y del To-Do List API final (Módulo 6). GET /books se convierte en GET /tasks, GET /books/{book_id} en GET /tasks/{task_id}. La estructura es idéntica — en módulos posteriores solo agregarás query parameters (Módulo 3), Pydantic models (Módulo 4), y HTTPException para 404 real (Módulo 5) sobre esta misma base.
Troubleshooting
Problema 1: El endpoint retorna la lista vacía []
Causa: La variable books se definió como lista vacía o se está creando dentro de la función.
Solución:
# ❌ Variable local, vacía cada vez
@app.get("/books")
def get_books():
books = []
return books
# ✅ La lista debe estar fuera de las funciones (variable de módulo)
books = [{"id": 1, "title": "Cien años de soledad", ...}]
@app.get("/books")
def get_books():
return books
Problema 2: GET por ID siempre retorna "Book not found"
Causa: Los tipos no coinciden. El id en el dict es int, pero book_id es str.
Solución:
# ❌ book_id es str, pero los IDs en books son int
@app.get("/books/{book_id}")
def get_book(book_id: str): # int == str → siempre False
...
# ✅ Tipos consistentes
@app.get("/books/{book_id}")
def get_book(book_id: int): # int == int → funciona
...
Problema 3: /books/latest retorna error 422
Causa: La ruta /books/{book_id} está definida antes de /books/latest.
Solución: Mueve la ruta estática antes de la dinámica. Ver la sección "Orden de evaluación de rutas" arriba.
Problema 4: TypeError: 'NoneType' object is not subscriptable
Causa: next() retornó None y no lo verificaste antes de acceder a una key.
Solución:
# ❌ No verifica si book es None
book = next((b for b in books if b["id"] == book_id), None)
return {"title": book["title"]} # Si book es None → TypeError
# ✅ Verifica antes de usar
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
Problema 5: El servidor no levanta
Causa: Error de sintaxis, import faltante, o coma faltante en los dicts de books.
Solución: Revisa la terminal de uvicorn — el error muestra la línea exacta. Verifica con python -c "import app.main".
Ejercicios
Ejercicio 1: Listar y obtener películas (Fácil)
Crea una API con datos de películas (id, title, director, year, genre). Implementa GET /movies y GET /movies/{movie_id} con al menos 4 películas.
Ver solución
from fastapi import FastAPI
app = FastAPI()
movies = [
{"id": 1, "title": "El laberinto del fauno", "director": "Guillermo del Toro", "year": 2006, "genre": "Fantasía"},
{"id": 2, "title": "Roma", "director": "Alfonso Cuarón", "year": 2018, "genre": "Drama"},
{"id": 3, "title": "Amores perros", "director": "Alejandro González Iñárritu", "year": 2000, "genre": "Drama"},
{"id": 4, "title": "El secreto de sus ojos", "director": "Juan José Campanella", "year": 2009, "genre": "Thriller"},
]
@app.get("/movies")
def get_movies():
return movies
@app.get("/movies/{movie_id}")
def get_movie(movie_id: int):
movie = next((m for m in movies if m["id"] == movie_id), None)
if movie is None:
return {"error": "Movie not found"}
return movie
curl http://127.0.0.1:8000/movies
# → Lista completa de 4 películas
curl http://127.0.0.1:8000/movies/2
# → {"id": 2, "title": "Roma", ...}
curl http://127.0.0.1:8000/movies/10
# → {"error": "Movie not found"}
Ejercicio 2: Ruta estática y dinámica (Fácil)
Usando la lista de books de esta cápsula, agrega GET /books/oldest que retorne el libro más antiguo. Asegúrate de que funcione junto con GET /books/{book_id}.
Ver solución
Agrega estos endpoints (usando la misma lista books y setup de la cápsula):
# Estática ANTES de la dinámica
@app.get("/books/oldest")
def get_oldest_book():
return min(books, key=lambda b: b["year"])
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
curl http://127.0.0.1:8000/books/oldest
# → {"id": 2, "title": "Don Quijote de la Mancha", "year": 1605, ...}
curl http://127.0.0.1:8000/books/1
# → {"id": 1, "title": "Cien años de soledad", ...}
Si /books/{book_id} estuviera antes, FastAPI intentaría convertir "oldest" a int → error 422.
Ejercicio 3: Respuesta de error enriquecida (Medio)
Modifica GET /books/{book_id} para que, cuando no encuentre el libro, retorne el mensaje de error, el ID solicitado, y el rango de IDs válidos (mínimo y máximo).
Ver solución
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
all_ids = [b["id"] for b in books]
return {
"error": "Book not found",
"requested_id": book_id,
"valid_range": {"min": min(all_ids), "max": max(all_ids)},
}
return book
curl http://127.0.0.1:8000/books/42
# → {"error": "Book not found", "requested_id": 42, "valid_range": {"min": 1, "max": 5}}
Ejercicio 4: Múltiples rutas estáticas (Medio)
Usando la lista books de la cápsula, implementa cinco endpoints y asegúrate de que todos funcionen: GET /books, GET /books/latest, GET /books/oldest, GET /books/count, y GET /books/{book_id}.
Ver solución
@app.get("/books")
def get_books():
return books
@app.get("/books/latest")
def get_latest_book():
return max(books, key=lambda b: b["year"])
@app.get("/books/oldest")
def get_oldest_book():
return min(books, key=lambda b: b["year"])
@app.get("/books/count")
def get_books_count():
return {"total": len(books)}
# Ruta dinámica AL FINAL
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = next((b for b in books if b["id"] == book_id), None)
if book is None:
return {"error": "Book not found"}
return book
curl http://127.0.0.1:8000/books # → Lista de 5 libros
curl http://127.0.0.1:8000/books/latest # → La casa de los espíritus (1982)
curl http://127.0.0.1:8000/books/oldest # → Don Quijote (1605)
curl http://127.0.0.1:8000/books/count # → {"total": 5}
curl http://127.0.0.1:8000/books/3 # → Rayuela
Las tres rutas estáticas van antes de la dinámica. El orden entre las estáticas no importa entre sí.
Ejercicio 5: API de cursos con filtro por nivel (Difícil)
Crea una API de cursos (id, name, instructor, level, duration_hours) con niveles "beginner", "intermediate", "advanced". Implementa:
GET /courses— todos los cursosGET /courses/beginner— solo cursos beginnerGET /courses/intermediate— solo cursos intermediateGET /courses/advanced— solo cursos advancedGET /courses/{course_id}— curso por ID
Usa al menos 6 cursos. Piensa en el orden de las rutas.
Ver solución
from fastapi import FastAPI
app = FastAPI()
courses = [
{"id": 1, "name": "Python Basics", "instructor": "Ana López", "level": "beginner", "duration_hours": 20},
{"id": 2, "name": "Web Development", "instructor": "Carlos Ruiz", "level": "beginner", "duration_hours": 30},
{"id": 3, "name": "FastAPI Fundamentals", "instructor": "María García", "level": "intermediate", "duration_hours": 15},
{"id": 4, "name": "Database Design", "instructor": "Pedro Martínez", "level": "intermediate", "duration_hours": 25},
{"id": 5, "name": "System Design", "instructor": "Laura Fernández", "level": "advanced", "duration_hours": 40},
{"id": 6, "name": "Microservices", "instructor": "Diego Morales", "level": "advanced", "duration_hours": 35},
]
@app.get("/courses")
def get_courses():
return courses
@app.get("/courses/beginner")
def get_beginner_courses():
return [c for c in courses if c["level"] == "beginner"]
@app.get("/courses/intermediate")
def get_intermediate_courses():
return [c for c in courses if c["level"] == "intermediate"]
@app.get("/courses/advanced")
def get_advanced_courses():
return [c for c in courses if c["level"] == "advanced"]
@app.get("/courses/{course_id}")
def get_course(course_id: int):
course = next((c for c in courses if c["id"] == course_id), None)
if course is None:
return {"error": "Course not found"}
return course
curl http://127.0.0.1:8000/courses/beginner # → 2 cursos beginner
curl http://127.0.0.1:8000/courses/advanced # → 2 cursos advanced
curl http://127.0.0.1:8000/courses/3 # → FastAPI Fundamentals
curl http://127.0.0.1:8000/courses/99 # → {"error": "Course not found"}
En el Módulo 3 aprenderás query parameters (GET /courses?level=beginner), que son más flexibles que rutas estáticas por nivel.
Ejercicio 6: Diagnóstico de orden de rutas (Difícil)
El siguiente código tiene un bug de orden de rutas. Identifica el problema, explica qué pasa al acceder a cada ruta, y corrígelo.
from fastapi import FastAPI
app = FastAPI()
products = [
{"id": 1, "name": "Laptop", "price": 999.99, "featured": True},
{"id": 2, "name": "Mouse", "price": 29.99, "featured": False},
{"id": 3, "name": "Keyboard", "price": 79.99, "featured": True},
]
@app.get("/products/{product_id}")
def get_product(product_id: int):
product = next((p for p in products if p["id"] == product_id), None)
if product is None:
return {"error": "Product not found"}
return product
@app.get("/products/featured")
def get_featured():
return [p for p in products if p["featured"]]
@app.get("/products/cheap")
def get_cheap():
return [p for p in products if p["price"] < 50]
Ver solución
El problema: /products/{product_id} está definida antes de /products/featured y /products/cheap.
Lo que pasa:
GET /products/1→ ✅ Funciona (1 es int válido)GET /products/featured→ ❌ Error 422 (intenta convertir "featured" a int)GET /products/cheap→ ❌ Error 422 (intenta convertir "cheap" a int)
Código corregido:
from fastapi import FastAPI
app = FastAPI()
products = [
{"id": 1, "name": "Laptop", "price": 999.99, "featured": True},
{"id": 2, "name": "Mouse", "price": 29.99, "featured": False},
{"id": 3, "name": "Keyboard", "price": 79.99, "featured": True},
]
@app.get("/products/featured")
def get_featured():
return [p for p in products if p["featured"]]
@app.get("/products/cheap")
def get_cheap():
return [p for p in products if p["price"] < 50]
@app.get("/products/{product_id}")
def get_product(product_id: int):
product = next((p for p in products if p["id"] == product_id), None)
if product is None:
return {"error": "Product not found"}
return product
curl http://127.0.0.1:8000/products/featured # → [Laptop, Keyboard]
curl http://127.0.0.1:8000/products/cheap # → [Mouse]
curl http://127.0.0.1:8000/products/1 # → Laptop
Resumen
GET /booksretorna la lista completa — FastAPI convierte la lista de dicts a JSON array automáticamenteGET /books/{book_id}usa un path parameter con type hintintpara obtener un recurso por ID- Manejo básico de "no encontrado": retorna
{"error": "Book not found"}— en Módulo 5 usarásHTTPExceptioncon status 404 next()con generator expression es la forma idiomática de buscar un elemento en una lista- Orden de rutas: las estáticas (
/books/latest) siempre van antes que las dinámicas (/books/{book_id}) - Prueba en
/docscada endpoint nuevo — es tu herramienta principal de verificación - Datos en memoria (lista de dicts) son suficientes para aprender routing
Próxima cápsula: POST: Crear recursos — Aprenderás cómo FastAPI recibe JSON en el request body y cómo crear nuevos items con IDs automáticos y status code 201.
Recursos Adicionales
- FastAPI - Path Parameters - Path parameters y orden de rutas en la documentación oficial
- FastAPI - First Steps - Fundamentos de path operations
- Python - next() Built-in - Documentación de
next()con valores default - HTTP GET Method - MDN - Especificación del método GET
- REST API Tutorial - HTTP GET - Convenciones REST para endpoints GET
- FastAPI - Interactive Docs - Cómo usar la documentación interactiva /docs