Los DTOs (Data Transfer Objects) controlan qué datos se reciben del cliente y qué datos se envían en las respuestas. En Litestar, los DTOs automatizan la validación, transformación y filtrado de datos, mejorando la seguridad de las APIs REST.

¿Por qué usar DTOs?

Sin DTOs, es fácil exponer información sensible como contraseñas, o permitir que el cliente modifique campos de solo lectura como IDs o timestamps.

DTOs vs Dataclasses

Las dataclasses definen la estructura de tus datos, los DTOs controlan qué partes son accesibles para el cliente. Puedes tener un modelo completo internamente, pero exponer solo lo necesario.

Exclusión de campos

Usa DTOConfig con exclude para omitir campos específicos. Esto es útil para excluir información sensible o campos generados por el servidor.

from dataclasses import dataclass
from datetime import datetime
from litestar import post, get
from litestar.dto import DataclassDTO, DTOConfig
 
 
@dataclass
class Usuario:
    id: int
    username: str
    fullname: str
    password: str
    created_at: datetime
    is_active: bool = True
 
 
class UsuarioCreateDTO(DataclassDTO[Usuario]):
    """Excluye campos generados por el servidor."""
 
    config = DTOConfig(exclude={"id", "created_at"})
 
 
class UsuarioReturnDTO(DataclassDTO[Usuario]):
    """Excluye información sensible."""
 
    config = DTOConfig(exclude={"password"})
 
 
@post("/usuarios", dto=UsuarioCreateDTO, return_dto=UsuarioReturnDTO)
async def crear_usuario(data: Usuario) -> Usuario:
    return data
 
 
@get("/usuarios/{user_id:int}", return_dto=UsuarioReturnDTO)
async def obtener_usuario(user_id: int) -> Usuario:
    return Usuario(
        id=user_id,
        username="admin",
        fullname="Admin User",
        password="hashed_password",  # Filtrado automáticamente
        created_at=datetime.now(),
        is_active=True,
    )

dto vs return_dto

  • dto: Controla qué datos acepta el servidor
  • return_dto: Controla qué datos expone el servidor

DTOData: Manipulación de datos

DTOData permite acceder a los datos validados y modificarlos antes de crear la instancia. Útil para agregar valores generados por el servidor.

from random import randint
from litestar import post
from litestar.dto import DTOData
 
 
@post("/usuarios", dto=UsuarioCreateDTO)
async def crear_usuario(data: DTOData[Usuario]) -> Usuario:
    # Crear instancia con valores generados por el servidor
    usuario = data.create_instance(id=randint(1, 10000), created_at=datetime.now())
    return usuario

Cuándo usar DTOData

Usa DTOData[T] cuando necesites generar IDs, timestamps, hashear contraseñas, o cualquier modificación antes de crear el objeto.

Actualizaciones parciales

Para operaciones PATCH, usa partial=True para permitir actualizaciones parciales.

from litestar import patch
from litestar.dto import DTOData
 
 
class UsuarioUpdateDTO(DataclassDTO[Usuario]):
    config = DTOConfig(
        partial=True,  # Permite omitir campos
        exclude={"id", "created_at", "password"},
    )
 
 
@patch("/usuarios/{user_id:int}", dto=UsuarioUpdateDTO)
async def actualizar_usuario(user_id: int, data: DTOData[Usuario]) -> Usuario:
    usuario = DB_FAKE[user_id]
    data.update_instance(usuario)  # Actualiza solo campos proporcionados
    return usuario

Renombrado de campos

Puedes renombrar campos para seguir diferentes convenciones de nomenclatura (ej: snake_case en Python, camelCase en APIs).

class UsuarioDTO(DataclassDTO[Usuario]):
    config = DTOConfig(
        # Renombrado explícito
        rename_fields={"fullname": "full_name"},
        # O usar estrategia para todos los campos
        rename_strategy="camel",  # is_active -> isActive
    )

Estrategias: "camel" (isActive), "pascal" (IsActive), "upper" (IS_ACTIVE)

DTOs en Controllers

Define DTOs a nivel de controlador para aplicarlos a todos sus handlers.

from random import randint
from litestar import Controller, get, post
 
 
class UserController(Controller):
    path = "/usuarios"
    return_dto = UsuarioReturnDTO  # Aplica a todos los métodos
 
    @get("/")
    async def list_users(self) -> list[Usuario]:
        return list(DB_FAKE.values())
 
    @post("/", dto=UsuarioCreateDTO)
    async def create_user(self, data: DTOData[Usuario]) -> Usuario:
        usuario = data.create_instance(id=randint(1, 10000), created_at=datetime.now())
        DB_FAKE[usuario.id] = usuario
        return usuario

Esto evita repetir el mismo return_dto en cada método y mantiene la consistencia.

Campos anidados y profundidad

Los DTOs manejan objetos anidados. Usa notación de punto para excluir campos específicos:

class UsuarioDTO(DataclassDTO[Usuario]):
    config = DTOConfig(
        exclude={"direccion.codigo_postal"},  # Campo anidado
        max_nested_depth=2,  # Limita niveles de anidamiento
    )

max_nested_depth es útil para prevenir referencias circulares y limitar el tamaño de respuestas.

Patrón recomendado

Para cada modelo, crea tres DTOs:

class UsuarioReadDTO(DataclassDTO[Usuario]):
    """Respuestas - excluye información sensible"""
 
    config = DTOConfig(exclude={"password"})
 
 
class UsuarioCreateDTO(DataclassDTO[Usuario]):
    """POST - excluye campos generados por servidor"""
 
    config = DTOConfig(exclude={"id", "created_at"})
 
 
class UsuarioUpdateDTO(DataclassDTO[Usuario]):
    """PATCH - parcial, excluye inmutables"""
 
    config = DTOConfig(partial=True, exclude={"id", "created_at", "password"})

Este patrón garantiza control adecuado en cada operación y mejora la seguridad de tu API.