Self-hosted knowledge keeper for 3D printing projects
This guide shows how to add proper validation to your FastAPI endpoints to prevent the issues you’ve been experiencing.
You mentioned fixing issues reactively as they pop up. This usually means:
Add comprehensive validation at multiple layers:
from pydantic import BaseModel
class ProjectCreate(BaseModel):
name: str
description: str = None
Problems:
from pydantic import BaseModel, Field, validator
from typing import Optional
import re
import html
class ProjectCreate(BaseModel):
name: str = Field(
..., # Required
min_length=3,
max_length=100,
description="Project name"
)
description: Optional[str] = Field(
None,
max_length=1000,
description="Project description"
)
@validator('name')
def validate_name(cls, v):
# Trim whitespace
v = v.strip()
# Reject if empty after trimming
if not v:
raise ValueError('Name cannot be empty or whitespace only')
# Reject dangerous characters
if any(char in v for char in ['<', '>', '"', "'"]):
raise ValueError('Name contains invalid characters')
# Escape HTML to prevent XSS
v = html.escape(v)
return v
@validator('description')
def validate_description(cls, v):
if v:
# Escape HTML
v = html.escape(v.strip())
return v
from pydantic import BaseModel, Field, validator
import re
class UserCreate(BaseModel):
username: str = Field(
...,
min_length=3,
max_length=50,
description="Username (alphanumeric and underscore only)"
)
email: EmailStr # Built-in email validation
password: str = Field(..., min_length=8, max_length=100)
display_name: Optional[str] = Field(None, max_length=100)
@validator('username')
def validate_username(cls, v):
v = v.strip().lower()
# Only alphanumeric and underscore
if not re.match(r'^[a-z0-9_]+$', v):
raise ValueError(
'Username must contain only letters, numbers, and underscores'
)
# Reserved names
reserved = ['admin', 'root', 'system', 'api', 'www']
if v in reserved:
raise ValueError('This username is reserved')
return v
@validator('password')
def validate_password(cls, v):
# Check minimum complexity
if len(v) < 8:
raise ValueError('Password must be at least 8 characters')
# Optionally check for complexity
if not any(c.isupper() for c in v):
raise ValueError('Password must contain at least one uppercase letter')
if not any(c.isdigit() for c in v):
raise ValueError('Password must contain at least one number')
return v
@validator('display_name')
def validate_display_name(cls, v):
if v:
import html
v = html.escape(v.strip())
if not v:
return None
return v
from fastapi import UploadFile, HTTPException
from typing import List
ALLOWED_EXTENSIONS = {'.stl', '.obj', '.3mf', '.gcode'}
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
async def validate_file_upload(file: UploadFile) -> UploadFile:
"""Validate uploaded file."""
# Check filename
if not file.filename:
raise HTTPException(400, "No filename provided")
# Sanitize filename (prevent path traversal)
safe_filename = os.path.basename(file.filename)
if safe_filename != file.filename:
raise HTTPException(400, "Invalid filename")
# Check extension
ext = os.path.splitext(safe_filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
400,
f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Check file size (read chunk by chunk)
size = 0
chunk_size = 8192
content = bytearray()
while chunk := await file.read(chunk_size):
size += len(chunk)
if size > MAX_FILE_SIZE:
raise HTTPException(400, f"File too large (max {MAX_FILE_SIZE} bytes)")
content.extend(chunk)
# Reset file pointer
file.file.seek(0)
# Validate content type
if file.content_type not in ['application/octet-stream', 'model/stl', 'model/obj']:
raise HTTPException(400, f"Invalid content type: {file.content_type}")
return file
from fastapi import Query
from typing import Optional
@router.get("/projects")
async def list_projects(
page: int = Query(1, ge=1, le=1000, description="Page number"),
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, max_length=100),
sort_by: str = Query("created_at", regex="^(name|created_at|updated_at)$"),
order: str = Query("desc", regex="^(asc|desc)$"),
):
"""List projects with validated parameters."""
# Sanitize search query
if search:
import html
search = html.escape(search.strip())
# ... rest of implementation
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
@router.post("/projects", status_code=status.HTTP_201_CREATED)
async def create_project(
project_data: ProjectCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new project with validation."""
# Check for duplicate name (per user)
existing = db.query(Project).filter(
Project.user_id == current_user.id,
Project.name == project_data.name
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Project '{project_data.name}' already exists"
)
try:
project = Project(
name=project_data.name,
description=project_data.description,
user_id=current_user.id
)
db.add(project)
db.commit()
db.refresh(project)
return project
except IntegrityError as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Database constraint violation"
)
from fastapi import Request, HTTPException
from datetime import datetime, timedelta
from collections import defaultdict
# Simple in-memory rate limiter (use Redis in production)
rate_limit_data = defaultdict(list)
def rate_limit(max_requests: int = 10, window_seconds: int = 60):
"""Decorator for rate limiting."""
def decorator(func):
async def wrapper(request: Request, *args, **kwargs):
# Get client identifier
client_id = request.client.host
now = datetime.utcnow()
# Clean old requests
rate_limit_data[client_id] = [
req_time for req_time in rate_limit_data[client_id]
if now - req_time < timedelta(seconds=window_seconds)
]
# Check limit
if len(rate_limit_data[client_id]) >= max_requests:
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded. Try again in {window_seconds} seconds."
)
# Record request
rate_limit_data[client_id].append(now)
return await func(request, *args, **kwargs)
return wrapper
return decorator
# Usage
@router.post("/auth/login")
@rate_limit(max_requests=5, window_seconds=300) # 5 attempts per 5 minutes
async def login(request: Request, credentials: LoginCredentials):
# ... login logic
pass
from fastapi import HTTPException
from pydantic import BaseModel, ValidationError
from typing import List, Dict, Any
class ErrorDetail(BaseModel):
field: str
message: str
class ErrorResponse(BaseModel):
error: str
details: List[ErrorDetail] = []
def format_validation_error(exc: ValidationError) -> ErrorResponse:
"""Convert Pydantic validation error to consistent format."""
details = []
for error in exc.errors():
field = '.'.join(str(loc) for loc in error['loc'])
details.append(ErrorDetail(
field=field,
message=error['msg']
))
return ErrorResponse(
error="Validation failed",
details=details
)
# Use in exception handler
@app.exception_handler(ValidationError)
async def validation_exception_handler(request, exc):
return JSONResponse(
status_code=400,
content=format_validation_error(exc).dict()
)
@router.post("/validate/project-name")
async def validate_project_name(
name: str = Form(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Validate project name and return error if invalid."""
# Length check
if len(name) < 3:
return HTMLResponse("<span class='error'>Name too short (min 3 chars)</span>")
if len(name) > 100:
return HTMLResponse("<span class='error'>Name too long (max 100 chars)</span>")
# Check duplicates
existing = db.query(Project).filter(
Project.user_id == current_user.id,
Project.name == name
).first()
if existing:
return HTMLResponse("<span class='error'>Name already exists</span>")
return HTMLResponse("<span class='success'>✓</span>")
<form hx-post="/api/v1/projects" hx-target="#result">
<input
type="text"
name="name"
hx-post="/api/v1/projects/validate/project-name"
hx-trigger="blur"
hx-target="#name-error"
/>
<span id="name-error"></span>
<button type="submit">Create Project</button>
</form>
<div id="result"></div>
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
# Security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = "default-src 'self'"
return response
For every user input field, ensure:
import html
# Before returning user-generated content
safe_text = html.escape(user_input)
name: str = Field(..., min_length=1, max_length=100)
from pydantic import EmailStr, HttpUrl, constr
email: EmailStr # Validates email format
website: HttpUrl # Validates URL format
username: constr(regex=r'^[a-z0-9_]+$') # Regex validation
# src/backend/core/validation.py
def validate_username(username: str) -> str:
"""Centralized username validation."""
# ... validation logic
return username
# Use everywhere
validated_username = validate_username(input_username)
def test_project_name_too_short(auth_client):
response = auth_client.post("/api/v1/projects", json={
"name": "ab" # Too short
})
assert response.status_code == 400
assert "too short" in response.json()["detail"].lower()
def test_xss_in_project_name(auth_client):
response = auth_client.post("/api/v1/projects", json={
"name": "<script>alert('xss')</script>"
})
# Should either reject or sanitize
if response.status_code == 201:
assert "<script>" not in response.json()["name"]
The key to preventing reactive bug fixing is comprehensive, layered validation:
Start with the most critical endpoints (auth, user input) and work your way through the application systematically.