2. Esquemas y pydantic con FastAPI
Ya tenemos nuestra primera versión de nuestro server, pero es muy minimalista con solo dos endpoints del tipo GET. Vamos a proceder a mejorar nuestra API con la validación de datos para crear registros en nuestro servidor.
¿Qué es un Esquema (Schema) en el Desarrollo Backend?
Antes de comenzar, veamos qué es un esquema (o schema) de validación. Esta es una estructura que define la forma, el tipo y las reglas de validación de los datos que una API espera recibir o enviar.
Imagina que tienes una base de datos con la siguiente tabla clientes:
Create table clientes (
id serial primary key,
nombre varchar(100) not null,
apellido varchar(100) not null,
email varchar(100) not null,
fecha_nacimiento date not null,
telefono varchar(100),
direccion varchar(100),
);
Esos son los campos que queremos guardar, siendo teléfono y dirección campos no obligatorios, ya que no tienen la restricción NOT NULL.
A nuestro backend debe llegar solo la información necesaria para crear ese registro. Si por ejemplo se intenta crear un cliente y no se envía el nombre, la API devolverá un error con un formato similar a este:
{
"detail": [
{
"loc": ["campo_que_dio_error"],
"msg": "message de error",
"type": "HTTPException"
}
]
}
Pydantic
Para validar este tipo de situaciones, en FastAPI usamos Pydantic, una biblioteca de Python para la validación de datos y configuración de modelos.
Para empezar a usar pydantic, vamos a crear un archivo schemas.py dentro de la carpeta app.
fastapi_project
├── .venv
└── app/
├── main.py
└── schemas.py
En este archivo, vamos a definir el schema para crear nuestro cliente:
# Importamos BaseModel de pydantic, esta es una clase
# que nos permite crear un modelo de datos. Si nuestro
# eschema no hereda de BaseModel, no podremos usarlo en nuestros endpoints.
from pydantic import BaseModel
from datetime import date
class Cliente(BaseModel):
nombre: str
apellido: str
email: str
fecha_nacimiento: date
telefono: str
direccion: str
Ahora que tenemos nuestro schema, vamos a proceder a crear un endpoint para crear un cliente. Vamos a ir a nuestro archivo main.py, borramos todos los endpoints que tengamos de la clase de introducción y copiaremos lo siguiente.
from app.schemas import Cliente
from typing import Annotated
from fastapi import FastAPI, Body
app = FastAPI()
clientes = []
@app.post("/clientes/")
async def create_cliente(
cliente: Annotated[Cliente, Body()]
):
clientes.append({
"id": len(clientes) + 1,
"nombre": cliente.nombre,
"apellido": cliente.apellido,
"fecha_nacimiento": cliente.fecha_nacimiento,
})
return "ok"
En este caso, estamos creando un endpoint POST que recibe un objeto Cliente como parámetro y lo agrega a la lista de clientes con un id autoincremental (len(clientes) + 1). Esta forma de colocar los id es solo provisional, ya que en una base de datos, los id se generan de forma automatica.
De este endpoint podemos destacar dos aspectos importantes en esta línea:
cliente: Annotated[Cliente, Body()]
-
Body()
: Esta función de FastAPI indica que el parámetrocliente
debe obtenerse del cuerpo (body) de la petición HTTP. Esto solo es válido en métodos como POST, PATCH y PUT, ya que son los métodos que normalmente envían datos en el cuerpo de la petición. -
Annotated
: Es una forma moderna en Python de agregar tipos a las variables. En este caso, estamos indicando que el parámetrocliente
es de tipoCliente
y que debe ser extraído del cuerpo de la petición usandoBody()
. Esta es la forma recomendada por FastAPI para trabajar con parámetros en los endpoints, ya que proporciona mejor información de tipos para el editor de código y herramientas de análisis estático.
Comparación de enfoques para manejar parámetros
1. Sin anotaciones de tipo
@app.post("/clientes/")
async def create_cliente(cliente):
return "ok"
Problemas:
- El editor no puede inferir el tipo de
cliente
- No hay validación de tipos
- No está claro de dónde viene el parámetro
2. Con tipo pero sin Annotated
@app.post("/clientes/")
async def create_cliente(cliente: Cliente):
return "ok"
Mejoras:
- El editor conoce el tipo de
cliente
- Se valida el tipo de datos
Limitaciones:
- No está explícito que el parámetro viene del body
3. Con Annotated (Recomendado)
from typing import Annotated
from fastapi import Body
@app.post("/clientes/")
async def create_cliente(
cliente: Annotated[Cliente, Body()]
):
return "ok"
Primera prueba
Ahora, para probarlo, ejecutamos el servidor:
uvicorn app.main:app --reload
Y nos vamos a la documentacion de FastAPI en http://127.0.0.1:8000/docs, y veremos que se ha creado un endpoint POST para crear un cliente.
Ejecutamos, y vemos que como este nos devuelve “OK”
Segunda prueba
Ahora vamos a hacer lo mismo, pero quitando los campos obligatorios, teléfono y dirección.
Como pudimos ver, el backend nos devolvió un error indicando que estos campos son requeridos. Esto ocurre porque en Pydantic, todos los campos son obligatorios por defecto, y si queremos que un campo sea opcional, debemos indicarlo manualmente.
Vamos a volver a nuestro código y cambiaremos el modelo cliente de la siguiente manera:
from pydantic import BaseModel
from datetime import date
from typing import Optional
class Cliente(BaseModel):
nombre: str
apellido: str
email: str
fecha_nacimiento: date
telefono: str | None = None # opcional, Forma 1
direccion: Optional[str] = None # opcional, Forma 2
-
Forma 1: El str | None = None: indica que el campo es un string o None, y por defecto, si no se envia, es None. El símbolo | se puede interpretar como “o”.
-
Forma 2: Optional[str] = None: es otra forma usando los type hints de Python, pero esta manera está en desuso en las versiones más recientes del lenguaje.
Habiendo realizado estos cambios, ejecutamos el servidor e intentamos de nuevo. Veremos como ahora si se puede crear un cliente sin especificar el telefono y la direccion.
Parámetros de Consulta (Query Parameters)
Hasta ahora hemos visto cómo recibir datos en el cuerpo (body) de las peticiones, pero esto solo es posible en métodos como POST, PUT y PATCH. Para métodos como GET, necesitamos usar parámetros de consulta (query parameters).
¿Qué son los Query Parameters?
Los parámetros de consulta son pares clave-valor que se añaden al final de una URL después de un signo de interrogación (?
). Cada par está separado por un ampersand (&
).
Ejemplo de URL con query parameters:
https://api.ejemplo.com/usuarios?nombre=Juan&apellido=Perez&edad=30
Casos de Uso Comunes:
-
Filtrado: Limitar los resultados según criterios específicos
/productos?categoria=electronica&precio_max=1000
-
Paginación: Dividir resultados en páginas
/articulos?pagina=2&por_pagina=10
-
Ordenamiento: Especificar el orden de los resultados
/productos?ordenar_por=precio&orden=desc
-
Búsqueda: Filtrar por términos de búsqueda
/libros?buscar=python&idioma=es
Ejemplo de URL con query params:
http://127.0.0.1:8000/clientes/?nombre=Juan&apellido=Perez
Aqui, nombre y apellido son los queryParams, y Juan y Perez son los valores de esos queryParams.
Para poder usarlos en fastAPi, basta con escribir que parametros queremos recibir en nuestros endpoints
from fastapi import FastAPI
app = FastAPI()
# Ruta con query parameters simples
@app.get("/items/")
async def read_items(nombre: str, apellido: str):
print("Nombre:", nombre)
print("Apellido:", apellido)
return {"nombre": nombre, "apellido": apellido}
Si envíamos
http://127.0.0.1:8000/clientes/?nombre=Juan&apellido=Perez
Esto mostrará por consola
Nombre: Juan
Apellido: Perez
Sin embargo, tambien podemos usar pydantic para definir como queremos recibir la data, de la sigiente manera
from pydantic import BaseModel, Field
from fastapi import Query
class Parametros(BaseModel):
nombre: str
apellido: str
@app.get("/items/")
async def read_items(query: Annotated[Parametros, Query()]):
print("Nombre:", query.nombre)
print("Apellido:", query.apellido)
return query
Ambas formas son validas, pero a mi gusto personal, prefiero crear el modelo con pydantic, ya que ofrece mas flexibilidad y validaciones.
Tipos de Datos en pydantic.
Hasta ahora solo hemos usado string y date. Sin embargo, pydantic ofrece una gran variedad de tipos de datos que podemos usar para tipar nuestros modelos. Estos son los mismos tipos de datos que tenemos en Python al definir una variable. Podríamos tipar todos los siguientes casos:
from pydantic import BaseModel
from typing import List, Optional, Dict, Set, Tuple
from datetime import datetime, date
class DataTypesExample(BaseModel):
# Tipos básicos
name: str
age: int
height: float
is_active: bool = True
# Tipos opcionales: ambas formas hacen lo mismo.
email: str | None = None
email: Optional[str] = None
# Tipos de colección
tags: List[str] = [] # Lista de strings
metadata: Dict[str, str] = {} # Diccionario de strings
unique_tags: Set[str] = set() # Conjunto de strings
coordinates: Tuple[float, float] # Tupla de floats
# Fechas
created_at: datetime # fecha y hora
birth_date: date # solo fecha
El uso de cada tipo dependerá de cómo queramos estructurar la información que enviamos a nuestro backend. Si quisiéramos enviar datos con la siguiente estructura
{
"nombre": "Juan",
"apellido": "Perez",
"email": "juan@example.com",
"fecha_nacimiento": "2023-01-01T00:00:00",
"telefono": "+573123456789",
"direccion": "Calle 123",
"dias_trabajo": ["Lunes", "Martes", "Miercoles", "Viernes"],
"created_at": "2023-01-01T00:00:00",
"birth_date": "2023-01-01"
}
Podriamos tener un esquema parecido al siguiente
from pydantic import BaseModel
from typing import Optional, Dict
from datetime import datetime, date
class DataTypesExample(BaseModel):
nombre: str
apellido: str
email: str
fecha_nacimiento: date
telefono: str | None = None
direccion: str | None = None
dias_trabajo: list[str] = []
created_at: datetime # fecha y hora
birth_date: date # solo fecha
Validaciones de campos
En pydantic podemos validar los datos de entrada ademas del tipo de dato. Por ejemplo, podriamos usar el objecto Field de pydantic para validar los datos de entrada de nuestro modelo cliente de la siguiente manera:
from pydantic import BaseModel, Field
from datetime import date
class Cliente(BaseModel):
nombre: str = Field(..., min_length=3, max_length=50)
apellido: str = Field(..., min_length=3, max_length=50)
email: str = Field(..., min_length=3, max_length=50)
fecha_nacimiento: date = Field()
telefono: str | None = Field(pattern=r"^\+?\d{9,15}$")
direccion: str | None = None
Ahora el modelo tiene las siguientes validaciones:
- nombre: debe tener al menos 3 carácteres y maximo 50.
- apellido: debe tener al menos 3 carácteres y maximo 50.
- email: debe tener al menos 3 carácteres y maximo 50.
- teléfono: debe ser un numero de teléfono con formato internacional, ejemplo: +573123456789. Este campo utiliza una expresión regular (también conocida como regex) para validar el formato del número de teléfono.
Validaciones Personalizadas
En Pydantic, si queremos ser más estrictos con la validación de los datos, podemos crear validaciones personalizadas para nuestros modelos. Por ejemplo, vamos a crear una validación para que la fecha de nacimiento requiera que el usuario sea mayor de edad.
Para ello, usaremos el decorador @field_validator, y los usaremos de la siguiente manera:
from pydantic import BaseModel, field_validator, Field
from datetime import date
class Cliente(BaseModel):
nombre: str = Field(..., min_length=3, max_length=50)
apellido: str = Field(..., min_length=3, max_length=50)
email: str = Field(..., min_length=3, max_length=50)
fecha_nacimiento: date = Field()
telefono: str | None = Field(pattern=r"^\+?\d{9,15}$")
direccion: str | None = None
@field_validator('fecha_nacimiento')
@classmethod # esto es requerido para que funcione
def fecha_nacimiento_must_be_adult(cls, valor_de_request):
edad = (date.today() - valor_de_request).days // 365
if edad < 18:
raise ValueError('El usuario debe ser mayor de edad')
return valor_de_request
Si probamos y no cumplimos con la validacion de la fecha, nos devolvera un error.
Modelos Anidados
En Pydantic, podemos crear modelos anidados, es decir, podemos crear un modelo que tenga como campo otro modelo. Esto nos permite manejar estructuras de datos más complejas de manera organizada.
Supongamos que queremos enviar los siguientes datos:
{
"nombre": "John Doe",
"email": "john.doe@example.com",
"direccion": {
"calle": "123 Main St",
"ciudad": "Anytown",
"pais": "USA",
"codigo_postal": "12345"
},
}
from typing import List
from pydantic import BaseModel
class Direccion(BaseModel):
calle: str
ciudad: str
pais: str
codigo_postal: str
class Usuario(BaseModel):
nombre: str
email: str
direccion: Direccion # Campo anidado
En el ejemplo anterior, tenemos un modelo Direccion que tiene como campos calle, ciudad, pais y codigo_postal. Luego tenemos un modelo Usuario que tiene como campos nombre, email y direccion. Este ultimo es el campo anidado.
Ejemplo con update clientes
Basándonos en lo aprendido, vamos a crear un modelo para actualizar un cliente. Imaginemos que queremos actualizar un cliente y que los datos lleguen en el siguiente formato:
{
"nombre_completo": {
"nombre": "Juan",
"apellido": "Perez"
},
"email": "juan.perez@example.com",
"fecha_nacimiento": "2000-01-01",
"telefono": "+573123456789",
"direccion": "Calle 123"
}
Vamos a crear un modelo con la estructura de arriba.
class NombreCompleto(BaseModel):
nombre: str | None = None
apellido: str | None = None
class ClienteUpdate(BaseModel):
nombre_completo: NombreCompleto | None
email: str | None = None
fecha_nacimiento: date | None = None
telefono: str | None = None
direccion: str | None = None
Marcamos cada campo como opcional con el símbolo | None = None, ya que al actualizar un cliente no es necesario enviar todos los campos, solo los que se deseen modificar.
Luego de eso, crearemos un endpoint para actualizar un cliente en el archivo main.py
from fastapi import HTTPException
@app.put("/clientes/{cliente_id}")
async def update_cliente(
cliente_id: int,
cliente: Annotated[ClienteUpdate, Body()]
):
# Logica para actualizar el cliente
return "ok"
Respuestas Personalizadas
Del mismo modo que validamos la información de entrada, podemos definir un esquema para las respuestas de nuestra API. Por ejemplo, podríamos querer que el endpoint de actualización devuelva una respuesta con el siguiente formato:
{
"status": 200,
"message": "Cliente actualizado exitosamente"
}
Para esto, nos venimos a schemas.py y creamos el siguiente modelo:
from pydantic import BaseModel
from typing import Optional
# Resto del codigo
class UpdateClienteResponse(BaseModel):
status: int
message: str
Luego en el endpoint, lo importamos y lo usamos pasandole al endpoint el parametro response_model. De la siguiente manera.
from fastapi import HTTPException
from app.schemas import UpdateClienteResponse
# ... resto del codigo.
@app.put(
"/clientes/{cliente_id}",
response_model=UpdateClienteResponse # Le pasamos response_model
)
async def update_cliente(
cliente_id: int,
cliente: Annotated[ClienteUpdate, Body()]
):
# Logica para actualizar el cliente.
## podemos devolverlo asi:
return UpdateClienteResponse(
status=200,
message="Cliente actualizado exitosamente"
)
## o asi:
return {
"status": 200,
"message": "Cliente actualizado exitosamente"
}
Que ocurre aqui? Si por casualidad la respuesta no cumple con el esquema que le pasamos a response_model, FastAPI se encarga de lanzar una excepcion en el codigo de la respuesta.
Esto puede ser util cuando esperamos que la respuesta tenga ciertos campos de forma obligatoria. Si en nuestros endpoint hay un error que ocasione que no se envie algo, FastAPI se encarga de lanzar una excepcion y esto nos ayudara a ubicar los errores y no enviar fallos en nuestro endpoint.
Conclusión
Hasta este punto ya sabes crear endpoints, validacion de campos, respuestas personalizadas. Pero esto no se queda aqui, ahora hay que mejorar el status de las respuestas y ver que tipos de datos podemos enviar.