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 skip y limit retorna subconjuntos controlados
  • ✅ El ordenamiento por title, year o author funciona 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-Count en endpoints de lista
  • ✅ Un endpoint /books/stats ofrece 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() con description
  • (4 pts) skip tiene ge=0, limit tiene ge=1 y le=100
  • (4 pts) search tiene min_length=1 y max_length=100
  • (4 pts) min_year tiene ge=0, max_year tiene le=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_id usa Path(..., ge=1) en GET por ID
  • (3 pts) book_id usa Path(..., ge=1) en PUT y PATCH
  • (2 pts) book_id usa Path(..., ge=1) en DELETE
  • (2 pts) Path() incluye description en 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=3 retorna los primeros 3
  • (3 pts) skip=3&limit=3 retorna 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=asc ordena por año ascendente
  • (3 pts) sort_by=title&order=desc ordena por título descendente
  • (2 pts) sort_by=author ordena por autor
  • (2 pts) sort_by inválido retorna error descriptivo

Response wrappers consistentes (10 puntos)

  • (3 pts) success_response con status, message, data en todos los endpoints exitosos
  • (3 pts) error_response con status, message, data: null en 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-Count en GET /books
  • (2 pts) X-Skip y X-Limit en GET /books
  • (1 pt) X-Total-Count en GET /books/stats

Stats endpoint (5 puntos)

  • (2 pts) Retorna total, available, unavailable
  • (2 pts) Retorna conteo by_genre correcto
  • (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 librosGET /books?genre=Ficción&available=true&sort_by=year
book_id: int sin validaciónbook_id: int = Path(ge=1) con constraint
book: dict = Body(...) sin metadataBody(example={...}) con ejemplo en /docs
return books (lista directa)success_response(data=books, message="...")
Sin headers de respuestaX-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ísticasGET /books/stats con agregaciones
Error como dict con status 200error_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 con description, ge, le, min_length, max_length segú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 skip y limit que 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/stats antes 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

  1. FastAPI - Query Parameters - Referencia oficial para parámetros de consulta y validación
  2. FastAPI - Path Parameters and Validations - Validaciones numéricas con Path()
  3. FastAPI - Body - Multiple Parameters - Combinar path, query y body en un endpoint
  4. FastAPI - Response directly - Control total con JSONResponse
  5. API Pagination Best Practices - Patrones de paginación: offset vs cursor
  6. 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.