Request schemas define the structure and documentation for incoming data in your API. sillo uses Pydantic models to provide comprehensive OpenAPI documentation and type definitions for all request bodies.
Request Schemas in sillo
Section titled “Request Schemas in sillo”Request schemas define the structure and documentation for incoming data in your API. sillo uses Pydantic models to provide comprehensive OpenAPI documentation and type definitions for all request bodies.
Why Use Request Schemas?
Section titled “Why Use Request Schemas?”Request schemas provide multiple benefits for API development:
- Clear Documentation: Schemas are automatically reflected in OpenAPI docs
- Type Safety: Full IDE support with autocompletion and type checking
- API Contracts: Define clear expectations for API consumers
- Code Generation: Enable client SDK generation with proper types
- Developer Experience: Interactive documentation with examples
Basic Request Models
Section titled “Basic Request Models”Define request schemas using Pydantic’s BaseModel. These work with POST, PUT, PATCH, and any endpoint expecting a request body:
from sillo import silloAppfrom pydantic import BaseModel, EmailStr, Fieldfrom typing import Optionalfrom datetime import datetime
class UserCreateRequest(BaseModel): username: str = Field(..., min_length=3, max_length=50, description="Unique username") email: EmailStr = Field(..., description="Valid email address") password: str = Field(..., min_length=8, description="Password (min 8 characters)") full_name: Optional[str] = Field(None, description="User's full name") is_active: bool = Field(True, description="Account activation status")
app = silloApp()
@app.post( "/users", request_model=UserCreateRequest, summary="Create new user account", description="Creates a new user account (manual validation required)")async def create_user(request, response): # Get raw request data user_data = await request.json
# Manual validation (if desired) try: validated_data = UserCreateRequest(**user_data) username = validated_data.username email = validated_data.email password = validated_data.password except Exception as e: return response.json({"error": "Invalid data", "details": str(e)}, status=400)
# Create user logic here return response.json({"id": 123, "username": username}, status=201)Advanced Request Schemas
Section titled “Advanced Request Schemas”Nested Models for Complex Data
Section titled “Nested Models for Complex Data”Handle complex, hierarchical data structures:
class Address(BaseModel): street: str = Field(..., description="Street address") city: str = Field(..., description="City name") state: str = Field(..., min_length=2, max_length=2, description="State code") zip_code: str = Field(..., regex=r'^\d{5}(-\d{4})?$', description="ZIP code") country: str = Field("US", description="Country code")
class ContactInfo(BaseModel): phone: Optional[str] = Field(None, regex=r'^\+?1?\d{9,15}$', description="Phone number") emergency_contact: Optional[str] = Field(None, description="Emergency contact name")
class UserProfileRequest(BaseModel): personal_info: dict = Field(..., description="Personal information") address: Address = Field(..., description="User's address") contact: ContactInfo = Field(..., description="Contact information") preferences: dict = Field(default_factory=dict, description="User preferences")
@app.put( "/users/{user_id}/profile", request_model=UserProfileRequest, summary="Update user profile", description="Updates user profile with nested address and contact information")async def update_profile(request, response, user_id: int): # Get raw request data profile_data = await request.json
# Manual validation (if desired) try: validated_data = UserProfileRequest(**profile_data) street = validated_data.address.street phone = validated_data.contact.phone except Exception as e: return response.json({"error": "Invalid profile data"}, status=400)
return response.json({"updated": True})Custom Business Logic Models
Section titled “Custom Business Logic Models”Define models with business logic for manual validation:
from pydantic import validator, root_validatorfrom typing import List
class OrderRequest(BaseModel): items: List[dict] = Field(..., min_items=1, description="Order items") shipping_method: str = Field(..., description="Shipping method") discount_code: Optional[str] = Field(None, description="Discount code") total_amount: float = Field(..., gt=0, description="Total order amount")
@validator('shipping_method') def validate_shipping(cls, v): allowed_methods = ['standard', 'express', 'overnight'] if v not in allowed_methods: raise ValueError(f'Shipping method must be one of: {allowed_methods}') return v
@validator('discount_code') def validate_discount(cls, v): if v and not v.startswith('SAVE'): raise ValueError('Discount codes must start with "SAVE"') return v
@root_validator def validate_order_total(cls, values): items = values.get('items', []) total = values.get('total_amount', 0)
# Business logic validation calculated_total = sum(item.get('price', 0) * item.get('quantity', 0) for item in items) if abs(calculated_total - total) > 0.01: raise ValueError('Total amount does not match item prices')
return values
@app.post( "/orders", request_model=OrderRequest, responses={ 201: {"description": "Order created successfully"}, 400: {"description": "Validation errors"}, 422: {"description": "Business rule violations"} })async def create_order(request, response): # Get raw request data order_data = await request.json
# Manual validation using the Pydantic model try: validated_order = OrderRequest(**order_data) # Process validated order return response.json({"order_id": 12345}, status=201) except Exception as e: return response.json({"error": "Order validation failed", "details": str(e)}, status=400)Content Type Support
Section titled “Content Type Support”sillo supports multiple content types for request bodies:
JSON Requests (Default)
Section titled “JSON Requests (Default)”@app.post( "/api/data", request_model=DataModel, request_content_type="application/json")async def handle_json(request, response): data = await request.json return response.json({"received": True})Form Data Requests
Section titled “Form Data Requests”class ContactForm(BaseModel): name: str email: EmailStr message: str subscribe: bool = False
@app.post( "/contact", request_model=ContactForm, request_content_type="application/x-www-form-urlencoded")async def handle_contact_form(request, response): form_data = await request.form # Process form submission (manual validation if needed) return response.json({"submitted": True})File Upload with Multipart
Section titled “File Upload with Multipart”from sillo.objects import UploadedFile
class FileUploadRequest(BaseModel): title: str = Field(..., description="File title") description: Optional[str] = Field(None, description="File description") category: str = Field(..., description="File category") file: UploadedFile = Field(..., description="File to upload")
@app.post( "/files/upload", request_model=FileUploadRequest, request_content_type="multipart/form-data")async def upload_file(request, response): # Get form data and files form_data = await request.form files = request.files
# Access file and metadata manually file = files.get('file') title = form_data.get('title')
if not file or not title: return response.json({"error": "File and title required"}, status=400)
# Process file upload file_content = await file.read()
return response.json({ "filename": file.filename, "size": len(file_content), "title": title })Documentation Enhancement
Section titled “Documentation Enhancement”Field Documentation and Examples
Section titled “Field Documentation and Examples”Provide rich documentation for better developer experience:
class ProductRequest(BaseModel): name: str = Field( ..., min_length=1, max_length=100, description="Product name", example="Premium Wireless Headphones" ) price: float = Field( ..., gt=0, le=10000, description="Product price in USD", example=299.99 ) category: str = Field( ..., description="Product category", example="Electronics" ) tags: List[str] = Field( default_factory=list, description="Product tags for search and filtering", example=["wireless", "audio", "premium"] ) specifications: dict = Field( default_factory=dict, description="Technical specifications", example={ "battery_life": "30 hours", "connectivity": "Bluetooth 5.0", "weight": "250g" } )
class Config: schema_extra = { "example": { "name": "Premium Wireless Headphones", "price": 299.99, "category": "Electronics", "tags": ["wireless", "audio", "premium"], "specifications": { "battery_life": "30 hours", "connectivity": "Bluetooth 5.0", "weight": "250g" } } }Conditional Business Logic
Section titled “Conditional Business Logic”Handle different business rules based on context:
class UserUpdateRequest(BaseModel): email: Optional[EmailStr] = None password: Optional[str] = Field(None, min_length=8) current_password: Optional[str] = None
@root_validator def validate_password_change(cls, values): password = values.get('password') current_password = values.get('current_password')
if password and not current_password: raise ValueError('Current password required when changing password')
return values
@app.patch( "/users/{user_id}", request_model=UserUpdateRequest, summary="Update user information", description="Partial update of user data with conditional business rules")async def update_user(request, response, user_id: int): # Get raw request data update_data = await request.json
# Manual validation using the model (if desired) try: validated_data = UserUpdateRequest(**update_data) # Handle partial updates with validated data return response.json({"updated": True}) except Exception as e: return response.json({"error": "Update validation failed"}, status=400)Best Practices
Section titled “Best Practices”Model Organization
Section titled “Model Organization”class UserBase(BaseModel): """Base user fields shared across requests""" username: str = Field(..., min_length=3, max_length=50) email: EmailStr
class UserCreateRequest(UserBase): """User creation with password""" password: str = Field(..., min_length=8) confirm_password: str
@validator('confirm_password') def passwords_match(cls, v, values): if 'password' in values and v != values['password']: raise ValueError('Passwords do not match') return v
class UserUpdateRequest(BaseModel): """Partial user updates""" username: Optional[str] = Field(None, min_length=3, max_length=50) email: Optional[EmailStr] = NoneManual Error Handling
Section titled “Manual Error Handling”from pydantic import ValidationError
@app.post("/users", request_model=UserCreateRequest)async def create_user(request, response): try: # Get raw request data user_data = await request.json
# Manual validation using Pydantic model validated_user = UserCreateRequest(**user_data)
# Process user creation with validated data return response.json({"created": True}, status=201) except ValidationError as e: return response.json({ "error": "Validation failed", "details": e.errors() }, status=400) except Exception as e: return response.json({ "error": "Invalid request data" }, status=400)Testing Request Schemas
Section titled “Testing Request Schemas”def test_user_create_schema(): # Test valid data structure valid_data = { "username": "testuser", "email": "test@example.com", "password": "securepass123" } user = UserCreateRequest(**valid_data) assert user.username == "testuser"
# Test schema validation (for manual validation) with pytest.raises(ValidationError): UserCreateRequest(username="ab") # Too shortRequest schemas are fundamental to building well-documented APIs. They provide the foundation for clear API contracts, comprehensive documentation, and type safety that make your API easier to use and maintain. Remember that in sillo, these schemas are primarily for documentation - you must implement your own validation logic if you want to validate incoming data.
The schema is a contract, not a mirror of your tables
Section titled “The schema is a contract, not a mirror of your tables”The most common mistake in documented request bodies is deriving the schema from the database model. It produces a document that leaks your storage layout, includes fields clients must never set, and breaks every time you refactor a table.
Model the request as its own thing: only the fields a client may provide,
named the way an integrator would expect, with constraints reflecting
business rules rather than column types. A VARCHAR(255) becomes
max_length=255 only if 255 is genuinely the rule — otherwise document
the real limit.
The published schema then describes what you accept, and refactoring storage is invisible to clients, which is the whole point of having a contract.
Documenting what happens to unknown fields
Section titled “Documenting what happens to unknown fields”Every request schema makes an implicit promise about extra keys, and clients depend on it whether or not you documented it.
Ignoring unknown fields is forgiving and lets a client send a superset
without breaking. Rejecting them catches typos — a client sending
emial gets a clear error instead of silently failing to set an email.
Both are defensible. What is not defensible is leaving it undecided, because the behaviour differs per model and integrators discover it by accident. Pick one, apply it consistently, and say which in the schema description.
The forgiving option has one real hazard: a renamed field means clients keep sending the old name, it is silently ignored, and the value they think they set is the default. If you ignore extras, a rename needs a transition period where both names are accepted.
Examples that reflect real requests
Section titled “Examples that reflect real requests”A schema with no examples is a schema people get wrong on the first try. A schema with unrealistic examples is worse, because they copy them.
Good request examples share three properties. They validate — an example that fails your own model is a trap, and one worth checking in CI. They are complete enough to actually work, including required fields a reader might not notice. And they show the common case, not the exotic one; the example for a payment endpoint should be a simple card payment, not the three-way split with a partial refund.
Where a body has variants — a discriminated union, an optional block that
changes meaning — give one whole-model example per variant. Field-level
examples cannot express “when type is card, card_token is
required”, and that relationship is exactly what integrators get wrong.
Content types beyond JSON
Section titled “Content types beyond JSON”Most APIs accept JSON and nothing else, and the schema should say so rather than leaving it implied. When you do accept more than one — JSON and form-encoded, JSON and multipart for uploads — document each content type separately, because their schemas genuinely differ. A file cannot be expressed in JSON, and a nested object cannot be expressed in a form without a convention.
If you accept only one, being explicit still helps: a client sending
text/plain gets a clear 415 and a schema that told them why, rather
than a parse error they have to interpret.
Reusing schemas across endpoints
Section titled “Reusing schemas across endpoints”A model referenced by several routes is emitted once into
components.schemas and referenced by $ref, which keeps the document
small and — more importantly — tells clients that the two endpoints
genuinely share a type. Generated clients then produce one class instead
of two identical ones with different names.
That is a reason to define shared shapes deliberately rather than
declaring a fresh model per route. An Address used by three endpoints
should be one Address.
The inverse also matters: two shapes that happen to match today but are allowed to diverge should be two models. Sharing them means a change to one silently changes the other, and the schema will not warn you because it looks correct either way.
Keeping request and response schemas distinct
Section titled “Keeping request and response schemas distinct”A model used for both input and output looks efficient and causes two recurring problems.
Input needs fields the client sets; output needs fields the server
computes. id, created_at, and any derived total belong in the
response and must not be accepted from a request. One shared model either
accepts fields it should reject or omits fields it should return.
The requiredness also differs. A POST body requires name; the
response always has name but a PATCH body must not require it. One
model cannot express three different requiredness rules.
Two or three small models per resource — OrderCreate, OrderUpdate,
OrderOut — read better in the generated document than one model with
every field optional, and they let a generated client tell the developer
which fields are actually needed.