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]
            return

Funciones 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=true

Pará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_usuario

Ventajas 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 (/usuarios en 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 con path = "/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 data

Có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.