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()]
  1. Body(): Esta función de FastAPI indica que el parámetro cliente 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.

  2. Annotated: Es una forma moderna en Python de agregar tipos a las variables. En este caso, estamos indicando que el parámetro cliente es de tipo Cliente y que debe ser extraído del cuerpo de la petición usando Body(). 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.

alt text

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.

alt text

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.

alt text

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:

  1. Filtrado: Limitar los resultados según criterios específicos

    /productos?categoria=electronica&precio_max=1000
  2. Paginación: Dividir resultados en páginas

    /articulos?pagina=2&por_pagina=10
  3. Ordenamiento: Especificar el orden de los resultados

    /productos?ordenar_por=precio&orden=desc
  4. 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.