FastAPI: construir una API REST con validación automática

1. Panorama general

Al escribir una API REST, gran parte del código de ‘defensa’ está en validar la entrada: ¿el campo es obligatorio?, ¿el número es negativo?, ¿es válido el email? FastAPI delega esto a Pydantic. Solo necesitas declarar tipos de datos y restricciones; el framework validará automáticamente las peticiones y devolverá errores 422 estructurados.

Este artículo construye una API de gestión de pedidos (Order) desde cero. Cuando termines, sabrás cómo restringir body, path y query. También podrás escribir validadores personalizados, filtrar datos de respuesta, personalizar el formato de errores y probar todo con pytest.

2. Instalación

Necesitas Python 3.10 o superior. Crea un virtualenv e instala FastAPI con el grupo de dependencias standard:

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest

El paquete fastapi[standard] incluye todo lo que necesita este artículo:

  • uvicorn para ejecutar el servidor.
  • fastapi-cli proporciona el comando fastapi dev.
  • httpx usado por TestClient.
  • email-validator usado para el tipo EmailStr.

3. Primer endpoint con modelo Pydantic

Crea un archivo main.py:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Order API")


class OrderCreate(BaseModel):
    customer_email: str
    product_sku: str
    quantity: int


@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
    return order

Ejecuta el servidor en modo dev, con recarga automática:

fastapi dev main.py

Aquí no hay ninguna línea if. El parámetro order: OrderCreate le indica a FastAPI tres cosas:

  • Leer JSON del cuerpo de la petición.
  • Parsear ese JSON a OrderCreate.
  • Devolver 422 si los datos no coinciden con el tipo.

Intenta enviar quantity como una cadena que no es un número:

curl -X POST localhost:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}

El campo loc indica la ubicación exacta del error: está en body, en el campo quantity. Además, la documentación OpenAPI en /docs se genera a partir de este modelo. Por lo tanto, el código y la documentación nunca se desincronizarán.

4. Restricciones de datos con Field

La validación de tipos no es suficiente. Por ejemplo, quantity = -5 sigue siendo un int válido. Añades restricciones con Field, combinado con Annotated:

from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, EmailStr, Field

Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]


class OrderCreate(BaseModel):
    customer_email: EmailStr
    product_sku: Sku
    quantity: Annotated[int, Field(gt=0, le=100)]
    unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
    discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
    note: Annotated[str | None, Field(max_length=200)] = None

Cada línea hace lo siguiente:

  • EmailStr valida el formato de email de verdad, no solo verifica si hay un @.
  • Sku es un tipo reutilizable. Requiere una longitud de 3–20 caracteres y solo contiene letras, números y guiones.
  • quantity debe ser mayor que 0 y no exceder 100.
  • unit_price usa Decimal en lugar de float para evitar errores de redondeo en cálculos de dinero. Máximo 10 dígitos, 2 decimales.
  • discount y note tienen valores por defecto, por lo que son campos opcionales. Los campos sin valor por defecto son obligatorios.

Enviar quantity: 0 devolverá:

{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}

Nota: Por defecto, Pydantic se ejecuta en modo lax. La cadena "5" se coerciona a 5 sin error. Si quieres rechazarla, añade model_config = ConfigDict(strict=True) al modelo, o usa Field(strict=True) en cada campo.

5. Validar path y query parameters

No solo el body, path y query también se validan con el mismo mecanismo:

from fastapi import HTTPException, Path, Query

orders_db: dict[int, dict] = {}


@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
    if order_id not in orders_db:
        raise HTTPException(status_code=404, detail="Order not found")
    return orders_db[order_id]


@app.get("/orders")
def list_orders(
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
    sku: Annotated[str | None, Query(min_length=3)] = None,
):
    items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
    start = (page - 1) * size
    return items[start : start + size]

Los resultados con diferentes peticiones:

  • GET /orders/0 devuelve 422 porque order_id debe ser mayor que 0.
  • GET /orders?size=500 devuelve 422 porque size es máximo 50.
  • GET /orders?page=2&size=5 se ejecuta normalmente. Los valores page y size ya se coercieron a int antes de entrar en la función.

Distingue entre 422 y 404. Error 422 significa que la petición está mal formada. Error 404 es lógica de negocio, así que tú mismo lanzas HTTPException.

6. Validadores personalizados

Hay reglas que no se pueden expresar con Field. En esos casos, usas field_validator para un campo o model_validator para reglas que afectan múltiples campos.

from pydantic import field_validator, model_validator

FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}


class OrderCreate(BaseModel):
    # ... campos como en la sección 4 ...

    @field_validator("product_sku")
    @classmethod
    def normalize_sku(cls, value: str) -> str:
        return value.upper()

    @field_validator("customer_email")
    @classmethod
    def reject_disposable_email(cls, value: str) -> str:
        domain = value.split("@")[-1].lower()
        if domain in FORBIDDEN_DOMAINS:
            raise ValueError("Disposable email is not allowed")
        return value

    @model_validator(mode="after")
    def check_discount(self) -> "OrderCreate":
        total = self.unit_price * self.quantity
        if self.discount > total:
            raise ValueError("Discount cannot exceed order total")
        return self

Explicación de cada validador:

  • normalize_sku se ejecuta después de que Field haya comprobado el patrón. Normaliza el SKU a mayúsculas, por lo que "tshirt-01" se guarda como "TSHIRT-01". Un validador puede transformar el valor, siempre que devuelva el nuevo valor.
  • reject_disposable_email lanza ValueError. Pydantic convierte esto en un error de validación, y el mensaje tiene el prefijo "Value error, ".
  • check_discount se ejecuta con mode="after", es decir, cuando todos los campos son válidos y tienen el tipo correcto. Así puedes multiplicar Decimal de forma segura. Recuerda devolver self.

Nota: No llames a la base de datos ni a APIs externas dentro de un validador. Por ejemplo, comprobar si el SKU existe en el inventario es lógica de negocio. Ponlo en un servicio o dependencia. Los validadores deben ser funciones puras, rápidas y fáciles de probar.

7. response_model: controlar los datos de salida

A menudo guardas más información de la que quieres devolver al cliente, como notas internas. Separa los modelos de entrada y salida:

class OrderRead(BaseModel):
    id: int
    customer_email: EmailStr
    product_sku: str
    quantity: int
    total: Decimal


@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
    order_id = len(orders_db) + 1
    record = {
        "id": order_id,
        **order.model_dump(),
        "total": order.unit_price * order.quantity - order.discount,
        "internal_flag": "manual-review",
    }
    orders_db[order_id] = record
    return record

La función devuelve un dict que tiene note e internal_flag. Sin embargo, response_model=OrderRead filtra la respuesta para que solo contenga los campos declarados:

{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}

Hay dos puntos a tener en cuenta:

  • Decimal se serializa a una cadena "39.98" para preservar la precisión. El cliente debe saberlo.
  • La respuesta también se valida. Si olvidas el campo total, FastAPI lanza ResponseValidationError y devuelve 500, no 422. Este es un error del servidor, no del cliente.

8. Personalizar el formato del error 422

El formato por defecto es bastante detallado. Muchos equipos quieren una estructura compacta y uniforme para el frontend. Sobrescribes el controlador de RequestValidationError:

from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "field": ".".join(str(part) for part in err["loc"][1:]) or "body",
            "message": err["msg"],
        }
        for err in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"code": "VALIDATION_FAILED", "errors": errors},
    )

Cómo el controlador procesa cada error:

  • err["loc"][1:] omite el primer elemento (body, query, path) y concatena el resto con puntos.
  • Los errores de model_validator no están asociados a ningún campo. Entonces loc solo tiene ("body",), así que el controlador asigna "body".

Enviar un pedido con quantity: 0 y un email desechable recibirá:

{"code":"VALIDATION_FAILED","errors":[
  {"field":"customer_email","message":"Value error, Disposable email is not allowed"},
  {"field":"quantity","message":"Input should be greater than 0"}]}

Pydantic recopila todos los errores de los campos en una sola pasada, en lugar de detenerse en el primero. Por lo tanto, el cliente puede corregir todo en un viaje.

9. Pruebas con pytest

TestClient llama a la aplicación directamente, sin necesidad de ejecutar un servidor. Crea un archivo test_main.py al lado de main.py:

import pytest
from fastapi.testclient import TestClient

from main import app, orders_db

client = TestClient(app)

VALID_ORDER = {
    "customer_email": "an@example.com",
    "product_sku": "tshirt-01",
    "quantity": 2,
    "unit_price": "19.99",
}


@pytest.fixture(autouse=True)
def clear_db():
    orders_db.clear()


def test_create_order_returns_normalized_sku_and_total():
    response = client.post("/orders", json=VALID_ORDER)

    assert response.status_code == 201
    body = response.json()
    assert body["product_sku"] == "TSHIRT-01"
    assert body["total"] == "39.98"
    assert "internal_flag" not in body


def test_invalid_quantity_and_email_are_reported_together():
    payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    fields = {e["field"] for e in response.json()["errors"]}
    assert fields == {"quantity", "customer_email"}


def test_discount_greater_than_total_is_rejected():
    payload = {**VALID_ORDER, "discount": "100.00"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    assert response.json()["errors"][0] == {
        "field": "body",
        "message": "Value error, Discount cannot exceed order total",
    }


def test_page_size_over_limit_is_rejected():
    assert client.get("/orders?size=500").status_code == 422

Ejecuta los tests:

pytest -q
....                                                    [100%]
4 passed in 0.31s

La fixture clear_db reinicia orders_db antes de cada test, ayudando a que los tests sean independientes entre sí. Además, puedes probar el modelo directamente sin HTTP usando OrderCreate.model_validate(...) combinado con pytest.raises(ValidationError). Esto es útil cuando el modelo tiene muchas reglas complejas.

10. Errores comunes

  • Usar float para dinero. 0.1 + 0.2 no es igual a 0.3. Usa Decimal con max_digits y decimal_places.
  • Olvidar @classmethod u orden de decoradores. @field_validator debe estar encima de @classmethod. De lo contrario, Pydantic v2 reporta un error al importar.
  • Olvidar return en un validador. Si field_validator no devuelve, asignará None al campo. model_validator(mode="after") debe devolver self.
  • Usar sintaxis de Pydantic v1. @validator, class Config, .dict() están deprecados. Usa @field_validator, model_config, .model_dump().
  • Confundir 422 con 500. Input incorrecto da 422. Response que no coincide con response_model da 500. Si ves 500, verifica los datos que devuelves.
  • Usar un modelo compartido para entrada y salida. Esto expone fácilmente campos internos y obliga al cliente a enviar campos como id. Separa OrderCreate y OrderRead.

Nota: La validación en la capa de API no reemplaza las restricciones en la base de datos. Las restricciones como UNIQUE o NOT NULL siguen siendo necesarias. Las peticiones pueden entrar al sistema desde múltiples rutas, como trabajos en segundo plano o scripts de migración.

11. Conclusión

FastAPI combinado con Pydantic convierte type hints en contratos de datos, con tres beneficios principales:

  • Las peticiones incorrectas se bloquean en la entrada.
  • Los errores se devuelven completos y estructurados.
  • La documentación OpenAPI siempre coincide con el código.

En este artículo, usaste Field para restricciones simples, Path y Query para parámetros de URL, field_validator y model_validator para reglas personalizadas. También usaste response_model para filtrar la salida, personalizar el formato del error 422 y probar todo con TestClient.

El principio a recordar: deja que los validadores verifiquen la forma de los datos, pero coloca la lógica de negocio en la capa de servicio.

pythonfastapipydanticrest-apivalidation