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:
uvicornpara ejecutar el servidor.fastapi-cliproporciona el comandofastapi dev.httpxusado porTestClient.email-validatorusado para el tipoEmailStr.
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
422si 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:
EmailStrvalida el formato de email de verdad, no solo verifica si hay un@.Skues un tipo reutilizable. Requiere una longitud de 3–20 caracteres y solo contiene letras, números y guiones.quantitydebe ser mayor que 0 y no exceder 100.unit_priceusaDecimalen lugar defloatpara evitar errores de redondeo en cálculos de dinero. Máximo 10 dígitos, 2 decimales.discountynotetienen 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 a5sin error. Si quieres rechazarla, añademodel_config = ConfigDict(strict=True)al modelo, o usaField(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/0devuelve422porqueorder_iddebe ser mayor que 0.GET /orders?size=500devuelve422porquesizees máximo 50.GET /orders?page=2&size=5se ejecuta normalmente. Los valorespageysizeya se coercieron aintantes 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_skuse ejecuta después de queFieldhaya 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_emaillanzaValueError. Pydantic convierte esto en un error de validación, y el mensaje tiene el prefijo"Value error, ".check_discountse ejecuta conmode="after", es decir, cuando todos los campos son válidos y tienen el tipo correcto. Así puedes multiplicarDecimalde forma segura. Recuerda devolverself.
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:
Decimalse 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 lanzaResponseValidationErrory devuelve500, no422. 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_validatorno están asociados a ningún campo. Entonceslocsolo 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
floatpara dinero.0.1 + 0.2no es igual a0.3. UsaDecimalconmax_digitsydecimal_places. - Olvidar
@classmethodu orden de decoradores.@field_validatordebe estar encima de@classmethod. De lo contrario, Pydantic v2 reporta un error al importar. - Olvidar
returnen un validador. Sifield_validatorno devuelve, asignaráNoneal campo.model_validator(mode="after")debe devolverself. - Usar sintaxis de Pydantic v1.
@validator,class Config,.dict()están deprecados. Usa@field_validator,model_config,.model_dump(). - Confundir
422con500. Input incorrecto da422. Response que no coincide conresponse_modelda500. Si ves500, 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. SeparaOrderCreateyOrderRead.
Nota: La validación en la capa de API no reemplaza las restricciones en la base de datos. Las restricciones como
UNIQUEoNOT NULLsiguen 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.