Initial commit
This commit is contained in:
332
skills/api-design-standards/examples/fastapi-crud.md
Normal file
332
skills/api-design-standards/examples/fastapi-crud.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# FastAPI CRUD Endpoints
|
||||
|
||||
**Complete CRUD endpoint examples with repository pattern and tenant isolation.**
|
||||
|
||||
## Complete User CRUD
|
||||
|
||||
```python
|
||||
# app/api/routes/users.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.schemas.user import UserCreate, UserRead, UserUpdate, PaginatedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[UserRead], status_code=status.HTTP_200_OK)
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
is_active: Optional[bool] = None,
|
||||
email_contains: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
"""
|
||||
List all users in the current tenant with pagination and filtering.
|
||||
|
||||
- **skip**: Number of records to skip (pagination offset)
|
||||
- **limit**: Maximum number of records to return (max 100)
|
||||
- **is_active**: Filter by active status (optional)
|
||||
- **email_contains**: Filter by email substring (optional)
|
||||
- **Returns**: Paginated list of users with public fields only
|
||||
"""
|
||||
repository = UserRepository(db, tenant_id=current_user.tenant_id)
|
||||
|
||||
# Build filter criteria
|
||||
filters = {}
|
||||
if is_active is not None:
|
||||
filters["is_active"] = is_active
|
||||
if email_contains:
|
||||
filters["email_contains"] = email_contains
|
||||
|
||||
users = await repository.list(filters=filters, skip=skip, limit=limit)
|
||||
total = await repository.count(filters=filters)
|
||||
|
||||
return PaginatedResponse(
|
||||
items=users,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
has_more=(skip + limit) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead, status_code=status.HTTP_200_OK)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserRead:
|
||||
"""Get a single user by ID (tenant-isolated)."""
|
||||
repository = UserRepository(db, tenant_id=current_user.tenant_id)
|
||||
user = await repository.get_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserRead:
|
||||
"""
|
||||
Create a new user in the current tenant.
|
||||
|
||||
- **email**: Valid email address (unique per tenant)
|
||||
- **full_name**: User's full name (1-255 characters)
|
||||
- **password**: At least 8 characters
|
||||
- **Returns**: Created user with ID, timestamps, and public fields
|
||||
|
||||
**Errors**:
|
||||
- 409 Conflict: Email already exists in tenant
|
||||
- 422 Validation Error: Invalid email or weak password
|
||||
"""
|
||||
repository = UserRepository(db, tenant_id=current_user.tenant_id)
|
||||
|
||||
try:
|
||||
user = await repository.create(user_data)
|
||||
return user
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"User with email {user_data.email} already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserRead, status_code=status.HTTP_200_OK)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserRead:
|
||||
"""
|
||||
Update an existing user (tenant-isolated).
|
||||
|
||||
All fields are optional - only provided fields will be updated.
|
||||
"""
|
||||
repository = UserRepository(db, tenant_id=current_user.tenant_id)
|
||||
user = await repository.update(user_id, user_data)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserRead, status_code=status.HTTP_200_OK)
|
||||
async def partial_update_user(
|
||||
user_id: str,
|
||||
user_data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserRead:
|
||||
"""
|
||||
Partially update an existing user (tenant-isolated).
|
||||
|
||||
Same as PUT but semantically indicates partial update.
|
||||
"""
|
||||
repository = UserRepository(db, tenant_id=current_user.tenant_id)
|
||||
user = await repository.update(user_id, user_data)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""
|
||||
Soft-delete a user (tenant-isolated).
|
||||
|
||||
Returns 204 No Content on success (no response body).
|
||||
"""
|
||||
repository = UserRepository(db, tenant_id=current_user.tenant_id)
|
||||
success = await repository.delete(user_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
```
|
||||
|
||||
## Repository Pattern with Tenant Isolation
|
||||
|
||||
```python
|
||||
# app/repositories/user_repository.py
|
||||
from sqlmodel import Session, select, func
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Repository with automatic tenant filtering."""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: str):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
async def list(
|
||||
self, filters: dict = None, skip: int = 0, limit: int = 100
|
||||
) -> list[User]:
|
||||
"""List users (automatically filtered by tenant_id)."""
|
||||
statement = select(User).where(User.tenant_id == self.tenant_id)
|
||||
|
||||
# Apply additional filters
|
||||
if filters:
|
||||
if "is_active" in filters:
|
||||
statement = statement.where(User.is_active == filters["is_active"])
|
||||
if "email_contains" in filters:
|
||||
statement = statement.where(User.email.contains(filters["email_contains"]))
|
||||
|
||||
statement = statement.offset(skip).limit(limit).order_by(User.created_at.desc())
|
||||
|
||||
result = await self.db.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count(self, filters: dict = None) -> int:
|
||||
"""Count users (tenant-isolated)."""
|
||||
statement = select(func.count(User.id)).where(User.tenant_id == self.tenant_id)
|
||||
|
||||
# Apply same filters as list()
|
||||
if filters:
|
||||
if "is_active" in filters:
|
||||
statement = statement.where(User.is_active == filters["is_active"])
|
||||
if "email_contains" in filters:
|
||||
statement = statement.where(User.email.contains(filters["email_contains"]))
|
||||
|
||||
result = await self.db.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_by_id(self, user_id: str) -> User | None:
|
||||
"""Get user by ID (tenant-isolated)."""
|
||||
statement = select(User).where(
|
||||
User.id == user_id,
|
||||
User.tenant_id == self.tenant_id,
|
||||
)
|
||||
result = await self.db.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, user_data: UserCreate) -> User:
|
||||
"""Create a new user in the current tenant."""
|
||||
from app.utils.password import hash_password
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
hashed_password=hash_password(user_data.password),
|
||||
tenant_id=self.tenant_id, # Automatic tenant assignment
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update(self, user_id: str, user_data: UserUpdate) -> User | None:
|
||||
"""Update an existing user (tenant-isolated)."""
|
||||
user = await self.get_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Update only provided fields
|
||||
update_data = user_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def delete(self, user_id: str) -> bool:
|
||||
"""Soft-delete a user (tenant-isolated)."""
|
||||
user = await self.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Soft delete by setting deleted_at timestamp
|
||||
user.deleted_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
return True
|
||||
```
|
||||
|
||||
## Nested Resources
|
||||
|
||||
```python
|
||||
# app/api/routes/organizations.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
router = APIRouter(prefix="/api/v1/organizations", tags=["organizations"])
|
||||
|
||||
|
||||
@router.get("/{org_id}/teams", response_model=list[TeamRead])
|
||||
async def list_organization_teams(
|
||||
org_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[TeamRead]:
|
||||
"""List all teams in an organization (tenant-isolated)."""
|
||||
# Verify organization exists and belongs to tenant
|
||||
org_repo = OrganizationRepository(db, tenant_id=current_user.tenant_id)
|
||||
org = await org_repo.get_by_id(org_id)
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Organization with ID {org_id} not found",
|
||||
)
|
||||
|
||||
# Fetch teams for organization
|
||||
team_repo = TeamRepository(db, tenant_id=current_user.tenant_id)
|
||||
teams = await team_repo.list_by_organization(org_id, skip=skip, limit=limit)
|
||||
return teams
|
||||
|
||||
|
||||
@router.post("/{org_id}/teams", response_model=TeamRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization_team(
|
||||
org_id: str,
|
||||
team_data: TeamCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> TeamRead:
|
||||
"""Create a new team in an organization."""
|
||||
# Verify organization exists
|
||||
org_repo = OrganizationRepository(db, tenant_id=current_user.tenant_id)
|
||||
org = await org_repo.get_by_id(org_id)
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Organization with ID {org_id} not found",
|
||||
)
|
||||
|
||||
# Create team
|
||||
team_repo = TeamRepository(db, tenant_id=current_user.tenant_id)
|
||||
team = await team_repo.create(team_data, organization_id=org_id)
|
||||
return team
|
||||
```
|
||||
|
||||
**See also:**
|
||||
- [pydantic-schemas.md](pydantic-schemas.md) - Request/response schema patterns
|
||||
- [pagination.md](pagination.md) - Pagination and filtering examples
|
||||
- [../templates/fastapi-crud-endpoint.py](../templates/fastapi-crud-endpoint.py) - CRUD template
|
||||
Reference in New Issue
Block a user