Module 3: Request and Response
Proyecto: Books API con Filtros, Búsqueda y Paginación
Descripción del proyecto
En este proyecto integras todo lo que aprendiste en el Módulo 3: query parameters con Query(), path parameters con Path(), request body con Body(), headers de respuesta, y estructura de respuesta consistente. Tu CRUD del Módulo 2 funcionaba, pero era básico — GET /books retornaba todos los libros sin filtros, sin paginación, sin búsqueda. Ahora lo transformas en una API profesional que soporta filtrado por género, autor y disponibilidad, búsqueda por texto, paginación con skip y limit, ordenamiento por cualquier campo, y respuestas consistentes con metadatos en headers.
La diferencia entre una API de principiante y una profesional no está en los endpoints que tiene, sino en cómo maneja los datos que recibe y retorna. Un GET /books que devuelve 10,000 libros sin opción de filtrar es inútil en producción. Un GET /books?genre=Ficción&available=true&sort_by=year&order=desc&skip=0&limit=10 es lo que un frontend real necesita. Eso es exactamente lo que construirás aquí.
Este proyecto es la base sobre la que el Módulo 4 (Pydantic) agregará validación con modelos tipados y el Módulo 5 (Error Handling) reemplazará los wrappers manuales con HTTPException. Cada módulo refina sin reescribir.
Objetivos del proyecto
Al completar este proyecto:
- ✅ Tu API filtra libros por género, autor, disponibilidad y rango de años usando
Query()con validaciones - ✅ La búsqueda por texto funciona case-insensitive en título y autor
- ✅ La paginación con
skipylimitretorna subconjuntos controlados - ✅ El ordenamiento por
title,yearoauthorfunciona en ambas direcciones (asc/desc) - ✅ Los path parameters usan
Path()con constraints (ge=1) - ✅ El request body usa
Body()con metadata descriptivo - ✅ Todas las respuestas siguen una estructura consistente con wrappers
- ✅ Los headers de respuesta incluyen
X-Total-Counten endpoints de lista - ✅ Un endpoint
/books/statsofrece estadísticas agregadas de la colección - ✅ El código es limpio con funciones auxiliares reutilizables y type hints
¿Por qué este proyecto?
En el Módulo 2 construiste un CRUD funcional. Pero la lectura era todo-o-nada: GET /books retornaba la lista completa, y GET /books/{id} retornaba uno solo. No había punto medio. En una API real un frontend necesita filtrar por disponibilidad, buscar por texto, paginar con skip y limit, ordenar por cualquier campo. Este proyecto cierra esa brecha. La evolución es clara:
Módulo 2: Módulo 3 (este proyecto):
────────── ─────────────────────────
GET /books → todos → GET /books?genre=Ficción&skip=0&limit=5
GET /books/{id} → sin validación → GET /books/{id} con Path(ge=1)
POST /books → dict suelto → POST /books con Body() descriptivo
return dict → success_response() / error_response()
sin headers → X-Total-Count en respuestas
sin stats → GET /books/stats con agregaciones
El proyecto completo
app/main.py
from fastapi import FastAPI, Query, Path, Body, Response
from fastapi.responses import JSONResponse
app = FastAPI(
title="Books API",
description="API de gestión de libros con filtros, búsqueda y paginación. Módulo 3 — FastAPI Fundamentals.",
version="3.0.0",
)
# --- Datos en memoria ---
books: list[dict] = [
{
"id": 1,
"title": "Cien Años de Soledad",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Realismo mágico",
"available": True,
},
{
"id": 2,
"title": "Don Quijote de la Mancha",
"author": "Miguel de Cervantes",
"year": 1605,
"genre": "Novela",
"available": True,
},
{
"id": 3,
"title": "El Aleph",
"author": "Jorge Luis Borges",
"year": 1949,
"genre": "Ficción",
"available": False,
},
{
"id": 4,
"title": "Rayuela",
"author": "Julio Cortázar",
"year": 1963,
"genre": "Ficción",
"available": True,
},
{
"id": 5,
"title": "Pedro Páramo",
"author": "Juan Rulfo",
"year": 1955,
"genre": "Realismo mágico",
"available": False,
},
{
"id": 6,
"title": "La Casa de los Espíritus",
"author": "Isabel Allende",
"year": 1982,
"genre": "Realismo mágico",
"available": True,
},
{
"id": 7,
"title": "Ficciones",
"author": "Jorge Luis Borges",
"year": 1944,
"genre": "Ficción",
"available": True,
},
{
"id": 8,
"title": "Conversación en La Catedral",
"author": "Mario Vargas Llosa",
"year": 1969,
"genre": "Novela",
"available": True,
},
]
# --- Funciones auxiliares ---
def find_book(book_id: int) -> dict | None:
"""Busca un libro por ID. Retorna None si no existe."""
return next((book for book in books if book["id"] == book_id), None)
def generate_id() -> int:
"""Genera el siguiente ID disponible."""
if not books:
return 1
return max(book["id"] for book in books) + 1
def success_response(data: dict | list, message: str = "OK") -> dict:
"""Wrapper de respuesta exitosa con estructura consistente."""
return {"status": "success", "message": message, "data": data}
def error_response(message: str, status_code: int = 400) -> JSONResponse:
"""Wrapper de respuesta de error con status code dinámico."""
return JSONResponse(
status_code=status_code,
content={"status": "error", "message": message, "data": None},
)
# --- Endpoint raíz ---
@app.get("/", tags=["General"], summary="Root")
def root():
"""Información del servicio."""
return {
"service": "Books API",
"version": "3.0.0",
"endpoints": {
"books": "/books",
"stats": "/books/stats",
"docs": "/docs",
},
}
# --- Estadísticas (antes de /books/{book_id} para evitar conflicto de rutas) ---
@app.get("/books/stats", tags=["Books"], summary="Book statistics")
def book_stats(response: Response):
"""Estadísticas agregadas de la colección de libros."""
total = len(books)
available = sum(1 for b in books if b["available"])
unavailable = total - available
# Conteo por género
genre_counts: dict[str, int] = {}
for book in books:
genre = book["genre"]
genre_counts[genre] = genre_counts.get(genre, 0) + 1
response.headers["X-Total-Count"] = str(total)
return success_response(
data={
"total": total,
"available": available,
"unavailable": unavailable,
"by_genre": genre_counts,
"by_availability": {
"available": available,
"unavailable": unavailable,
},
},
message="Book statistics",
)
# --- Endpoints de lectura ---
@app.get("/books", tags=["Books"], summary="List books with filters")
def list_books(
response: Response,
genre: str | None = Query(
default=None,
description="Filtrar por género exacto (ej: Ficción, Novela, Realismo mágico)",
),
author: str | None = Query(
default=None,
description="Filtrar por nombre de autor (búsqueda parcial, case-insensitive)",
),
available: bool | None = Query(
default=None,
description="Filtrar por disponibilidad: true o false",
),
search: str | None = Query(
default=None,
min_length=1,
max_length=100,
description="Buscar en título y autor (case-insensitive)",
),
min_year: int | None = Query(
default=None,
ge=0,
description="Año mínimo de publicación",
),
max_year: int | None = Query(
default=None,
le=2100,
description="Año máximo de publicación",
),
skip: int = Query(
default=0,
ge=0,
description="Número de resultados a saltar (offset para paginación)",
),
limit: int = Query(
default=10,
ge=1,
le=100,
description="Número máximo de resultados a retornar (1-100)",
),
sort_by: str = Query(
default="id",
description="Campo para ordenar: title, year, author, id",
),
order: str = Query(
default="asc",
description="Dirección del orden: asc o desc",
),
):
"""
Lista libros con filtros, búsqueda, paginación y ordenamiento.
Todos los parámetros son opcionales. Sin parámetros retorna
los primeros 10 libros ordenados por ID.
"""
results = books.copy()
# Filtro por género (coincidencia exacta, case-insensitive)
if genre is not None:
results = [
b for b in results
if b["genre"].lower() == genre.lower()
]
# Filtro por autor (búsqueda parcial, case-insensitive)
if author is not None:
results = [
b for b in results
if author.lower() in b["author"].lower()
]
# Filtro por disponibilidad
if available is not None:
results = [b for b in results if b["available"] == available]
# Búsqueda de texto en título y autor
if search is not None:
query = search.lower()
results = [
b for b in results
if query in b["title"].lower() or query in b["author"].lower()
]
# Filtro por rango de años
if min_year is not None:
results = [b for b in results if b["year"] >= min_year]
if max_year is not None:
results = [b for b in results if b["year"] <= max_year]
# Total después de filtros (antes de paginar)
total_filtered = len(results)
# Ordenamiento
valid_sort_fields = ["title", "year", "author", "id"]
if sort_by not in valid_sort_fields:
return error_response(
f"sort_by debe ser uno de: {', '.join(valid_sort_fields)}",
status_code=400,
)
reverse = order.lower() == "desc"
results.sort(key=lambda b: b[sort_by], reverse=reverse)
# Paginación
paginated = results[skip : skip + limit]
# Headers de respuesta con metadatos
response.headers["X-Total-Count"] = str(total_filtered)
response.headers["X-Skip"] = str(skip)
response.headers["X-Limit"] = str(limit)
return success_response(
data=paginated,
message=f"{total_filtered} books found, showing {len(paginated)}",
)
@app.get("/books/{book_id}", tags=["Books"], summary="Get book by ID")
def get_book(
book_id: int = Path(
...,
ge=1,
description="ID del libro (debe ser >= 1)",
),
):
"""Retorna un libro por su ID."""
book = find_book(book_id)
if book is None:
return error_response(f"Book with id {book_id} not found", status_code=404)
return success_response(data=book)
# --- Endpoint de creación ---
@app.post("/books", tags=["Books"], summary="Create a new book", status_code=201)
def create_book(
book: dict = Body(
...,
example={
"title": "El Túnel",
"author": "Ernesto Sabato",
"year": 1948,
"genre": "Ficción",
"available": True,
},
),
notify: bool = Query(
default=False,
description="Si es true, simula enviar una notificación de libro nuevo",
),
):
"""
Crea un libro nuevo con ID auto-generado.
Envía un JSON con: title, author, year, genre, available.
"""
new_book = {"id": generate_id(), **book}
books.append(new_book)
message = "Book created"
if notify:
message += " (notification sent)"
return success_response(data=new_book, message=message)
# --- Endpoints de actualización ---
@app.put("/books/{book_id}", tags=["Books"], summary="Full update", status_code=200)
def update_book(
book_id: int = Path(
...,
ge=1,
description="ID del libro a actualizar",
),
book: dict = Body(
...,
example={
"title": "Cien Años de Soledad (Edición Aniversario)",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Realismo mágico",
"available": True,
},
),
):
"""
Reemplaza todos los campos de un libro existente.
Envía TODOS los campos (title, author, year, genre, available).
"""
existing_book = find_book(book_id)
if existing_book is None:
return error_response(f"Book with id {book_id} not found", status_code=404)
index = books.index(existing_book)
books[index] = {"id": book_id, **book}
return success_response(data=books[index], message="Book updated")
@app.patch("/books/{book_id}", tags=["Books"], summary="Partial update", status_code=200)
def partial_update_book(
book_id: int = Path(
...,
ge=1,
description="ID del libro a actualizar parcialmente",
),
updates: dict = Body(
...,
example={"available": False},
),
):
"""
Actualiza solo los campos proporcionados.
Envía solo los campos que quieres cambiar.
"""
existing_book = find_book(book_id)
if existing_book is None:
return error_response(f"Book with id {book_id} not found", status_code=404)
existing_book.update(updates)
return success_response(data=existing_book, message="Book partially updated")
# --- Endpoint de eliminación ---
@app.delete("/books/{book_id}", tags=["Books"], summary="Delete book", status_code=200)
def delete_book(
book_id: int = Path(
...,
ge=1,
description="ID del libro a eliminar",
),
):
"""Elimina un libro por su ID."""
book = find_book(book_id)
if book is None:
return error_response(f"Book with id {book_id} not found", status_code=404)
books.remove(book)
return success_response(
data={"deleted_id": book_id},
message="Book deleted",
)
Ejecutar
uvicorn app.main:app --reload
Abre http://127.0.0.1:8000/docs y verás todos los endpoints con sus parámetros documentados automáticamente.
Verificación paso a paso
Sigue este flujo completo para verificar que cada feature funciona. Usa /docs o curl.
Paso 1: Listar todos los libros (sin filtros)
curl -s http://127.0.0.1:8000/books | python -m json.tool
{
"status": "success",
"message": "8 books found, showing 8",
"data": [
{"id": 1, "title": "Cien Años de Soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico", "available": true},
{"id": 2, "title": "Don Quijote de la Mancha", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novela", "available": true},
{"id": 3, "title": "El Aleph", "author": "Jorge Luis Borges", "year": 1949, "genre": "Ficción", "available": false},
{"id": 4, "title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Ficción", "available": true},
{"id": 5, "title": "Pedro Páramo", "author": "Juan Rulfo", "year": 1955, "genre": "Realismo mágico", "available": false},
{"id": 6, "title": "La Casa de los Espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico", "available": true},
{"id": 7, "title": "Ficciones", "author": "Jorge Luis Borges", "year": 1944, "genre": "Ficción", "available": true},
{"id": 8, "title": "Conversación en La Catedral", "author": "Mario Vargas Llosa", "year": 1969, "genre": "Novela", "available": true}
]
}
Los 8 libros aparecen ordenados por id (default). El wrapper success_response envuelve todo.
Paso 2: Filtrar por género
curl -s "http://127.0.0.1:8000/books?genre=Ficci%C3%B3n" | python -m json.tool
Retorna 3 libros (El Aleph, Rayuela, Ficciones) con "message": "3 books found, showing 3".
Paso 3: Buscar por texto
curl -s "http://127.0.0.1:8000/books?search=borges" | python -m json.tool
Retorna 2 libros (El Aleph, Ficciones) — busca "borges" en título y autor, case-insensitive.
Paso 4: Filtrar por rango de años
curl -s "http://127.0.0.1:8000/books?min_year=1960&max_year=1970" | python -m json.tool
Retorna 3 libros publicados entre 1960 y 1970: Cien Años de Soledad (1967), Rayuela (1963), Conversación en La Catedral (1969).
Paso 5: Paginación
curl -s "http://127.0.0.1:8000/books?skip=2&limit=3" | python -m json.tool
Salta los 2 primeros y retorna los siguientes 3. El mensaje dice "8 books found, showing 3" — el total es antes de paginar, el showing es después.
Paso 6: Filtros combinados con ordenamiento
curl -s "http://127.0.0.1:8000/books?genre=Realismo+m%C3%A1gico&available=true&sort_by=year&order=desc" | python -m json.tool
{
"status": "success",
"message": "2 books found, showing 2",
"data": [
{"id": 6, "title": "La Casa de los Espíritus", "author": "Isabel Allende", "year": 1982, "genre": "Realismo mágico", "available": true},
{"id": 1, "title": "Cien Años de Soledad", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico", "available": true}
]
}
Filtra por género "Realismo mágico" + solo disponibles, ordenados por año descendente. La Casa de los Espíritus (1982) aparece antes que Cien Años de Soledad (1967).
Paso 7: Crear un libro nuevo
curl -s -X POST "http://127.0.0.1:8000/books?notify=true" \
-H "Content-Type: application/json" \
-d '{"title": "El Túnel", "author": "Ernesto Sabato", "year": 1948, "genre": "Ficción", "available": true}' \
| python -m json.tool
Retorna "message": "Book created (notification sent)" con id: 9. El query param notify=true activa el mensaje de notificación simulada.
Paso 8: Actualizar un libro (PUT)
curl -s -X PUT http://127.0.0.1:8000/books/1 \
-H "Content-Type: application/json" \
-d '{"title": "Cien Años de Soledad (Edición Conmemorativa)", "author": "Gabriel García Márquez", "year": 1967, "genre": "Realismo mágico", "available": false}' \
| python -m json.tool
Retorna "message": "Book updated" con el libro actualizado. PUT reemplaza todos los campos; el ID se preserva.
Paso 9: Obtener un libro por ID
curl -s http://127.0.0.1:8000/books/1 | python -m json.tool
Confirma que el PUT del paso anterior aplicó los cambios — el título ahora dice "(Edición Conmemorativa)".
Paso 10: Estadísticas
curl -s http://127.0.0.1:8000/books/stats | python -m json.tool
{
"status": "success",
"message": "Book statistics",
"data": {
"total": 8,
"available": 6,
"unavailable": 2,
"by_genre": {
"Realismo mágico": 3,
"Novela": 2,
"Ficción": 3
},
"by_availability": {
"available": 6,
"unavailable": 2
}
}
}
Estadísticas calculadas dinámicamente sobre los datos actuales.
Paso 11: Verificar headers de respuesta
curl -v -s "http://127.0.0.1:8000/books?limit=3" 2>&1 | grep -i "x-"
< x-total-count: 8
< x-skip: 0
< x-limit: 3
Los headers X-Total-Count, X-Skip y X-Limit acompañan la respuesta sin contaminar el body.
Paso 12: Probar error — libro no encontrado
curl -s http://127.0.0.1:8000/books/999 | python -m json.tool
{
"status": "error",
"message": "Book with id 999 not found",
"data": null
}
El wrapper error_response retorna status 404 con la misma estructura que success_response. El cliente siempre puede verificar response["status"].
Paso 13: Probar desde /docs
Abre http://127.0.0.1:8000/docs. En GET /books despliega los parámetros — debes ver los 10 query params con sus descripciones, tipos y defaults. Ejecuta GET /books?genre=Ficción&sort_by=year&order=desc desde "Try it out".
Checklist de completitud
Query Parameters (GET /books):
- [ ] genre filtra por género exacto
- [ ] author filtra por autor (parcial, case-insensitive)
- [ ] available filtra por disponibilidad (true/false)
- [ ] search busca en título y autor (case-insensitive)
- [ ] min_year filtra año mínimo
- [ ] max_year filtra año máximo
- [ ] skip y limit paginan correctamente
- [ ] sort_by ordena por title, year, author o id
- [ ] order funciona como asc y desc
- [ ] sort_by inválido retorna error 400
Path Parameters:
- [ ] GET /books/{book_id} usa Path(ge=1)
- [ ] PUT /books/{book_id} usa Path(ge=1)
- [ ] PATCH /books/{book_id} usa Path(ge=1)
- [ ] DELETE /books/{book_id} usa Path(ge=1)
Body Parameters:
- [ ] POST /books usa Body() con example
- [ ] PUT /books/{book_id} usa Body() con example
- [ ] PATCH /books/{book_id} usa Body() con example
Respuestas:
- [ ] Todas las respuestas usan success_response() o error_response()
- [ ] X-Total-Count presente en GET /books y GET /books/stats
- [ ] X-Skip y X-Limit presentes en GET /books
- [ ] Errores retornan status 404 con estructura consistente
Funcionalidad:
- [ ] 8 libros precargados con datos realistas
- [ ] GET /books/stats retorna estadísticas correctas
- [ ] POST con notify=true incluye mensaje de notificación
- [ ] Filtros se combinan correctamente (género + disponibilidad + rango)
- [ ] Paginación muestra total filtrado vs resultados mostrados
Documentación:
- [ ] /docs muestra descripciones en cada parámetro
- [ ] Tags organizan endpoints bajo "General" y "Books"
- [ ] Examples aparecen en POST, PUT, PATCH
Rúbrica de evaluación (100 puntos)
Query parameters con Query() (20 puntos)
- (4 pts) Cada query param usa
Query()condescription - (4 pts)
skiptienege=0,limittienege=1yle=100 - (4 pts)
searchtienemin_length=1ymax_length=100 - (4 pts)
min_yeartienege=0,max_yeartienele=2100 - (4 pts) Valores por defecto correctos (
skip=0,limit=10,sort_by="id",order="asc")
Path parameters con Path() (10 puntos)
- (3 pts)
book_idusaPath(..., ge=1)en GET por ID - (3 pts)
book_idusaPath(..., ge=1)en PUT y PATCH - (2 pts)
book_idusaPath(..., ge=1)en DELETE - (2 pts)
Path()incluyedescriptionen todos los endpoints
Filtrado funciona correctamente (15 puntos)
- (3 pts) Filtro por
genre(exacto, case-insensitive) - (3 pts) Filtro por
author(parcial, case-insensitive) - (3 pts) Filtro por
available(booleano) - (3 pts) Filtro por rango de años (
min_year,max_year) - (3 pts) Filtros se combinan:
genre+available+ rango retorna intersección
Búsqueda funciona (10 puntos)
- (4 pts) Busca en
title(case-insensitive) - (3 pts) Busca en
author(case-insensitive) - (3 pts) Se combina con otros filtros
Paginación funciona (10 puntos)
- (3 pts)
skip=0&limit=3retorna los primeros 3 - (3 pts)
skip=3&limit=3retorna los siguientes 3 - (2 pts) El mensaje incluye total filtrado vs mostrados
- (2 pts) Paginar más allá del total retorna lista vacía (no error)
Ordenamiento funciona (10 puntos)
- (3 pts)
sort_by=year&order=ascordena por año ascendente - (3 pts)
sort_by=title&order=descordena por título descendente - (2 pts)
sort_by=authorordena por autor - (2 pts)
sort_byinválido retorna error descriptivo
Response wrappers consistentes (10 puntos)
- (3 pts)
success_responseconstatus,message,dataen todos los endpoints exitosos - (3 pts)
error_responseconstatus,message,data: nullen todos los errores - (2 pts) Error 404 para "not found" (no status 200 con error en body)
- (2 pts) Error 400 para parámetros inválidos
Response headers (5 puntos)
- (2 pts)
X-Total-CountenGET /books - (2 pts)
X-SkipyX-LimitenGET /books - (1 pt)
X-Total-CountenGET /books/stats
Stats endpoint (5 puntos)
- (2 pts) Retorna
total,available,unavailable - (2 pts) Retorna conteo
by_genrecorrecto - (1 pt) Retorna
by_availability
Calidad de código (5 puntos)
- (2 pts) Funciones auxiliares (
find_book,generate_id,success_response,error_response) - (1 pt) Type hints en funciones auxiliares
- (1 pt) Docstrings en endpoints
- (1 pt) Datos de libros completos y realistas
Troubleshooting
Problema 1: GET /books/stats retorna 422 pidiendo book_id
Causa: FastAPI interpreta /books/stats como /books/{book_id} con book_id="stats". Esto pasa cuando GET /books/{book_id} está declarado antes de GET /books/stats.
# ❌ Orden incorrecto — /books/stats nunca se alcanza
@app.get("/books/{book_id}")
def get_book(book_id: int = Path(...)): ...
@app.get("/books/stats")
def book_stats(): ...
# ✅ Orden correcto — rutas fijas antes que rutas con parámetros
@app.get("/books/stats")
def book_stats(): ...
@app.get("/books/{book_id}")
def get_book(book_id: int = Path(...)): ...
FastAPI evalúa las rutas en orden de declaración. Rutas fijas (/stats) siempre antes de rutas con path params (/{book_id}).
Problema 2: El filtro available=true no filtra nada
Causa: Estás comparando un str con un bool. Si declaras available: str = Query(default=None), recibes el string "true" en lugar del booleano True.
# ❌ available llega como string "true", no como bool True
available: str = Query(default=None)
results = [b for b in results if b["available"] == available] # "true" != True
# ✅ Declara el tipo como bool | None
available: bool | None = Query(default=None)
results = [b for b in results if b["available"] == available] # True == True
FastAPI convierte ?available=true al booleano True automáticamente cuando el tipo es bool.
Problema 3: La búsqueda no encuentra libros con acentos
Causa: "Ficción".lower() sigue teniendo el acento. La búsqueda ?search=ficcion (sin acento) no coincide con "Ficción" (con acento).
Solución: En esta versión, la búsqueda es exacta con .lower(). Para búsqueda normalizada (sin acentos) necesitarías unicodedata.normalize(), pero eso está fuera del scope de este módulo. El usuario debe buscar con acentos: ?search=ficción.
Problema 4: sort_by=genre no funciona
Causa: Solo los campos listados en valid_sort_fields son aceptados. El endpoint retorna un error 400 indicando qué campos son válidos.
valid_sort_fields = ["title", "year", "author", "id"]
if sort_by not in valid_sort_fields:
return error_response(f"sort_by debe ser uno de: {', '.join(valid_sort_fields)}")
Si necesitas ordenar por género, agrega "genre" a la lista. La validación explícita evita errores de KeyError si el cliente envía un campo que no existe.
Problema 5: X-Total-Count dice 8 pero solo recibo 3 libros
Causa: Esto es comportamiento correcto. X-Total-Count muestra el total después de filtros pero antes de paginación. Si hay 8 libros que cumplen los filtros y pides limit=3, recibes 3 libros en el body y X-Total-Count: 8 en headers. El frontend usa X-Total-Count para calcular cuántas páginas hay.
Total en la base: 8 libros
Después de filtros: 8 (no filtraste)
Después de paginación: 3 (limit=3)
X-Total-Count: 8 ← para que el frontend calcule: ceil(8/3) = 3 páginas
Body: 3 libros ← la página actual
Reflexión: Lo que controlas ahora vs el Módulo 2
Detente un momento y compara tu API actual con la del Módulo 2:
| Módulo 2 (CRUD básico) | Módulo 3 (este proyecto) |
|---|---|
GET /books → todos los libros | GET /books?genre=Ficción&available=true&sort_by=year |
book_id: int sin validación | book_id: int = Path(ge=1) con constraint |
book: dict = Body(...) sin metadata | Body(example={...}) con ejemplo en /docs |
return books (lista directa) | success_response(data=books, message="...") |
| Sin headers de respuesta | X-Total-Count, X-Skip, X-Limit |
| Sin búsqueda | ?search=borges busca en título y autor |
| Sin paginación | ?skip=0&limit=10 controla resultados |
| Sin ordenamiento | ?sort_by=year&order=desc |
| Sin estadísticas | GET /books/stats con agregaciones |
| Error como dict con status 200 | error_response() con status 404/400 |
Tu API pasó de "funciona" a "funciona como un profesional la diseñaría." Los endpoints son los mismos, pero la calidad de cada uno es radicalmente diferente.
La próxima vez que uses una API pública (GitHub, Spotify, cualquiera), fíjate en sus query parameters. Verás los mismos patrones: ?q=, ?limit=, ?offset=, ?sort=, ?order=. Lo que construiste aquí es el estándar de la industria.
Patrones que aplicaste
1. Filtrado encadenado
Cada filtro reduce la lista progresivamente — son filtros AND. Si el usuario pasa genre + available, obtiene libros que cumplen ambos criterios. Copiar la lista original (books.copy()) evita mutar los datos fuente.
2. Paginación offset-based
results[skip : skip + limit] es la forma más directa de paginar en memoria. Este patrón se traduce directamente a SQL: OFFSET skip LIMIT limit.
3. Ordenamiento dinámico
El campo y dirección vienen como query params. Validar sort_by contra una lista blanca evita KeyError si el cliente envía un campo inexistente.
4. Response wrappers
Toda respuesta sigue la misma estructura: {"status", "message", "data"}. El cliente siempre sabe dónde están los datos sin adivinar.
5. Metadatos en headers
Los datos van en el body, los metadatos sobre la respuesta van en headers. El frontend lee X-Total-Count para calcular páginas sin contaminar el payload.
6. Rutas fijas antes de rutas parametrizadas
/books/stats se declara antes de /books/{book_id}. FastAPI evalúa rutas en orden — si la parametrizada va primero, captura stats como un book_id.
Resumen
En este proyecto integraste todas las herramientas del Módulo 3 en una API cohesiva:
- Query parameters con
Query()para filtrado, búsqueda, paginación y ordenamiento — cada uno condescription,ge,le,min_length,max_lengthsegún corresponde - Path parameters con
Path(ge=1)para validar que los IDs son positivos antes de buscar - Body parameters con
Body(example={...})para documentar el formato esperado del request - Filtrado encadenado que combina género, autor, disponibilidad, búsqueda y rango de años
- Paginación offset-based con
skipylimitque separa total filtrado de resultados mostrados - Ordenamiento dinámico por múltiples campos con dirección configurable
- Response wrappers (
success_response,error_response) que garantizan estructura consistente en toda la API - Response headers (
X-Total-Count,X-Skip,X-Limit) que comunican metadatos de paginación - Stats endpoint con agregaciones por género y disponibilidad
- Orden de rutas correcto:
/books/statsantes de/books/{book_id}
Cada parámetro que declaraste aparece documentado automáticamente en /docs con su tipo, descripción y valor por defecto. Eso es lo que Query(), Path() y Body() te dan: validación y documentación en una sola declaración.
Recursos adicionales
- FastAPI - Query Parameters - Referencia oficial para parámetros de consulta y validación
- FastAPI - Path Parameters and Validations - Validaciones numéricas con Path()
- FastAPI - Body - Multiple Parameters - Combinar path, query y body en un endpoint
- FastAPI - Response directly - Control total con JSONResponse
- API Pagination Best Practices - Patrones de paginación: offset vs cursor
- Stripe API Reference - Ejemplo de API profesional con filtros, paginación y respuestas consistentes
¿Qué sigue?
Tu API funciona y tiene features profesionales. Pero hay un problema evidente: los datos no tienen validación. Nada impide que alguien haga POST /books con {"year": "no sé", "available": "quizás"}. Tu API lo acepta sin quejarse porque estás trabajando con dict sin estructura.
En el Módulo 4 (Pydantic y Validación) reemplazarás los dict con modelos Pydantic tipados:
Módulo 3 (ahora): Módulo 4 (siguiente):
────────────────── ─────────────────────
book: dict = Body(...) → book: BookCreate (Pydantic model)
sin validación de campos → title: str, year: int (validados)
cualquier JSON se acepta → solo campos definidos se aceptan
errores silenciosos → 422 con detalle del error
Pydantic toma las mismas funciones que ya escribiste — create_book, update_book, list_books — y les agrega una capa de validación automática. No reescribes la lógica de filtros, paginación u ordenamiento. Solo cambias dict por un modelo, y todo lo demás se mantiene.
Módulo 3 completado. Has dominado los canales de comunicación entre cliente y servidor. Siguiente parada: Pydantic, donde los datos que recibes se validan antes de que tu código los toque.