Pydantic Validation

Proyecto: API de contactos con validación completa

Descripción del proyecto

Integras todo lo aprendido en el Módulo 4: una API de contactos con modelos Pydantic, validaciones con Field, Address anidado, y modelos separados para crear (ContactCreate) y responder (ContactResponse). Cada endpoint valida automáticamente y documenta su contrato en /docs.

En este proyecto la validación se convierte en protagonista: ya no aceptas dict y rezas por que lleguen datos correctos. Con Pydantic defines el contrato exacto de cada request y response, y FastAPI valida antes de ejecutar tu código. Si alguien envía un email sin @, un nombre vacío o un zip_code con letras, obtiene un 422 con un mensaje claro — sin una sola línea de validación manual.

Al terminar tendrás una API lista para probar en /docs con validación robusta. La separación entre ContactCreate (lo que envía el cliente), ContactUpdate (para PATCH con campos opcionales) y ContactResponse (lo que devuelves, con id y created_at) es el patrón estándar en APIs profesionales.


Antes de empezar

Asegúrate de haber completado las cápsulas 02 a 05 del Módulo 4. Necesitas dominar:

  • Cápsula 02 — BaseModel: Definir clases con type hints, validación automática, model_dump() para serializar
  • Cápsula 03 — Field constraints: min_length, max_length, pattern, valores por defecto
  • Cápsula 04 — Nested models: Modelos anidados como Address dentro de Contact
  • Cápsula 05 — Request vs Response: Diferentes modelos para crear (sin id) y responder (con id, created_at)

Tu API de productos del Módulo 3 (con dict = Body(...)) es la base. Aquí reemplazarás esos dict por modelos Pydantic. Si puedes arrancar el proyecto del M3 con uvicorn app.main:app --reload y hacer POST a /products, tienes todo lo necesario.


Estructura del proyecto

fastapi-fundamentals/
├── venv/
├── app/
│   ├── __init__.py
│   └── main.py          ← Todo el código
├── requirements.txt     ← fastapi, uvicorn[standard]
└── .gitignore

Ejecuta: uvicorn app.main:app --reload


Modelo de datos detallado

Contact — Entidad principal

CampoTipoConstraints¿En Create?¿En Response?
idintauto-generadoNo
namestrmin_length=1, max_length=100
emailstrmin_length=5, max_length=100, debe contener @
phonestrdefault="", max_length=20
addressAddress | Nonemodelo anidado opcional
notesstr | Nonemax_length=500, opcional
created_atdatetimesolo en Response (opcional)NoSí si lo implementas

ContactCreate — Lo que envía el cliente en POST

Modelo sin id. Todos los campos para crear un contacto nuevo. El validador email_must_contain_at rechaza emails sin @ y normaliza a minúsculas.

ContactUpdate — Lo que envía el cliente en PATCH

Todos los campos son Optional. Solo se envían los que quieren cambiar. Usa model_dump(exclude_unset=True) para no sobrescribir con None campos no enviados.

ContactResponse — Lo que devuelve la API

Incluye id (generado por el servidor). No incluye campos sensibles. Usa response_model=ContactResponse en los endpoints para documentar y filtrar la salida.

Address — Modelo anidado

CampoTipoConstraints
streetstrmin_length=1, max_length=200
citystrmin_length=1, max_length=100
zip_codestrpattern ^\d{5}$ (5 dígitos)

Si el cliente envía "address": null, no hay problema. Si envía "address": {}, Pydantic falla porque faltan street, city, zip_code.


Endpoints requeridos

MétodoRutaDescripciónStatus
GET/Info del servicio200
GET/contactsLista todos los contactos200
GET/contacts/{id}Obtiene contacto por ID200
POST/contactsCrea contacto nuevo201
PUT/contacts/{id}Actualiza contacto completo200
PATCH/contacts/{id}Actualiza campos específicos200
DELETE/contacts/{id}Elimina contacto200

Guía paso a paso

Paso 1: Define los modelos Pydantic con Field constraints

Crea los modelos Address, ContactCreate, ContactUpdate y ContactResponse. Usa Field() para constraints y el validador @field_validator para el email.

class Address(BaseModel):
    street: str = Field(min_length=1, max_length=200)
    city: str = Field(min_length=1, max_length=100)
    zip_code: str = Field(pattern=r"^\d{5}$")

class ContactCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: str = Field(min_length=5, max_length=100)
    phone: str = Field(default="", max_length=20)
    address: Optional[Address] = None
    notes: Optional[str] = Field(default=None, max_length=500)

    @field_validator("email")
    @classmethod
    def email_must_contain_at(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("El email debe contener @")
        return v.lower()

Explicación: min_length=1 impide cadenas vacías. pattern=r"^\d{5}$" exige exactamente 5 dígitos en zip_code. El validador se ejecuta tras la validación básica de tipos.

Verifica: En /docs, los esquemas de ContactCreate y Address deberían aparecer con las descripciones. No hace falta llamar al endpoint aún.


Paso 2: Crea el data store y las funciones auxiliares

Inicializa la lista de contactos y las funciones next_id() y find_contact().

contacts_db: list[dict] = [
    {"id": 1, "name": "Ana García", "email": "ana@example.com", "phone": "612345678",
     "address": {"street": "Calle Mayor 1", "city": "Madrid", "zip_code": "28001"}, "notes": "Contacto principal"},
    {"id": 2, "name": "Carlos Ruiz", "email": "carlos@example.com", "phone": "", "address": None, "notes": None},
]

def next_id() -> int:
    return max((c["id"] for c in contacts_db), default=0) + 1

def find_contact(contact_id: int) -> Optional[dict]:
    return next((c for c in contacts_db if c["id"] == contact_id), None)

Explicación: Guardamos dicts para compatibilidad con model_dump(). find_contact reutiliza la misma lógica en GET, PUT, PATCH y DELETE.

Verifica: Arranca la app y GET / debe devolver total_contacts: 2.


Paso 3: Implementa los endpoints CRUD usando los modelos

Crea GET, POST, PUT, PATCH y DELETE. Usa ContactCreate para POST y PUT, ContactUpdate para PATCH, y response_model=ContactResponse en los GET y POST.

@app.post("/contacts", status_code=201, response_model=ContactResponse)
def create_contact(contact: ContactCreate):
    data = contact.model_dump()
    data["id"] = next_id()
    contacts_db.append(data)
    return data

@app.patch("/contacts/{contact_id}", response_model=ContactResponse)
def partial_update_contact(contact_id: int, updates: ContactUpdate):
    existing = find_contact(contact_id)
    if existing is None:
        raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found")
    update_data = updates.model_dump(exclude_unset=True)  # clave para PATCH
    existing.update(update_data)
    return existing

Explicación: exclude_unset=True evita sobrescribir con None los campos que el cliente no envió. Sin ello, un PATCH con {"phone": "666"} borraría address y notes.

Verifica: POST con body válido crea el contacto 3. PATCH con {"phone": "666777888"} en el contacto 2 solo actualiza el teléfono.


Paso 4: Prueba escenarios de validación inválida

Prueba en /docs estos cuerpos inválidos y confirma que retornan 422:

  • Email sin @: {"name": "Test", "email": "invalid"}
  • Nombre vacío: {"name": "", "email": "a@b.com"}
  • Address con zip_code inválido: {"name": "Test", "email": "a@b.com", "address": {"street": "Calle 1", "city": "Madrid", "zip_code": "28A01"}}

Explicación: FastAPI no ejecuta tu endpoint cuando Pydantic falla. El 422 incluye el detalle del error por campo en el body de la respuesta.

Verifica: Cada intento debe devolver 422 con un JSON que indique el campo y el motivo (ej. "El email debe contener @").


Paso 5: Valida el flujo completo en /docs

Ejecuta en orden: GET /contacts → POST con dato válido → GET /contacts/3 → PUT actualizando → PATCH con un campo → DELETE → GET /contacts/3 (404).

Verifica: El flujo completo debe funcionar sin errores. La documentación en /docs debe mostrar los esquemas de request y response correctos.


Validación en acción — 5 casos de prueba con 422

Prueba estos cuerpos en POST /contacts y verifica que todos retornan 422 Unprocessable Entity:

#Body enviadoMotivo del 422
1{"name": "Test", "email": "sinArroba"}El email debe contener @
2{"name": "", "email": "a@b.com"}String should have at least 1 character (name)
3{"name": "Test", "email": "a@b.com", "address": {"street": "Calle 1", "city": "Madrid", "zip_code": "123"}}zip_code debe tener exactamente 5 dígitos
4{"name": "Test", "email": "ab"}Email demasiado corto (min_length=5) y sin @
5{"name": "X" * 101, "email": "a@b.com"}name excede max_length=100

En cada caso, el body de la respuesta 422 contiene un array detail con el campo afectado y el mensaje. Usa /docs → POST /contacts → "Try it out" y pega cada JSON para comprobar el comportamiento.


Código completo

app/main.py

from typing import Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, field_validator


# --- Modelos ---

class Address(BaseModel):
    street: str = Field(min_length=1, max_length=200, description="Calle y número")
    city: str = Field(min_length=1, max_length=100, description="Ciudad")
    zip_code: str = Field(pattern=r"^\d{5}$", description="Código postal 5 dígitos")


class ContactCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100, description="Nombre del contacto")
    email: str = Field(min_length=5, max_length=100, description="Email")
    phone: str = Field(default="", max_length=20, description="Teléfono")
    address: Optional[Address] = None
    notes: Optional[str] = Field(default=None, max_length=500)

    @field_validator("email")
    @classmethod
    def email_must_contain_at(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("El email debe contener @")
        return v.lower()


class ContactUpdate(BaseModel):
    name: Optional[str] = Field(default=None, min_length=1, max_length=100)
    email: Optional[str] = Field(default=None, min_length=5, max_length=100)
    phone: Optional[str] = Field(default=None, max_length=20)
    address: Optional[Address] = None
    notes: Optional[str] = Field(default=None, max_length=500)

    @field_validator("email")
    @classmethod
    def email_must_contain_at(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and "@" not in v:
            raise ValueError("El email debe contener @")
        return v.lower() if v is not None else v


class ContactResponse(BaseModel):
    id: int
    name: str
    email: str
    phone: str
    address: Optional[Address] = None
    notes: Optional[str] = None


# --- App ---

app = FastAPI(
    title="Contacts API",
    description="API CRUD de contactos con validación Pydantic. Módulo 4 — FastAPI Fundamentals.",
    version="1.0.0",
)

contacts_db: list[dict] = [
    {
        "id": 1,
        "name": "Ana García",
        "email": "ana@example.com",
        "phone": "612345678",
        "address": {"street": "Calle Mayor 1", "city": "Madrid", "zip_code": "28001"},
        "notes": "Contacto principal",
    },
    {
        "id": 2,
        "name": "Carlos Ruiz",
        "email": "carlos@example.com",
        "phone": "",
        "address": None,
        "notes": None,
    },
]


def next_id() -> int:
    return max((c["id"] for c in contacts_db), default=0) + 1


def find_contact(contact_id: int) -> Optional[dict]:
    return next((c for c in contacts_db if c["id"] == contact_id), None)


@app.get("/")
def root():
    return {"service": "Contacts API", "version": "1.0.0", "total_contacts": len(contacts_db)}


@app.get("/contacts", response_model=list[ContactResponse])
def list_contacts():
    return contacts_db


@app.get("/contacts/{contact_id}", response_model=ContactResponse)
def get_contact(contact_id: int):
    contact = find_contact(contact_id)
    if contact is None:
        raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found")
    return contact


@app.post("/contacts", status_code=201, response_model=ContactResponse)
def create_contact(contact: ContactCreate):
    data = contact.model_dump()
    data["id"] = next_id()
    contacts_db.append(data)
    return data


@app.put("/contacts/{contact_id}", response_model=ContactResponse)
def update_contact(contact_id: int, contact: ContactCreate):
    existing = find_contact(contact_id)
    if existing is None:
        raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found")
    updated = contact.model_dump()
    updated["id"] = contact_id
    idx = contacts_db.index(existing)
    contacts_db[idx] = updated
    return updated


@app.patch("/contacts/{contact_id}", response_model=ContactResponse)
def partial_update_contact(contact_id: int, updates: ContactUpdate):
    existing = find_contact(contact_id)
    if existing is None:
        raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found")
    update_data = updates.model_dump(exclude_unset=True)
    existing.update(update_data)
    return existing


@app.delete("/contacts/{contact_id}")
def delete_contact(contact_id: int):
    contact = find_contact(contact_id)
    if contact is None:
        raise HTTPException(status_code=404, detail=f"Contact {contact_id} not found")
    contacts_db.remove(contact)
    return {"message": "Contact deleted", "id": contact_id}

Verificación paso a paso

  1. GET / — Info del servicio con total_contacts
  2. GET /contacts — Lista de contactos (2 precargados)
  3. GET /contacts/1 — Contacto con address
  4. GET /contacts/999 — 404
  5. POST /contacts con body válido — Crea con id 3
  6. POST /contacts con email sin @ — 422
  7. POST /contacts con zip_code inválido en address — 422
  8. PUT /contacts/1 — Actualiza completo
  9. PATCH /contacts/2 con {"phone": "666777888"} — Solo actualiza phone
  10. DELETE /contacts/3 — Elimina y retorna confirmación

Ejercicios del proyecto

Ejercicio 1: Agregar campo company (Medio)

Agrega company: Optional[str] = None a ContactCreate y ContactResponse. Actualiza los contactos precargados. Prueba POST con y sin company.

Ver solución

En ContactCreate y ContactResponse:

company: Optional[str] = Field(default=None, max_length=100)

En contacts_db, agrega "company": None o un valor a los elementos existentes.

Ejercicio 2: Validador para phone (Medio)

Agrega un @field_validator para phone que acepte solo dígitos, espacios, + y guiones. Si tiene otros caracteres, lanza ValueError.

Ver solución
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
    if not v:
        return v
    allowed = set("0123456789 +-")
    if not all(c in allowed for c in v):
        raise ValueError("Teléfono solo puede tener dígitos, espacios, + y -")
    return v

Ejercicio 3: GET /contacts/search (Medio)

Implementa GET /contacts?q=ana que filtre contactos cuyo name o email contenga q (case insensitive). Usa Query() para el parámetro.

Ver solución
from fastapi import Query

@app.get("/contacts", response_model=list[ContactResponse])
def list_contacts(q: Optional[str] = Query(None)):
    if q is None or not q.strip():
        return contacts_db
    q_lower = q.lower().strip()
    return [c for c in contacts_db if q_lower in c["name"].lower() or q_lower in c["email"].lower()]

Rúbrica de evaluación (100 puntos)

Modelos (30 pts)

  • (8) ContactCreate con todos los campos y constraints
  • (8) ContactResponse sin campos sensibles
  • (6) Address anidado con validaciones
  • (4) ContactUpdate con campos Optional
  • (4) @field_validator para email

Endpoints (45 pts)

  • (8) GET /contacts y GET /contacts/{id} con response_model
  • (10) POST /contacts con validación y 201
  • (8) PUT /contacts/{id} actualización completa
  • (8) PATCH /contacts/{id} con exclude_unset
  • (6) DELETE /contacts/{id}
  • (5) 404 con HTTPException

Validación y calidad (25 pts)

  • (5) Datos inválidos retornan 422
  • (5) Al menos 2 contactos precargados con address en uno
  • (5) Código organizado, sin duplicación
  • (5) Documentación en /docs correcta
  • (5) Flujo CRUD completo funciona

Checklist de completitud

  • ContactCreate con Field constraints
  • ContactResponse sin password ni datos sensibles
  • Address como modelo anidado opcional
  • Validador para email (@)
  • POST retorna 422 con email inválido
  • PUT y PATCH funcionan correctamente
  • response_model en GET y POST
  • HTTPException para 404
  • Flujo completo probado en /docs

Troubleshooting

1. ContactUpdate con campos requeridos — PATCH falla o borra datos

  • Causa: Si ContactUpdate tiene campos sin Optional, un PATCH con {"phone": "666"} enviará None implícito para los demás y sobrescribirá address o notes.
  • Solución: Todos los campos en ContactUpdate deben ser Optional. Usa model_dump(exclude_unset=True) para incluir solo los campos que el cliente envió.

2. address: {} vacío vs null

  • Causa: Si el cliente envía "address": {}, Pydantic valida el objeto y falla porque faltan street, city, zip_code.
  • Solución: Lo estándar es "address": null para "sin dirección". Si quieres aceptar objeto vacío, necesitarías un modelo más flexible o un validador que convierta {} en None.

3. Validador en ContactUpdate recibe None

  • Causa: Cuando el campo es Optional[str], el validador puede recibir None (el cliente no envió el campo).
  • Solución: Verifica if v is not None antes de validar. Ejemplo: if v is not None and "@" not in v: raise ValueError(...).

4. 422 sin mensaje claro — error genérico

  • Causa: A veces el detail del 422 viene anidado en una estructura que el cliente no parsea bien.
  • Solución: En /docs verás el formato exacto. El detail es una lista de objetos con loc (ej. ["body","email"]) y msg. Revisa que tu validador lance ValueError con un mensaje en español legible.

5. model_dump() vs model_dump(exclude_unset=True)

  • Causa: En PATCH, model_dump() sin exclude_unset=True incluye todos los campos con sus defaults (incluyendo None para Optional), sobrescribiendo datos existentes.
  • Solución: Siempre usa model_dump(exclude_unset=True) en endpoints PATCH para actualizar solo los campos enviados.

Conexión con el siguiente módulo

Tu API de contactos tiene validación sólida. En el Módulo 5 agregarás:

  • HTTPException con códigos de estado apropiados (404, 400)
  • Manejo global de excepciones con handlers
  • CORS para conectar con un frontend

Ideas para extender (opcional)

  • Agregar campo created_at solo en Response (fecha de creación)
  • Implementar GET /contacts/count que retorne el total
  • Validar que email no esté duplicado antes de crear
  • Agregar paginación a GET /contacts con skip y limit

Sección de testing

Prueba cada endpoint y anota el resultado. Usa /docs o curl:

PruebaEndpoint / AcciónResultado esperado
RaízGET /total_contacts: 2
ListaGET /contacts2 contactos
Detalle OKGET /contacts/1Ana con address
Detalle 404GET /contacts/999404
Crear OKPOST con body válido201, id 3
Crear 422POST con email sin @422
Crear 422POST con zip_code inválido en address422
ActualizarPUT /contacts/1200
ParcialPATCH con {"phone": "666777888"}200
EliminarDELETE /contacts/3200, luego GET 404

Comandos curl de referencia

# GET
curl http://127.0.0.1:8000/
curl http://127.0.0.1:8000/contacts
curl http://127.0.0.1:8000/contacts/1

# POST - crear contacto
curl -X POST http://127.0.0.1:8000/contacts \
  -H "Content-Type: application/json" \
  -d '{"name": "María López", "email": "maria@example.com", "phone": "612345678"}'

# POST - con address (debe tener zip_code 5 dígitos)
curl -X POST http://127.0.0.1:8000/contacts \
  -H "Content-Type: application/json" \
  -d '{"name": "Pedro", "email": "pedro@test.com", "address": {"street": "Calle 1", "city": "Barcelona", "zip_code": "08001"}}'

# PUT - actualizar completo
curl -X PUT http://127.0.0.1:8000/contacts/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "Ana García Actualizada", "email": "ana@example.com", "phone": "612345678"}'

# PATCH - actualizar solo phone
curl -X PATCH http://127.0.0.1:8000/contacts/2 \
  -H "Content-Type: application/json" \
  -d '{"phone": "666777888"}'

# DELETE
curl -X DELETE http://127.0.0.1:8000/contacts/3

Flujo completo de verificación

Ejecuta en orden para validar el CRUD:

1. GET /contacts → Ver contactos precargados
2. POST /contacts con body válido → Crear contacto 3
3. GET /contacts/3 → Verificar creación
4. POST /contacts con email sin @ → Verificar 422
5. PUT /contacts/3 → Actualizar completo
6. PATCH /contacts/3 con {"notes": "Actualizado"} → Actualización parcial
7. GET /contacts/3 → Verificar cambios
8. DELETE /contacts/3 → Eliminar
9. GET /contacts/3 → Verificar 404

Resumen

  • API de contactos CRUD con Pydantic completo
  • ContactCreate, ContactUpdate, ContactResponse separados
  • Address anidado opcional
  • Field constraints y @field_validator para email
  • response_model para documentar y filtrar respuestas
  • HTTPException para 404

El Módulo 5 agregará error handling profesional y CORS. Tu base con validación está lista.


Recursos Adicionales

  1. FastAPI - Request Body — Modelos en body
  2. FastAPI - Response Model — response_model y filtraje
  3. Pydantic - Validators — field_validator y model_validator
  4. Pydantic - Field Types — Field constraints (min_length, pattern, etc.)
  5. Pydantic - Model Config — Configuración de modelos
  6. FastAPI - Declarar ejemplos — Ejemplos en /docs

Módulo 4, Cápsula 06 — FastAPI Fundamentals Guide