En APIs REST, las rutas definen los puntos de acceso a través de los cuales los clientes pueden interactuar con el servidor. Litestar facilita el registro y la gestión de rutas, permitiendo definir endpoints de manera clara y estructurada.
Registro de rutas
Las rutas en Litestar se registran con funciones. Para definir una ruta, usa el decorador @route(), o decoradores específicos como @get(), @post(), @put(), @delete(), etc., según el método HTTP.
from litestar import Litestar, get
@get("/saludo")
def saludar() -> dict:
return {"mensaje": "Hola, Litestar!"}
app = Litestar(route_handlers=[saludar])Métodos HTTP disponibles
Litestar proporciona decoradores para los principales métodos HTTP:
from litestar import get, post, put, patch, delete
# Simulación de base de datos
DB_FAKE = [
{"id": 1, "username": "admin", "fullname": "Admin"},
{"id": 2, "username": "user", "fullname": "Normal user"},
]
@get("/usuarios")
async def obtener_usuarios() -> list[dict]:
return DB_FAKE
@post("/usuarios")
async def crear_usuario(data: dict) -> dict:
DB_FAKE.append(data)
return data
@put("/usuarios/{id:int}")
async def actualizar_usuario(id: int, data: dict) -> dict:
for i, row in enumerate(DB_FAKE):
if row["id"] == id:
DB_FAKE[i] = {"id": id, **data}
return data
return {"mensaje": "Usuario no encontrado"}
@patch("/usuarios/{id:int}")
async def actualizar_parcial(id: int, data: dict) -> dict:
for row in DB_FAKE:
if row["id"] == id:
for k, v in data.items():
row[k] = v
return row
return {"mensaje": "Usuario no encontrado"}
@delete("/usuarios/{id:int}")
async def eliminar_usuario(id: int) -> None:
for i, row in enumerate(DB_FAKE):
if row["id"] == id:
del DB_FAKE[i]
returnFunciones síncronas vs asíncronas
En Litestar puedes usar tanto funciones síncronas (
def) como asíncronas (async def). Las funciones asíncronas son preferibles en la mayoría de los casos, ya que permiten manejar múltiples solicitudes concurrentemente sin bloquear el servidor.
Parámetros de ruta (Path Parameters)
Los parámetros de ruta permiten capturar valores dinámicos desde la URL. Se definen usando llaves {} en la ruta y deben coincidir con los parámetros de la función.
from litestar import get
DB_FAKE = [
{"id": 1, "username": "admin", "fullname": "Admin"},
{"id": 2, "username": "user", "fullname": "Normal user"},
]
@get("/usuarios/{id:int}")
async def obtener_usuario(id: int) -> dict:
for row in DB_FAKE:
if row["id"] == id:
return row
return {"mensaje": "Usuario no encontrado"}
@get("/productos/{categoria:str}/{id:int}")
async def obtener_producto(categoria: str, id: int) -> dict:
return {"categoria": categoria, "id": id, "nombre": "Producto"}Tipos de parámetros
Litestar admite conversión automática de tipos:
- int: Números enteros →
/usuarios/{id:int} - str: Cadenas de texto →
/categorias/{nombre:str} - float: Números decimales →
/productos/{precio:float} - uuid: UUID →
/sesiones/{token:uuid} - path: Rutas con barras →
/archivos/{ruta:path}
¿Qué es un UUID?
UUID (Universally Unique IDentifier) es un identificador de 128 bits con formato
550e8400-e29b-41d4-a716-446655440000. Su principal característica es que pueden generarse de forma independiente con una probabilidad de colisión extremadamente baja (prácticamente cero), lo que los hace ideales para sistemas distribuidos, tokens de sesión, y generación de IDs sin servidor centralizado.
from uuid import UUID
from litestar import get
@get("/sesiones/{id:int}")
async def obtener_sesion(id: int) -> dict:
return {"ID": str(id), "activa": True}
@get("/archivos/{ruta:path}")
async def obtener_archivo(ruta: str) -> dict:
return {"archivo": ruta}Validación automática
Si el valor en la URL no coincide con el tipo especificado, Litestar devolverá automáticamente un error 400 (Bad Request) con un mensaje descriptivo.
Query Parameters
Los query parameters se extraen de la URL después del símbolo ? y se definen como parámetros opcionales en la función con valores predeterminados.
from litestar import get
@get("/usuarios")
async def listar_usuarios(
page: int = 1,
limit: int = 10,
activo: bool | None = None,
) -> dict:
return {
"page": page,
"limit": limit,
"activo": activo,
"usuarios": [],
}Ejemplo de uso:
GET /usuarios?page=2&limit=20&activo=trueParámetros requeridos vs opcionales
from litestar import get
# Parámetro requerido (sin valor predeterminado)
@get("/buscar")
async def buscar(query: str) -> dict:
return {"busqueda": query, "resultados": []}
# Parámetros opcionales (con valores predeterminados)
@get("/filtrar")
async def filtrar(
categoria: str | None = None,
precio_min: float = 0.0,
precio_max: float = 1000.0,
) -> dict:
return {
"categoria": categoria,
"precio_min": precio_min,
"precio_max": precio_max,
}Tipos con None
Cuando defines un parámetro como
str | None = None, indicas que el parámetro es opcional. Si no se proporciona en la URL, su valor seráNone.
Request Body
El cuerpo de la petición se utiliza principalmente con los métodos POST, PUT y PATCH. Litestar permite definir el cuerpo usando dataclasses, diccionarios o modelos Pydantic.
Usando diccionarios
from litestar import post
@post("/usuarios")
async def crear_usuario(data: dict[str, str | int]) -> dict:
return {"mensaje": "Usuario creado", "usuario": data}Usando dataclasses
from dataclasses import dataclass
from litestar import post
@dataclass
class Usuario:
id: int
username: str
fullname: str
DB_FAKE = [
{"id": 1, "username": "admin", "fullname": "Admin"},
{"id": 2, "username": "user", "fullname": "Normal user"},
]
@post("/usuarios")
async def crear_usuario(data: Usuario) -> dict:
# Convertir dataclass a diccionario para agregarlo a la BD
nuevo_usuario = {
"id": data.id,
"username": data.username,
"fullname": data.fullname,
}
DB_FAKE.append(nuevo_usuario)
return nuevo_usuarioVentajas de usar dataclasses:
- Validación automática de tipos
- Autocompletado en el editor
- Documentación automática en Swagger
- Mensajes de error descriptivos
Validación mejorada con Pydantic
Para validaciones más complejas (emails, URLs, rangos), puedes usar modelos de Pydantic en lugar de dataclasses. Litestar soporta ambos enfoques.
Respuestas personalizadas
Por defecto, Litestar serializa el retorno de las funciones a JSON. Sin embargo, puedes personalizar las respuestas usando Response.
Códigos de estado personalizados
from litestar import post, Response
from litestar.status_codes import HTTP_201_CREATED, HTTP_400_BAD_REQUEST
DB_FAKE = [
{"id": 1, "username": "admin", "fullname": "Admin"},
{"id": 2, "username": "user", "fullname": "Normal user"},
]
@post("/usuarios")
async def crear_usuario(data: dict) -> Response:
# Validar campos requeridos
if not data.get("username"):
return Response(
{"error": "username es requerido"},
status_code=HTTP_400_BAD_REQUEST,
)
if not data.get("fullname"):
return Response(
{"error": "fullname es requerido"},
status_code=HTTP_400_BAD_REQUEST,
)
# Agregar usuario
DB_FAKE.append(data)
return Response(
{"mensaje": "Usuario creado", "usuario": data},
status_code=HTTP_201_CREATED,
)Headers personalizados
from litestar import get, Response
@get("/datos")
async def obtener_datos() -> Response:
return Response(
{"datos": [1, 2, 3]},
headers={
"X-Total-Count": "3",
"X-Page": "1",
},
)Organización de rutas con Controllers
Para proyectos más grandes, es recomendable organizar las rutas en controladores. Un controlador agrupa endpoints relacionados bajo un prefijo común.
from litestar import Controller, Litestar, delete, get, patch, post, put
from litestar.exceptions import HTTPException
DB_FAKE = [
{"id": 1, "username": "admin", "fullname": "Admin"},
{"id": 2, "username": "user", "fullname": "Normal user"},
{"id": 3, "username": "guest", "fullname": "Guest"},
]
class UserController(Controller):
path = "/usuarios"
@get("/")
async def list_users(self) -> list[dict]:
return DB_FAKE
@get("/{id:int}")
async def get_user(self, id: int) -> dict:
for row in DB_FAKE:
if row["id"] == id:
return row
raise HTTPException(
detail="Usuario no encontrado",
status_code=404,
)
@post("/")
async def create_user(self, data: dict) -> dict:
for row in DB_FAKE:
if row["id"] == data["id"]:
raise HTTPException(
detail=f"Usuario con id={data['id']} ya existe",
status_code=409,
)
DB_FAKE.append(data)
return data
@put("/{id:int}")
async def replace_user(self, id: int, data: dict) -> dict:
for i, row in enumerate(DB_FAKE):
if row["id"] == id:
DB_FAKE[i] = {"id": id, **data}
return data
raise HTTPException(
detail="Usuario no encontrado",
status_code=404,
)
@patch("/{id:int}")
async def update_user(self, id: int, data: dict) -> dict:
for row in DB_FAKE:
if row["id"] == id:
for k, v in data.items():
row[k] = v
return row
raise HTTPException(
detail="Usuario no encontrado",
status_code=404,
)
@delete("/{id:int}")
async def delete_user(self, id: int) -> None:
for i, row in enumerate(DB_FAKE):
if row["id"] == id:
del DB_FAKE[i]
return
raise HTTPException(
detail="Usuario no encontrado",
status_code=404,
)
app = Litestar(route_handlers=[UserController])Ventajas de los Controllers:
- Agrupación lógica de endpoints relacionados
- Prefijo de ruta compartido (
/usuariosen este caso) - Manejo consistente de errores
- Middleware y guards por controlador
- Código más mantenible y escalable
Rutas en Controllers
Cuando usas
@get("/")dentro de un Controller conpath = "/usuarios", la ruta completa será/usuarios/. De forma similar,@get("/{id:int}")se convierte en/usuarios/{id:int}.
Routers
Los routers permiten organizar endpoints sin usar clases. Son útiles cuando necesitas agrupar rutas pero prefieres un enfoque funcional.
from litestar import Router, get, post
# Definir rutas
@get("/")
async def listar_productos() -> list[dict]:
return [{"id": 1, "nombre": "Laptop"}]
@post("/")
async def crear_producto(data: dict) -> dict:
return {"mensaje": "Producto creado", "producto": data}
# Agrupar en router
productos_router = Router(
path="/productos",
route_handlers=[listar_productos, crear_producto],
)
# Registrar router en la aplicación
app = Litestar([productos_router])Controllers vs Routers
- Controllers: Basados en clases, ideal para APIs orientadas a objetos y/o basados en modelos de bases de datos relacionales (por ejemplo, SQLAlchemy)
- Routers: Basados en funciones, más flexibles y funcionales
Ambos enfoques son válidos y puedes combinarlos en la misma aplicación.
Validación y manejo de errores
Litestar valida automáticamente los tipos de parámetros, query params y request body. Si la validación falla, retorna un error 400 con detalles del problema.
from dataclasses import dataclass
from litestar import post
@dataclass
class Usuario:
id: int
username: str
fullname: str
@post("/usuarios")
async def crear_usuario(data: Usuario) -> dict:
return {
"id": data.id,
"username": data.username,
"fullname": data.fullname,
}Petición inválida:
POST /usuarios
{
"id": "no_es_numero",
"username": "newuser",
"fullname": "New User"
}Respuesta automática de Litestar:
HTTP/1.1 400 Bad Request
{
"detail": "Validation failed for field 'id'",
"extra": {
"field": "id",
"expected": "int",
"received": "str"
}
}Excepciones HTTP personalizadas
Para manejar errores específicos (recursos no encontrados, conflictos, etc.), usa HTTPException de Litestar. Esta excepción permite lanzar respuestas HTTP con códigos de estado y mensajes personalizados.
from litestar import get, post
from litestar.exceptions import HTTPException
DB_FAKE = [
{"id": 1, "username": "admin", "fullname": "Admin"},
{"id": 2, "username": "user", "fullname": "Normal user"},
]
@get("/usuarios/{id:int}")
async def obtener_usuario(id: int) -> dict:
for row in DB_FAKE:
if row["id"] == id:
return row
# Si no se encuentra, lanzar excepción 404
raise HTTPException(
detail="Usuario no encontrado",
status_code=404,
)
@post("/usuarios")
async def crear_usuario(data: dict) -> dict:
# Verificar si el usuario ya existe
for row in DB_FAKE:
if row["id"] == data["id"]:
raise HTTPException(
detail=f"Usuario con id={data['id']} ya existe",
status_code=409, # Conflict
)
DB_FAKE.append(data)
return dataCódigos de estado HTTP comunes:
- 200 OK: Petición exitosa
- 201 Created: Recurso creado exitosamente
- 400 Bad Request: Datos inválidos o mal formados
- 404 Not Found: Recurso no encontrado
- 409 Conflict: Conflicto (ej: duplicados)
- 500 Internal Server Error: Error del servidor
Mensajes de error descriptivos
Proporciona mensajes de error claros y específicos en tus excepciones. Esto facilita la depuración y mejora la experiencia del desarrollador que consume tu API.