Pydantic Validation

Proyecto: API de Librería con Validación Pydantic

Descripción del proyecto

En este proyecto transformas tu Books API de los Módulos 2 y 3 — que usaba diccionarios sin estructura — en una API con validación completa mediante modelos Pydantic. Cada dict se reemplaza por un modelo tipado. Cada campo tiene restricciones explícitas. Cada operación (crear, actualizar, responder) tiene su propio modelo. Los datos que entran se validan antes de que tu código los toque. Los datos que salen pasan por response_model para filtrar campos internos.

La transformación no es cosmética: cambia cómo tu API se defiende contra datos inválidos. Antes, un POST con título vacío, año futuro o género inventado entraba sin rechistar. Ahora Pydantic retorna 422 con el detalle de cada campo incorrecto. Antes, el PATCH podía sobreescribir campos con null por error. Ahora model_dump(exclude_unset=True) actualiza solo lo que el cliente envió. Antes, el autor era un string plano. Ahora es un modelo anidado AuthorInfo con nombre y nacionalidad.

Integras todo lo aprendido en las cápsulas 01–04: Field() con constraints, @field_validator para capitalizar el título automáticamente, modelos anidados para el autor, modelos separados Create/Update/Patch/Response, response_model en cada endpoint y PATCH con exclude_unset=True. Al terminar tendrás una API donde los modelos Pydantic reemplazan completamente a los diccionarios.


Objetivos del proyecto

Al completar este proyecto:

  • ✅ Defines BookBase, BookCreate, BookUpdate, BookPatch y BookResponse con herencia apropiada
  • ✅ Usas el modelo anidado AuthorInfo (name, nationality) para la información del autor
  • ✅ Aplicas constraints con Field(): título min 1 char, año >= 1000, etc.
  • ✅ Implementas un validador custom que capitaliza el título automáticamente
  • ✅ Usas response_model en todos los endpoints para documentar y filtrar la salida
  • ✅ Manejas PATCH correctamente con model_dump(exclude_unset=True)
  • ✅ Personalizas /docs con tags descriptivos y descripción del API
  • ✅ Los datos inválidos se rechazan con 422 y mensaje detallado por campo
  • ✅ Mantienes los endpoints CRUD y estadísticas que ya tenías en Módulo 3

¿Por qué este proyecto?

En el Módulo 3 tu API tenía filtros, paginación y ordenamiento. Pero los datos seguían siendo dict sin validación. Observa qué pasaba con un body malformado:

curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "", "author": 12345, "year": "no sé", "genre": true}'

El Módulo 3 lo aceptaba. Ese libro corrupto contaminaba la colección. Con Pydantic, ese mismo request retorna 422 con el detalle de cada campo inválido:

Módulo 3 (dicts):                      Módulo 4 (este proyecto):
──────────────────                      ─────────────────────────
book: dict = Body(...)              →   book: BookCreate (modelo validado)
sin validación de tipos             →   title: str, year: int (tipos garantizados)
título vacío se acepta              →   Field(min_length=1) lo rechaza
autor plano (string)                →   AuthorInfo anidado (name, nationality)
return dict suelto                  →   response_model=BookResponse
PATCH puede sobrescribir con None   →   exclude_unset=True solo aplica lo enviado

Especificaciones técnicas

Stack

  • Framework: FastAPI
  • Validación: Pydantic v2
  • Servidor: uvicorn con hot reload
  • Almacenamiento: Lista en memoria (estructura actualizada para modelos)

Modelo de datos con AuthorInfo

CampoTipoDescripción
idintIdentificador único (solo en Response)
titlestrTítulo (min 1 char, capitalizado por validator)
authorAuthorInfoObjeto anidado con name y nationality
yearintAño de publicación (ge=1000, le=2030)
genrestrGénero literario
availableboolDisponible para préstamo (default True)

AuthorInfo:

CampoTipoDescripción
namestrNombre del autor (min 1 char)
nationalitystrPaís/nacionalidad del autor

Endpoints

  • GET / — Info del servicio
  • GET /books — Lista con filtros (genre, author, available, search, paginación)
  • GET /books/stats — Estadísticas agregadas
  • GET /books/{book_id} — Obtener por ID
  • POST /books — Crear (BookCreate)
  • PUT /books/{book_id} — Actualizar completo (BookUpdate)
  • PATCH /books/{book_id} — Actualizar parcial (BookPatch)
  • DELETE /books/{book_id} — Eliminar

Guía paso a paso

Paso 1: Definir el modelo anidado AuthorInfo

Crea primero el modelo interno. Debe existir antes de usarlo en BookBase:

from pydantic import BaseModel, Field

class AuthorInfo(BaseModel):
    """Información del autor — modelo anidado."""
    name: str = Field(min_length=1, max_length=100, description="Nombre del autor")
    nationality: str = Field(min_length=1, max_length=50, description="Nacionalidad")

Paso 2: Definir BookBase con Field() y validators

class BookBase(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    author: AuthorInfo
    year: int = Field(ge=1000, le=2030)
    genre: str = Field(min_length=1)
    available: bool = Field(default=True)

    @field_validator("title", mode="before")
    @classmethod
    def capitalize_title(cls, v: str) -> str:
        s = v.strip()
        if not s:
            raise ValueError("El título no puede estar vacío")
        return s.title()  # Capitaliza cada palabra

Paso 3: Crear modelos separados por operación

  • BookCreate(BookBase) — para POST
  • BookUpdate(BookBase) — para PUT (todos los campos)
  • BookPatch — todos los campos opcionales, incluyendo author: AuthorInfo | None
  • BookResponse(BookBase) — agrega id: int, para respuestas

Paso 4: Actualizar los datos en memoria

Convierte los libros existentes para usar author como objeto:

{
    "id": 1,
    "title": "Cien Años de Soledad",
    "author": {"name": "Gabriel García Márquez", "nationality": "Colombia"},
    "year": 1967,
    "genre": "Realismo mágico",
    "available": True
}

Paso 5: Usar response_model en cada endpoint

@app.get("/books", response_model=BookListResponse)
def list_books(...): ...

@app.post("/books", response_model=SuccessResponse, status_code=201)
def create_book(book: BookCreate): ...

Paso 6: Implementar PATCH con exclude_unset=True

update_data = book.model_dump(exclude_unset=True)
existing.update(update_data)

Paso 7: Personalizar FastAPI con tags y descripción

app = FastAPI(
    title="Books API",
    description="API de librería con validación Pydantic. Módulo 4 — FastAPI Fundamentals.",
    version="4.0.0",
)

Usa tags=["Books"], tags=["General"] y summary en cada endpoint.


El proyecto completo

Estructura

fastapi-fundamentals/
├── app/
│   ├── __init__.py
│   └── main.py       ← todo el código
├── requirements.txt
└── .gitignore

app/main.py

"""
Books API con validación Pydantic.
Módulo 4 — Proyecto: API de Librería con Validación.
"""

from fastapi import FastAPI, Query, Path
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator


# --- Modelos Pydantic ---


class AuthorInfo(BaseModel):
    """Modelo anidado para información del autor."""
    name: str = Field(
        min_length=1,
        max_length=100,
        description="Nombre completo del autor",
    )
    nationality: str = Field(
        min_length=1,
        max_length=50,
        description="Nacionalidad o país del autor",
    )


class BookBase(BaseModel):
    """Campos compartidos entre modelos de libro."""
    title: str = Field(
        min_length=1,
        max_length=200,
        description="Título del libro",
    )
    author: AuthorInfo = Field(description="Información del autor")
    year: int = Field(
        ge=1000,
        le=2030,
        description="Año de publicación",
    )
    genre: str = Field(
        min_length=1,
        max_length=50,
        description="Género literario",
    )
    available: bool = Field(
        default=True,
        description="Disponible para préstamo",
    )

    @field_validator("title", mode="before")
    @classmethod
    def capitalize_title(cls, v: str) -> str:
        """Capitaliza el título automáticamente (ej: 'rayuela' -> 'Rayuela')."""
        s = v.strip()
        if not s:
            raise ValueError("El título no puede estar vacío o solo espacios")
        return s.title()


class BookCreate(BookBase):
    """Lo que el cliente envía para crear un libro."""
    pass


class BookUpdate(BookBase):
    """Actualización completa (PUT) — todos los campos requeridos."""
    pass


class BookPatch(BaseModel):
    """Actualización parcial (PATCH) — todos los campos opcionales."""
    title: str | None = Field(default=None, min_length=1, max_length=200)
    author: AuthorInfo | None = None
    year: int | None = Field(default=None, ge=1000, le=2030)
    genre: str | None = Field(default=None, min_length=1, max_length=50)
    available: bool | None = None


class BookResponse(BookBase):
    """Lo que la API retorna — incluye id generado por el servidor."""
    id: int


class BookListResponse(BaseModel):
    """Respuesta para listas de libros con metadata."""
    status: str = "success"
    count: int
    message: str
    data: list[BookResponse]


class SuccessResponse(BaseModel):
    """Respuesta exitosa con datos opcionales."""
    status: str = "success"
    message: str
    data: BookResponse | dict | None = None


# --- Configuración de la app ---


app = FastAPI(
    title="Books API",
    description=(
        "API de gestión de libros con validación Pydantic completa. "
        "Incluye modelos anidados (AuthorInfo), validators custom, "
        "modelos separados por operación y response_model. Módulo 4 — FastAPI Fundamentals."
    ),
    version="4.0.0",
)


# --- Datos en memoria (estructura con author como objeto) ---


books: list[dict] = [
    {
        "id": 1,
        "title": "Cien Años de Soledad",
        "author": {"name": "Gabriel García Márquez", "nationality": "Colombia"},
        "year": 1967,
        "genre": "Realismo mágico",
        "available": True,
    },
    {
        "id": 2,
        "title": "Don Quijote de la Mancha",
        "author": {"name": "Miguel de Cervantes", "nationality": "España"},
        "year": 1605,
        "genre": "Novela",
        "available": True,
    },
    {
        "id": 3,
        "title": "El Aleph",
        "author": {"name": "Jorge Luis Borges", "nationality": "Argentina"},
        "year": 1949,
        "genre": "Ficción",
        "available": False,
    },
    {
        "id": 4,
        "title": "Rayuela",
        "author": {"name": "Julio Cortázar", "nationality": "Argentina"},
        "year": 1963,
        "genre": "Ficción",
        "available": True,
    },
    {
        "id": 5,
        "title": "Pedro Páramo",
        "author": {"name": "Juan Rulfo", "nationality": "México"},
        "year": 1955,
        "genre": "Realismo mágico",
        "available": False,
    },
    {
        "id": 6,
        "title": "La Casa de los Espíritus",
        "author": {"name": "Isabel Allende", "nationality": "Chile"},
        "year": 1982,
        "genre": "Realismo mágico",
        "available": True,
    },
    {
        "id": 7,
        "title": "Ficciones",
        "author": {"name": "Jorge Luis Borges", "nationality": "Argentina"},
        "year": 1944,
        "genre": "Ficción",
        "available": True,
    },
    {
        "id": 8,
        "title": "Conversación en La Catedral",
        "author": {"name": "Mario Vargas Llosa", "nationality": "Perú"},
        "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((b for b in books if b["id"] == book_id), None)


def generate_id() -> int:
    """Genera el siguiente ID disponible."""
    if not books:
        return 1
    return max(b["id"] for b in books) + 1


def error_json(message: str, status_code: int = 404) -> JSONResponse:
    """Retorna respuesta de error con status HTTP correcto."""
    return JSONResponse(
        status_code=status_code,
        content={"status": "error", "message": message, "data": None},
    )


# --- Endpoint raíz ---


@app.get("/", tags=["General"], summary="Información del servicio")
def root():
    """Información del API y enlaces a recursos."""
    return {
        "service": "Books API",
        "version": "4.0.0",
        "description": "API con validación Pydantic (modelos, validators, nested models)",
        "endpoints": {
            "books": "/books",
            "stats": "/books/stats",
            "docs": "/docs",
        },
    }


# --- Estadísticas (declarar ANTES de /books/{book_id}) ---


@app.get(
    "/books/stats",
    tags=["Books"],
    summary="Estadísticas de la colección",
)
def book_stats():
    """Estadísticas agregadas: total, disponibles, por género."""
    total = len(books)
    available = sum(1 for b in books if b["available"])
    genre_counts: dict[str, int] = {}
    for b in books:
        g = b["genre"]
        genre_counts[g] = genre_counts.get(g, 0) + 1

    return {
        "status": "success",
        "message": "Book statistics",
        "data": {
            "total": total,
            "available": available,
            "unavailable": total - available,
            "by_genre": genre_counts,
        },
    }


# --- Endpoints de lectura ---


@app.get(
    "/books",
    response_model=BookListResponse,
    tags=["Books"],
    summary="Listar libros con filtros",
)
def list_books(
    genre: str | None = Query(
        default=None,
        description="Filtrar por género exacto",
    ),
    author: str | None = Query(
        default=None,
        description="Filtrar por nombre de autor (parcial, case-insensitive)",
    ),
    available: bool | None = Query(
        default=None,
        description="Filtrar por disponibilidad",
    ),
    search: str | None = Query(
        default=None,
        min_length=1,
        max_length=100,
        description="Buscar en título y nombre de autor",
    ),
    skip: int = Query(default=0, ge=0, description="Offset para paginación"),
    limit: int = Query(default=10, ge=1, le=100, description="Máximo de resultados"),
):
    """
    Lista libros con filtros opcionales y paginación.
    El filtro author busca en author.name (modelo anidado).
    """
    results = books.copy()

    if genre is not None:
        results = [b for b in results if b["genre"].lower() == genre.lower()]

    if author is not None:
        results = [
            b for b in results
            if author.lower() in b["author"]["name"].lower()
        ]

    if available is not None:
        results = [b for b in results if b["available"] == available]

    if search is not None:
        q = search.lower()
        results = [
            b for b in results
            if q in b["title"].lower() or q in b["author"]["name"].lower()
        ]

    total = len(results)
    paginated = results[skip : skip + limit]

    return BookListResponse(
        count=total,
        message=f"{total} books found, showing {len(paginated)}",
        data=[BookResponse.model_validate(b) for b in paginated],
    )


@app.get(
    "/books/{book_id}",
    response_model=BookResponse,
    tags=["Books"],
    summary="Obtener libro por ID",
)
def get_book(
    book_id: int = Path(..., ge=1, description="ID del libro"),
):
    """Retorna un libro por su ID. 404 si no existe."""
    book = find_book(book_id)
    if book is None:
        return error_json(f"Book with id {book_id} not found", 404)
    return BookResponse.model_validate(book)


# --- Endpoint de creación ---


@app.post(
    "/books",
    response_model=SuccessResponse,
    status_code=201,
    tags=["Books"],
    summary="Crear libro nuevo",
)
def create_book(book: BookCreate):
    """
    Crea un libro con ID auto-generado.
    El título se capitaliza automáticamente.
    """
    new_book = {"id": generate_id(), **book.model_dump()}
    books.append(new_book)
    return SuccessResponse(
        message="Book created",
        data=BookResponse.model_validate(new_book),
    )


# --- Endpoints de actualización ---


@app.put(
    "/books/{book_id}",
    response_model=SuccessResponse,
    tags=["Books"],
    summary="Actualización completa (PUT)",
)
def update_book(
    book_id: int = Path(..., ge=1, description="ID del libro"),
    book: BookUpdate = ...,
):
    """Reemplaza todos los campos del libro. Requiere todos los campos."""
    existing = find_book(book_id)
    if existing is None:
        return error_json(f"Book with id {book_id} not found", 404)

    index = books.index(existing)
    books[index] = {"id": book_id, **book.model_dump()}
    return SuccessResponse(
        message="Book updated",
        data=BookResponse.model_validate(books[index]),
    )


@app.patch(
    "/books/{book_id}",
    response_model=SuccessResponse,
    tags=["Books"],
    summary="Actualización parcial (PATCH)",
)
def patch_book(
    book_id: int = Path(..., ge=1, description="ID del libro"),
    book: BookPatch = ...,
):
    """
    Actualiza solo los campos enviados.
    Usa exclude_unset=True para ignorar campos no enviados.
    """
    existing = find_book(book_id)
    if existing is None:
        return error_json(f"Book with id {book_id} not found", 404)

    update_data = book.model_dump(exclude_unset=True)
    existing.update(update_data)

    return SuccessResponse(
        message="Book partially updated",
        data=BookResponse.model_validate(existing),
    )


# --- Endpoint de eliminación ---


@app.delete(
    "/books/{book_id}",
    response_model=SuccessResponse,
    tags=["Books"],
    summary="Eliminar libro",
)
def delete_book(
    book_id: int = Path(..., ge=1, description="ID del libro"),
):
    """Elimina un libro por su ID."""
    book = find_book(book_id)
    if book is None:
        return error_json(f"Book with id {book_id} not found", 404)

    books.remove(book)
    return SuccessResponse(
        message=f"Book {book_id} deleted",
        data=None,
    )

Ejecutar

uvicorn app.main:app --reload

Abre http://127.0.0.1:8000/docs. Verás los schemas de AuthorInfo, BookCreate, BookPatch, BookResponse, etc., generados automáticamente.


Verificación paso a paso

Paso 1: Listar libros

curl -s http://127.0.0.1:8000/books | python -m json.tool

Debe retornar "count": 8 y "data" con los libros. Cada libro tiene author como objeto con name y nationality.

Paso 2: Crear libro con título en minúsculas

curl -s -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{
    "title": "el túnel",
    "author": {"name": "Ernesto Sabato", "nationality": "Argentina"},
    "year": 1948,
    "genre": "Ficción"
  }' | python -m json.tool

Verifica que el título en la respuesta es "El Túnel" — el validator lo capitalizó automáticamente.

Paso 3: Crear con título vacío (esperado: 422)

curl -s -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "", "author": {"name": "X", "nationality": "Y"}, "year": 2020, "genre": "Ficción"}' \
  | python -m json.tool

Esperado: status 422, detalle indicando que el título falló la validación.

Paso 4: Crear con año inválido (esperado: 422)

curl -s -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Libro", "author": {"name": "X", "nationality": "Y"}, "year": 999, "genre": "Ficción"}' \
  | python -m json.tool

Esperado: 422 por year fuera del rango (ge=1000).

Paso 5: PATCH solo available

curl -s -X PATCH http://127.0.0.1:8000/books/3 \
  -H "Content-Type: application/json" \
  -d '{"available": true}' | python -m json.tool

Solo available debe cambiar. El resto de campos se mantienen gracias a exclude_unset=True.

Paso 6: PATCH actualizando autor anidado

curl -s -X PATCH http://127.0.0.1:8000/books/3 \
  -H "Content-Type: application/json" \
  -d '{"author": {"name": "Jorge Luis Borges", "nationality": "Argentina"}}' \
  | python -m json.tool

El objeto author completo se actualiza.

Paso 7: Filtrar por autor

curl -s "http://127.0.0.1:8000/books?author=Borges" | python -m json.tool

Retorna los libros donde author.name contiene "Borges".

Paso 8: Libro no encontrado (404)

curl -s -w "\n%{http_code}" http://127.0.0.1:8000/books/999

El status debe ser 404 y el body un JSON con "status": "error".

Paso 9: Verificar /docs

En http://127.0.0.1:8000/docs:

  • POST /books muestra schema BookCreate con author como objeto anidado
  • PATCH muestra BookPatch con todos los campos opcionales
  • Schemas incluyen AuthorInfo, BookResponse, BookListResponse
  • Tags "General" y "Books" organizan los endpoints
  • Descripciones en cada parámetro y campo

Checklist de completitud

Modelos Pydantic:
- [ ] AuthorInfo con name y nationality (modelo anidado)
- [ ] BookBase con title, author, year, genre, available
- [ ] Field() con min_length en title (min 1 char)
- [ ] Field() con ge=1000, le=2030 en year
- [ ] @field_validator para capitalizar título
- [ ] BookCreate hereda de BookBase
- [ ] BookUpdate hereda de BookBase
- [ ] BookPatch con todos los campos opcionales
- [ ] BookResponse hereda de BookBase + id
- [ ] BookListResponse y SuccessResponse para wrappers

Endpoints:
- [ ] GET /books con response_model=BookListResponse
- [ ] GET /books/{id} con response_model=BookResponse
- [ ] GET /books/stats antes de GET /books/{id}
- [ ] POST /books con BookCreate, status 201
- [ ] PUT /books/{id} con BookUpdate
- [ ] PATCH /books/{id} con BookPatch y exclude_unset=True
- [ ] DELETE /books/{id} con SuccessResponse
- [ ] response_model en todos los endpoints de éxito
- [ ] Errores retornan 404 con JSONResponse

Funcionalidad:
- [ ] El título se capitaliza al crear/actualizar
- [ ] Filtros por genre, author (en author.name) y available
- [ ] Búsqueda en título y author.name
- [ ] Paginación skip/limit
- [ ] PATCH solo actualiza campos enviados
- [ ] Datos con author como objeto anidado

Documentación:
- [ ] Tags en cada endpoint
- [ ] summary y description en app y endpoints
- [ ] Schemas visibles en /docs

Troubleshooting

Problema 1: PATCH sobreescribe campos con None

Causa: Usar model_dump() sin exclude_unset=True.

# ❌ Incluye todos los campos con None
update_data = book.model_dump()
# {'title': None, 'author': None, 'year': None, 'genre': None, 'available': None}
existing.update(update_data)  # Sobreescribe todo con None

# ✅ Solo incluye lo que el cliente envió
update_data = book.model_dump(exclude_unset=True)
existing.update(update_data)

Problema 2: El validator de capitalizar no se ejecuta

Causa: Falta @classmethod o mode="before" si necesitas transformar antes de otras validaciones.

# ❌ Sin @classmethod — Pydantic v2 lo ignora
@field_validator("title")
def capitalize_title(cls, v: str) -> str:
    ...

# ✅ Forma correcta
@field_validator("title", mode="before")
@classmethod
def capitalize_title(cls, v: str) -> str:
    s = v.strip()
    if not s:
        raise ValueError("El título no puede estar vacío")
    return s.title()

Problema 3: GET /books/stats retorna 422 pidiendo book_id

Causa: La ruta /books/{book_id} está declarada antes que /books/stats. FastAPI interpreta "stats" como book_id.

# ❌ Orden incorrecto
@app.get("/books/{book_id}")
def get_book(...): ...

@app.get("/books/stats")
def book_stats(): ...

# ✅ Rutas fijas primero
@app.get("/books/stats")
def book_stats(): ...

@app.get("/books/{book_id}")
def get_book(...): ...

Problema 4: Error al filtrar por autor

Causa: Con author como objeto anidado, debes acceder a author["name"] o author.name, no a author directamente.

# ❌ author es un dict con "name"
results = [b for b in results if author in b["author"]]

# ✅ Buscar en el nombre
results = [
    b for b in results
    if author.lower() in b["author"]["name"].lower()
]

Problema 5: "BookResponse is not JSON serializable"

Causa: Retornar una instancia Pydantic dentro de un dict sin pasar por el wrapper correcto.

# ❌ Mezcla dict con instancia
return {"status": "success", "data": BookResponse(**book)}

# ✅ Usar el wrapper Pydantic
return SuccessResponse(
    message="Book created",
    data=BookResponse.model_validate(new_book),
)

Problema 6: 422 al crear libro — "author" esperado como objeto

Causa: El cliente envía "author": "Gabriel García Márquez" (string) en lugar de "author": {"name": "...", "nationality": "..."}.

// ❌ Formato incorrecto (Módulo 3)
{"title": "Rayuela", "author": "Julio Cortázar", "year": 1963, "genre": "Ficción"}

// ✅ Formato correcto (Módulo 4 con AuthorInfo)
{"title": "Rayuela", "author": {"name": "Julio Cortázar", "nationality": "Argentina"}, "year": 1963, "genre": "Ficción"}

Problema 7: PATCH con author anidado no actualiza

Causa: model_dump(exclude_unset=True) con modelos anidados genera sub-dicts. Asegúrate de que existing.update() reciba el dict correcto. Si update_data es {"author": {"name": "X", "nationality": "Y"}}, existing.update(update_data) reemplaza la clave "author" por completo, lo cual es correcto.

Verificación: Si envías {"author": {"name": "Nuevo", "nationality": "Chile"}}, el libro debe tener el autor actualizado. Si no ocurre, revisa que estés usando existing.update(update_data) y no reemplazando solo campos de primer nivel.

Problema 8: Validación de autor anidado falla con mensaje poco claro

Causa: Cuando author es inválido (ej. falta nationality), Pydantic reporta el error en ["body", "author", "nationality"]. Revisa el array detail de la respuesta 422 para localizar el campo exacto.


Conexión con el Módulo 5 (Error Handling)

Hasta ahora usas return error_json(...) para devolver 404. Funciona, pero el Módulo 5 te enseñará el patrón idiomático de FastAPI:

# Módulo 4 (actual)
if book is None:
    return error_json(f"Book with id {book_id} not found", 404)

# Módulo 5 (siguiente)
if book is None:
    raise HTTPException(status_code=404, detail=f"Book with id {book_id} not found")

HTTPException permite que FastAPI genere respuestas de error consistentes, integre con middlewares y documente los códigos de error en /docs. Los custom exception handlers te permitirán formatear todas las respuestas de error de forma uniforme.


Conexión con el Módulo 6 (Proyecto final)

Este proyecto es la base del CRUD completo que integrarás en el Módulo 6. Allí combinarás:

  • Módulo 4 (este): Modelos Pydantic, validación, AuthorInfo, response_model
  • Módulo 5: HTTPException, CORS, manejo de errores
  • Módulo 6: Proyecto To-Do List API o similar que integre todo

Los modelos que defines aquí (BookCreate, BookPatch, etc.) se reutilizarán. La estructura con AuthorInfo anidado es el patrón para dominios más complejos (usuario con dirección, pedido con ítems).


Recursos adicionales

  1. Pydantic v2 - Models — Modelos, herencia y configuración
  2. Pydantic v2 - Field — Field() con constraints y metadata
  3. Pydantic v2 - Validators — @field_validator y @model_validator
  4. FastAPI - Response Model — response_model, filtrado y documentación
  5. FastAPI - Extra Models — Modelos separados para request/response
  6. Pydantic - Nested Models — Modelos anidados y validación
  7. FastAPI - OpenAPI — Cómo se generan los schemas en /docs

¿Qué sigue?

Tu API valida datos de entrada, usa modelos anidados, capitaliza títulos y maneja PATCH correctamente. Pero el manejo de errores sigue siendo manual con error_json(). En el Módulo 5 (Error Handling y CORS) aprenderás:

Módulo 4 (ahora):                      Módulo 5 (siguiente):
──────────────────                      ─────────────────────
return error_json(..., 404)          →  raise HTTPException(404, detail=...)
Sin CORS                              →  CORSMiddleware para frontend
Errores manuales                      →  Custom exception handlers

Además, configurarás CORS para que un frontend en otro dominio pueda consumir tu API. Eso cierra la capa de robustez que toda API profesional necesita.

Módulo 4 completado. Has migrado tu Books API de diccionarios a modelos Pydantic con validación automática, modelos anidados y separación Create/Update/Patch/Response. Tus datos están estructurados y protegidos. Siguiente parada: error handling profesional.