DTOs & Validation¶
Cello provides a Data Transfer Object (DTO) system for controlling which fields are exposed in API inputs and outputs. Combined with optional Pydantic integration, you get full request validation with automatic error responses.
Overview¶
DTOs solve a common problem: your internal data model has fields that should not be exposed to clients (like password hashes), and input data needs validation before reaching your business logic.
Request Body Validation (body=)¶
The fastest way to validate an incoming JSON body is the body= argument on any route decorator. Cello parses the body, validates it against your model, and — on failure — returns 400 with a {"detail": [...]} payload before your handler runs. On success the validated instance is injected into the handler.
from cello import App
from pydantic import BaseModel
app = App()
class UserDTO(BaseModel):
name: str
age: int
@app.post("/users", body=UserDTO)
def create_user(request, user): # `user` is a validated UserDTO
return {"id": 1, "name": user.name, "age": user.age}
- Bad input →
400 {"detail": [...]}automatically, no boilerplate. - Works with Pydantic models, dataclasses, and plain classes constructible from the decoded JSON (
Model(**json)). - The instance is injected into the parameter annotated with the model, or the first non-
requestparameter otherwise. - Available on
get/post/put/delete/patchfor bothAppandBlueprint.
Explicit 400 vs. type-hint 422
body= is the explicit form and returns 400. Cello also supports implicit validation: annotate a handler parameter with a Pydantic model and it is validated automatically, returning 422 on failure. Use body= when you want the clearer, decorator-declared contract.
Client Request DTO Layer Internal Model
{ Filter & Validate {
"username": "alice", --> - Remove read-only fields "id": 1,
"email": "a@b.com", - Validate types "username": "alice",
"password": "secret" - Check constraints "email": "a@b.com",
} - Rename fields "password_hash": "...",
"created_at": "...",
}
DTOConfig¶
The DTOConfig class controls field filtering, renaming, and access rules:
from cello._cello import DTOConfig
# Create DTO for user creation (exclude auto-generated fields)
create_config = (
DTOConfig()
.exclude(["id", "created_at"])
.write_only(["password"])
)
# Create DTO for user response (exclude sensitive fields)
response_config = (
DTOConfig()
.exclude(["password"])
.read_only(["id", "created_at"])
)
# Create DTO for user listing (include only specific fields)
list_config = (
DTOConfig()
.include(["id", "username", "is_active"])
)
Configuration Methods¶
| Method | Description |
|---|---|
.include(fields) | Only include these fields (whitelist) |
.exclude(fields) | Exclude these fields (blacklist) |
.read_only(fields) | Fields that cannot be set in input (auto-generated) |
.write_only(fields) | Fields excluded from output (e.g., passwords) |
.rename(field, alias) | Rename a field in the output (e.g., created_at -> createdAt) |
.max_depth(n) | Maximum nesting depth for nested DTOs |
.partial(bool) | Allow partial updates (missing fields are ignored) |
Field Filtering¶
Include List (Whitelist)¶
When include is set, only the listed fields appear in the output:
list_config = DTOConfig().include(["id", "username", "is_active"])
# Input: {"id": 1, "username": "alice", "email": "a@b.com", "password": "..."}
# Output: {"id": 1, "username": "alice", "is_active": true}
Exclude List (Blacklist)¶
When exclude is set, the listed fields are removed:
response_config = DTOConfig().exclude(["password", "internal_notes"])
# Input: {"id": 1, "username": "alice", "password": "hash", "internal_notes": "..."}
# Output: {"id": 1, "username": "alice"}
Read-Only Fields¶
Read-only fields are rejected if present in input data:
config = DTOConfig().read_only(["id", "created_at"])
# POST {"id": 5, "name": "alice"}
# -> Error: Field 'id' is read-only
Write-Only Fields¶
Write-only fields are accepted in input but stripped from output:
config = DTOConfig().write_only(["password"])
# Input: {"username": "alice", "password": "secret"} -> accepted
# Output: {"username": "alice"} -> password removed
Field Renaming¶
Map internal field names to API-friendly names:
config = (
DTOConfig()
.rename("created_at", "createdAt")
.rename("is_active", "isActive")
.rename("user_name", "userName")
)
# Internal: {"user_name": "alice", "created_at": "2026-01-01", "is_active": true}
# Output: {"userName": "alice", "createdAt": "2026-01-01", "isActive": true}
Pydantic Validation¶
Cello integrates with Pydantic for request body validation. When you type-hint a handler parameter with a Pydantic BaseModel, Cello automatically parses and validates the JSON body.
Setup¶
Basic Validation¶
The validation wrapper supports both sync and async handlers. When applied to an async def handler, the wrapper correctly awaits the coroutine after validation -- there is no risk of returning an unawaited coroutine.
from pydantic import BaseModel, EmailStr, Field
from cello import App, Response
app = App()
class CreateUser(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: str
password: str = Field(min_length=8)
age: int = Field(ge=0, le=150)
# Sync handler with validation
@app.post("/users")
def create_user(request, user: CreateUser):
# 'user' is already validated
return {
"username": user.username,
"email": user.email,
"age": user.age,
}
# Async handler with validation -- works identically
@app.post("/users/async")
async def create_user_async(request, user: CreateUser):
# 'user' is already validated; the handler is properly awaited
result = await db.insert(user.model_dump())
return {
"id": result["id"],
"username": user.username,
"email": user.email,
}
If validation fails, Cello returns a 422 Unprocessable Entity response automatically:
{
"detail": [
{
"loc": ["password"],
"msg": "String should have at least 8 characters",
"type": "string_too_short"
}
]
}
Type Coercion¶
Pydantic handles type coercion automatically:
class SearchParams(BaseModel):
query: str
limit: int = 10 # String "10" is coerced to int
offset: int = 0
active: bool = True # String "true" is coerced to bool
Validation Errors¶
Automatic Error Responses¶
When Pydantic validation fails, Cello responds with structured error details:
class Product(BaseModel):
name: str = Field(min_length=1)
price: float = Field(gt=0)
quantity: int = Field(ge=0)
@app.post("/products")
def create_product(request, product: Product):
return {"created": product.name}
Request with invalid data:
Response:
{
"detail": [
{"loc": ["name"], "msg": "String should have at least 1 character", "type": "string_too_short"},
{"loc": ["price"], "msg": "Input should be greater than 0", "type": "greater_than"},
{"loc": ["quantity"], "msg": "Input should be greater than or equal to 0", "type": "greater_than_equal"}
]
}
Custom Validators¶
Use Pydantic's field_validator for custom validation logic:
from pydantic import BaseModel, field_validator
class CreateUser(BaseModel):
username: str
email: str
password: str
@field_validator("email")
@classmethod
def validate_email(cls, v):
if "@" not in v:
raise ValueError("Invalid email format")
return v.lower()
@field_validator("password")
@classmethod
def validate_password(cls, v):
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("Password must contain an uppercase letter")
return v
Nested DTOs¶
DTOs can contain nested models for complex data structures:
from pydantic import BaseModel
from typing import List, Optional
class Address(BaseModel):
street: str
city: str
country: str
zip_code: str
class CreateUser(BaseModel):
username: str
email: str
address: Optional[Address] = None
tags: List[str] = []
@app.post("/users")
def create_user(request, user: CreateUser):
return {
"username": user.username,
"city": user.address.city if user.address else None,
}
The DTOConfig.max_depth() setting limits how deeply nested DTOs are processed, preventing excessive nesting:
DTO Factory Patterns¶
Use factory methods to create reusable configurations for different operations:
class UserDTOs:
"""Centralized DTO configurations for the User model."""
@staticmethod
def for_create():
return DTOConfig().exclude(["id", "created_at"]).write_only(["password"])
@staticmethod
def for_update():
return DTOConfig().exclude(["id", "created_at"]).partial(True)
@staticmethod
def for_response():
return DTOConfig().exclude(["password"])
@staticmethod
def for_list():
return DTOConfig().include(["id", "username", "is_active"])
Complete Example¶
This example shows validation with both sync and async handlers. The validation wrapper detects handler type automatically.
from cello import App, Response
from pydantic import BaseModel, Field, field_validator
from typing import Optional
app = App()
class CreateUser(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: str
password: str = Field(min_length=8)
bio: Optional[str] = None
@field_validator("email")
@classmethod
def validate_email(cls, v):
if "@" not in v:
raise ValueError("Invalid email address")
return v.lower()
class UpdateUser(BaseModel):
username: Optional[str] = Field(None, min_length=3, max_length=50)
email: Optional[str] = None
bio: Optional[str] = None
# Async handler -- validation runs before await
@app.post("/users")
async def create_user(request, user: CreateUser):
# user is validated -- password present, email valid
new_user = await db.create(user.model_dump())
# Exclude password from response
return {k: v for k, v in new_user.items() if k != "password"}
# Async handler -- partial update with validation
@app.patch("/users/{id}")
async def update_user(request, updates: UpdateUser):
user_id = request.params["id"]
# Only non-None fields are applied
changes = updates.model_dump(exclude_none=True)
await db.update(user_id, changes)
return {"updated": True, "fields": list(changes.keys())}
if __name__ == "__main__":
app.run()
Next Steps¶
- File Uploads - Validate uploaded file metadata
- Dependency Injection - Inject validated services
- Error Handling - Customize validation error responses