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
Addressdentro deContact - Cápsula 05 — Request vs Response: Diferentes modelos para crear (sin
id) y responder (conid,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
| Campo | Tipo | Constraints | ¿En Create? | ¿En Response? |
|---|---|---|---|---|
| id | int | auto-generado | No | Sí |
| name | str | min_length=1, max_length=100 | Sí | Sí |
| str | min_length=5, max_length=100, debe contener @ | Sí | Sí | |
| phone | str | default="", max_length=20 | Sí | Sí |
| address | Address | None | modelo anidado opcional | Sí | Sí |
| notes | str | None | max_length=500, opcional | Sí | Sí |
| created_at | datetime | solo en Response (opcional) | No | Sí 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
| Campo | Tipo | Constraints |
|---|---|---|
| street | str | min_length=1, max_length=200 |
| city | str | min_length=1, max_length=100 |
| zip_code | str | pattern ^\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étodo | Ruta | Descripción | Status |
|---|---|---|---|
| GET | / | Info del servicio | 200 |
| GET | /contacts | Lista todos los contactos | 200 |
| GET | /contacts/{id} | Obtiene contacto por ID | 200 |
| POST | /contacts | Crea contacto nuevo | 201 |
| PUT | /contacts/{id} | Actualiza contacto completo | 200 |
| PATCH | /contacts/{id} | Actualiza campos específicos | 200 |
| DELETE | /contacts/{id} | Elimina contacto | 200 |
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_codeinvá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 enviado | Motivo 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
- GET / — Info del servicio con total_contacts
- GET /contacts — Lista de contactos (2 precargados)
- GET /contacts/1 — Contacto con address
- GET /contacts/999 — 404
- POST /contacts con body válido — Crea con id 3
- POST /contacts con email sin @ — 422
- POST /contacts con zip_code inválido en address — 422
- PUT /contacts/1 — Actualiza completo
- PATCH /contacts/2 con
{"phone": "666777888"}— Solo actualiza phone - 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áNoneimplícito para los demás y sobrescribirá address o notes. - Solución: Todos los campos en ContactUpdate deben ser
Optional. Usamodel_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": nullpara "sin dirección". Si quieres aceptar objeto vacío, necesitarías un modelo más flexible o un validador que convierta{}enNone.
3. Validador en ContactUpdate recibe None
- Causa: Cuando el campo es
Optional[str], el validador puede recibirNone(el cliente no envió el campo). - Solución: Verifica
if v is not Noneantes 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
detaildel 422 viene anidado en una estructura que el cliente no parsea bien. - Solución: En /docs verás el formato exacto. El
detailes una lista de objetos conloc(ej.["body","email"]) ymsg. Revisa que tu validador lanceValueErrorcon un mensaje en español legible.
5. model_dump() vs model_dump(exclude_unset=True)
- Causa: En PATCH,
model_dump()sinexclude_unset=Trueincluye todos los campos con sus defaults (incluyendoNonepara 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_atsolo en Response (fecha de creación) - Implementar
GET /contacts/countque retorne el total - Validar que email no esté duplicado antes de crear
- Agregar paginación a
GET /contactsconskipylimit
Sección de testing
Prueba cada endpoint y anota el resultado. Usa /docs o curl:
| Prueba | Endpoint / Acción | Resultado esperado |
|---|---|---|
| Raíz | GET / | total_contacts: 2 |
| Lista | GET /contacts | 2 contactos |
| Detalle OK | GET /contacts/1 | Ana con address |
| Detalle 404 | GET /contacts/999 | 404 |
| Crear OK | POST con body válido | 201, id 3 |
| Crear 422 | POST con email sin @ | 422 |
| Crear 422 | POST con zip_code inválido en address | 422 |
| Actualizar | PUT /contacts/1 | 200 |
| Parcial | PATCH con {"phone": "666777888"} | 200 |
| Eliminar | DELETE /contacts/3 | 200, 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
- FastAPI - Request Body — Modelos en body
- FastAPI - Response Model — response_model y filtraje
- Pydantic - Validators — field_validator y model_validator
- Pydantic - Field Types — Field constraints (min_length, pattern, etc.)
- Pydantic - Model Config — Configuración de modelos
- FastAPI - Declarar ejemplos — Ejemplos en /docs
Módulo 4, Cápsula 06 — FastAPI Fundamentals Guide