initial commit

This commit is contained in:
2025-10-19 22:09:35 +03:00
commit 6d593b4554
114 changed files with 23622 additions and 0 deletions

View File

@ -0,0 +1,327 @@
# API Endpoints
This guide shows you how to create API endpoints using the boilerplate's established patterns. You'll learn the common patterns you need for building CRUD APIs.
## Quick Start
Here's how to create a typical endpoint using the boilerplate's patterns:
```python
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from app.core.db.database import async_get_db
from app.crud.crud_users import crud_users
from app.schemas.user import UserRead, UserCreate
from app.api.dependencies import get_current_user
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: int,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
"""Get a user by ID."""
user = await crud_users.get(db=db, id=user_id, schema_to_select=UserRead)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
```
That's it! The boilerplate handles the rest.
## Common Endpoint Patterns
### 1. Get Single Item
```python
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: int,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
user = await crud_users.get(db=db, id=user_id, schema_to_select=UserRead)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
```
### 2. Get Multiple Items (with Pagination)
```python
from fastcrud.paginated import PaginatedListResponse, paginated_response
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
page: int = 1,
items_per_page: int = 10,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * items_per_page,
limit=items_per_page,
schema_to_select=UserRead,
return_as_model=True,
return_total_count=True
)
return paginated_response(
crud_data=users,
page=page,
items_per_page=items_per_page
)
```
### 3. Create Item
```python
@router.post("/", response_model=UserRead, status_code=201)
async def create_user(
user_data: UserCreate,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Check if user already exists
if await crud_users.exists(db=db, email=user_data.email):
raise HTTPException(status_code=409, detail="Email already exists")
# Create user
new_user = await crud_users.create(db=db, object=user_data)
return new_user
```
### 4. Update Item
```python
@router.patch("/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
user_data: UserUpdate,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Check if user exists
if not await crud_users.exists(db=db, id=user_id):
raise HTTPException(status_code=404, detail="User not found")
# Update user
updated_user = await crud_users.update(db=db, object=user_data, id=user_id)
return updated_user
```
### 5. Delete Item (Soft Delete)
```python
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
if not await crud_users.exists(db=db, id=user_id):
raise HTTPException(status_code=404, detail="User not found")
await crud_users.delete(db=db, id=user_id)
return {"message": "User deleted"}
```
## Adding Authentication
To require login, add the `get_current_user` dependency:
```python
@router.get("/me", response_model=UserRead)
async def get_my_profile(
current_user: Annotated[dict, Depends(get_current_user)]
):
"""Get current user's profile."""
return current_user
@router.post("/", response_model=UserRead)
async def create_user(
user_data: UserCreate,
current_user: Annotated[dict, Depends(get_current_user)], # Requires login
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Only logged-in users can create users
new_user = await crud_users.create(db=db, object=user_data)
return new_user
```
## Adding Admin-Only Endpoints
For admin-only endpoints, use `get_current_superuser`:
```python
from app.api.dependencies import get_current_superuser
@router.delete("/{user_id}/permanent", dependencies=[Depends(get_current_superuser)])
async def permanently_delete_user(
user_id: int,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
"""Admin-only: Permanently delete user from database."""
await crud_users.db_delete(db=db, id=user_id)
return {"message": "User permanently deleted"}
```
## Query Parameters
### Simple Parameters
```python
@router.get("/search")
async def search_users(
name: str | None = None, # Optional string
age: int | None = None, # Optional integer
is_active: bool = True, # Boolean with default
db: Annotated[AsyncSession, Depends(async_get_db)]
):
filters = {"is_active": is_active}
if name:
filters["name"] = name
if age:
filters["age"] = age
users = await crud_users.get_multi(db=db, **filters)
return users["data"]
```
### Parameters with Validation
```python
from fastapi import Query
@router.get("/")
async def get_users(
page: Annotated[int, Query(ge=1)] = 1, # Must be >= 1
limit: Annotated[int, Query(ge=1, le=100)] = 10, # Between 1-100
search: Annotated[str | None, Query(max_length=50)] = None, # Max 50 chars
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Use the validated parameters
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * limit,
limit=limit
)
return users["data"]
```
## Error Handling
The boilerplate includes custom exceptions you can use:
```python
from app.core.exceptions.http_exceptions import (
NotFoundException,
DuplicateValueException,
ForbiddenException
)
@router.get("/{user_id}")
async def get_user(user_id: int, db: AsyncSession):
user = await crud_users.get(db=db, id=user_id)
if not user:
raise NotFoundException("User not found") # Returns 404
return user
@router.post("/")
async def create_user(user_data: UserCreate, db: AsyncSession):
if await crud_users.exists(db=db, email=user_data.email):
raise DuplicateValueException("Email already exists") # Returns 409
return await crud_users.create(db=db, object=user_data)
```
## File Uploads
```python
from fastapi import UploadFile, File
@router.post("/{user_id}/avatar")
async def upload_avatar(
user_id: int,
file: UploadFile = File(...),
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Check file type
if not file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="File must be an image")
# Save file and update user
# ... file handling logic ...
return {"message": "Avatar uploaded successfully"}
```
## Creating New Endpoints
### Step 1: Create the Router File
Create `src/app/api/v1/posts.py`:
```python
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from app.core.db.database import async_get_db
from app.crud.crud_posts import crud_posts # You'll create this
from app.schemas.post import PostRead, PostCreate, PostUpdate # You'll create these
from app.api.dependencies import get_current_user
router = APIRouter(prefix="/posts", tags=["posts"])
@router.get("/", response_model=list[PostRead])
async def get_posts(db: Annotated[AsyncSession, Depends(async_get_db)]):
posts = await crud_posts.get_multi(db=db, schema_to_select=PostRead)
return posts["data"]
@router.post("/", response_model=PostRead, status_code=201)
async def create_post(
post_data: PostCreate,
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Add current user as post author
post_dict = post_data.model_dump()
post_dict["author_id"] = current_user["id"]
new_post = await crud_posts.create(db=db, object=post_dict)
return new_post
```
### Step 2: Register the Router
In `src/app/api/v1/__init__.py`, add:
```python
from .posts import router as posts_router
api_router.include_router(posts_router)
```
### Step 3: Test Your Endpoints
Your new endpoints will be available at:
- `GET /api/v1/posts/` - Get all posts
- `POST /api/v1/posts/` - Create new post (requires login)
## Best Practices
1. **Always use the database dependency**: `Depends(async_get_db)`
2. **Use existing CRUD methods**: `crud_users.get()`, `crud_users.create()`, etc.
3. **Check if items exist before operations**: Use `crud_users.exists()`
4. **Use proper HTTP status codes**: `status_code=201` for creation
5. **Add authentication when needed**: `Depends(get_current_user)`
6. **Use response models**: `response_model=UserRead`
7. **Handle errors with custom exceptions**: `NotFoundException`, `DuplicateValueException`
## What's Next
Now that you understand basic endpoints:
- **[Pagination](pagination.md)** - Add pagination to your endpoints<br>
- **[Exceptions](exceptions.md)** - Custom error handling and HTTP exceptions<br>
- **[CRUD Operations](../database/crud.md)** - Understand the CRUD layer<br>
The boilerplate provides everything you need - just follow these patterns!

View File

@ -0,0 +1,465 @@
# API Exception Handling
Learn how to handle errors properly in your API endpoints using the boilerplate's built-in exceptions and patterns.
## Quick Start
The boilerplate provides ready-to-use exceptions that return proper HTTP status codes:
```python
from app.core.exceptions.http_exceptions import NotFoundException
@router.get("/{user_id}")
async def get_user(user_id: int, db: AsyncSession):
user = await crud_users.get(db=db, id=user_id)
if not user:
raise NotFoundException("User not found") # Returns 404
return user
```
That's it! The exception automatically becomes a proper JSON error response.
## Built-in Exceptions
The boilerplate includes common HTTP exceptions you'll need:
### NotFoundException (404)
```python
from app.core.exceptions.http_exceptions import NotFoundException
@router.get("/{user_id}")
async def get_user(user_id: int):
user = await crud_users.get(db=db, id=user_id)
if not user:
raise NotFoundException("User not found")
return user
# Returns:
# Status: 404
# {"detail": "User not found"}
```
### DuplicateValueException (409)
```python
from app.core.exceptions.http_exceptions import DuplicateValueException
@router.post("/")
async def create_user(user_data: UserCreate):
if await crud_users.exists(db=db, email=user_data.email):
raise DuplicateValueException("Email already exists")
return await crud_users.create(db=db, object=user_data)
# Returns:
# Status: 409
# {"detail": "Email already exists"}
```
### ForbiddenException (403)
```python
from app.core.exceptions.http_exceptions import ForbiddenException
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
current_user: Annotated[dict, Depends(get_current_user)]
):
if current_user["id"] != user_id and not current_user["is_superuser"]:
raise ForbiddenException("You can only delete your own account")
await crud_users.delete(db=db, id=user_id)
return {"message": "User deleted"}
# Returns:
# Status: 403
# {"detail": "You can only delete your own account"}
```
### UnauthorizedException (401)
```python
from app.core.exceptions.http_exceptions import UnauthorizedException
# This is typically used in the auth system, but you can use it too:
@router.get("/admin-only")
async def admin_endpoint():
# Some validation logic
if not user_is_admin:
raise UnauthorizedException("Admin access required")
return {"data": "secret admin data"}
# Returns:
# Status: 401
# {"detail": "Admin access required"}
```
## Common Patterns
### Check Before Create
```python
@router.post("/", response_model=UserRead)
async def create_user(user_data: UserCreate, db: AsyncSession):
# Check email
if await crud_users.exists(db=db, email=user_data.email):
raise DuplicateValueException("Email already exists")
# Check username
if await crud_users.exists(db=db, username=user_data.username):
raise DuplicateValueException("Username already taken")
# Create user
return await crud_users.create(db=db, object=user_data)
# For public registration endpoints, consider rate limiting
# to prevent email enumeration attacks
```
### Check Before Update
```python
@router.patch("/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
user_data: UserUpdate,
db: AsyncSession
):
# Check if user exists
if not await crud_users.exists(db=db, id=user_id):
raise NotFoundException("User not found")
# Check for email conflicts (if email is being updated)
if user_data.email:
existing = await crud_users.get(db=db, email=user_data.email)
if existing and existing.id != user_id:
raise DuplicateValueException("Email already taken")
# Update user
return await crud_users.update(db=db, object=user_data, id=user_id)
```
### Check Ownership
```python
@router.get("/{post_id}")
async def get_post(
post_id: int,
current_user: Annotated[dict, Depends(get_current_user)],
db: AsyncSession
):
post = await crud_posts.get(db=db, id=post_id)
if not post:
raise NotFoundException("Post not found")
# Check if user owns the post or is admin
if post.author_id != current_user["id"] and not current_user["is_superuser"]:
raise ForbiddenException("You can only view your own posts")
return post
```
## Validation Errors
FastAPI automatically handles Pydantic validation errors, but you can catch and customize them:
```python
from fastapi import HTTPException
from pydantic import ValidationError
@router.post("/")
async def create_user(user_data: UserCreate):
try:
# If user_data fails validation, Pydantic raises ValidationError
# FastAPI automatically converts this to a 422 response
return await crud_users.create(db=db, object=user_data)
except ValidationError as e:
# You can catch and customize if needed
raise HTTPException(
status_code=400,
detail=f"Invalid data: {e.errors()}"
)
```
## Standard HTTP Exceptions
For other status codes, use FastAPI's HTTPException:
```python
from fastapi import HTTPException
# Bad Request (400)
@router.post("/")
async def create_something(data: dict):
if not data.get("required_field"):
raise HTTPException(
status_code=400,
detail="required_field is missing"
)
# Too Many Requests (429)
@router.post("/")
async def rate_limited_endpoint():
if rate_limit_exceeded():
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Try again later."
)
# Internal Server Error (500)
@router.get("/")
async def risky_endpoint():
try:
# Some operation that might fail
result = risky_operation()
return result
except Exception as e:
# Log the error
logger.error(f"Unexpected error: {e}")
raise HTTPException(
status_code=500,
detail="An unexpected error occurred"
)
```
## Creating Custom Exceptions
If you need custom exceptions, follow the boilerplate's pattern:
```python
# In app/core/exceptions/http_exceptions.py (add to existing file)
from fastapi import HTTPException
class PaymentRequiredException(HTTPException):
"""402 Payment Required"""
def __init__(self, detail: str = "Payment required"):
super().__init__(status_code=402, detail=detail)
class TooManyRequestsException(HTTPException):
"""429 Too Many Requests"""
def __init__(self, detail: str = "Too many requests"):
super().__init__(status_code=429, detail=detail)
# Use them in your endpoints
from app.core.exceptions.http_exceptions import PaymentRequiredException
@router.get("/premium-feature")
async def premium_feature(current_user: dict):
if current_user["tier"] == "free":
raise PaymentRequiredException("Upgrade to access this feature")
return {"data": "premium content"}
```
## Error Response Format
All exceptions return consistent JSON responses:
```json
{
"detail": "Error message here"
}
```
For validation errors (422), you get more detail:
```json
{
"detail": [
{
"type": "missing",
"loc": ["body", "email"],
"msg": "Field required",
"input": null
}
]
}
```
## Global Exception Handling
The boilerplate includes global exception handlers. You can add your own in `main.py`:
```python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
"""Handle ValueError exceptions globally"""
return JSONResponse(
status_code=400,
content={"detail": f"Invalid value: {str(exc)}"}
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Catch-all exception handler"""
# Log the error
logger.error(f"Unhandled exception: {exc}")
return JSONResponse(
status_code=500,
content={"detail": "An unexpected error occurred"}
)
```
## Security Considerations
### Authentication Endpoints - Use Generic Messages
For security, authentication endpoints should use generic error messages to prevent information disclosure:
```python
# SECURITY: Don't reveal if username exists
@router.post("/login")
async def login(credentials: LoginCredentials):
user = await crud_users.get(db=db, username=credentials.username)
# Don't do this - reveals if username exists
# if not user:
# raise NotFoundException("User not found")
# if not verify_password(credentials.password, user.hashed_password):
# raise UnauthorizedException("Invalid password")
# Do this - generic message for all auth failures
if not user or not verify_password(credentials.password, user.hashed_password):
raise UnauthorizedException("Invalid username or password")
return create_access_token(user.id)
# SECURITY: Don't reveal if email is registered during password reset
@router.post("/forgot-password")
async def forgot_password(email: str):
user = await crud_users.get(db=db, email=email)
# Don't do this - reveals if email exists
# if not user:
# raise NotFoundException("Email not found")
# Do this - always return success message
if user:
await send_password_reset_email(user.email)
# Always return the same message
return {"message": "If the email exists, a reset link has been sent"}
```
### Resource Access - Be Specific When Safe
For non-auth operations, specific messages help developers:
```python
# Safe to be specific for resource operations
@router.get("/{post_id}")
async def get_post(
post_id: int,
current_user: Annotated[dict, Depends(get_current_user)]
):
post = await crud_posts.get(db=db, id=post_id)
if not post:
raise NotFoundException("Post not found") # Safe to be specific
if post.author_id != current_user["id"]:
# Don't reveal post exists if user can't access it
raise NotFoundException("Post not found") # Generic, not "Access denied"
return post
```
## Best Practices
### 1. Use Specific Exceptions (When Safe)
```python
# Good for non-sensitive operations
if not user:
raise NotFoundException("User not found")
# Good for validation errors
raise DuplicateValueException("Username already taken")
```
### 2. Use Generic Messages for Security
```python
# Good for authentication
raise UnauthorizedException("Invalid username or password")
# Good for authorization (don't reveal resource exists)
raise NotFoundException("Resource not found") # Instead of "Access denied"
```
### 3. Check Permissions Early
```python
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
current_user: Annotated[dict, Depends(get_current_user)]
):
# Check permission first
if current_user["id"] != user_id:
raise ForbiddenException("Cannot delete other users")
# Then check if user exists
if not await crud_users.exists(db=db, id=user_id):
raise NotFoundException("User not found")
await crud_users.delete(db=db, id=user_id)
```
### 4. Log Important Errors
```python
import logging
logger = logging.getLogger(__name__)
@router.post("/")
async def create_user(user_data: UserCreate):
try:
return await crud_users.create(db=db, object=user_data)
except Exception as e:
logger.error(f"Failed to create user: {e}")
raise HTTPException(status_code=500, detail="User creation failed")
```
## Testing Exceptions
Test that your endpoints raise the right exceptions:
```python
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_user_not_found(client: AsyncClient):
response = await client.get("/api/v1/users/99999")
assert response.status_code == 404
assert "User not found" in response.json()["detail"]
@pytest.mark.asyncio
async def test_duplicate_email(client: AsyncClient):
# Create a user
await client.post("/api/v1/users/", json={
"name": "Test User",
"username": "test1",
"email": "test@example.com",
"password": "Password123!"
})
# Try to create another with same email
response = await client.post("/api/v1/users/", json={
"name": "Test User 2",
"username": "test2",
"email": "test@example.com", # Same email
"password": "Password123!"
})
assert response.status_code == 409
assert "Email already exists" in response.json()["detail"]
```
## What's Next
Now that you understand error handling:
- **[Versioning](versioning.md)** - Learn how to version your APIs<br>
- **[Database CRUD](../database/crud.md)** - Understand the database operations<br>
- **[Authentication](../authentication/index.md)** - Add user authentication to your APIs
Proper error handling makes your API much more user-friendly and easier to debug!

View File

@ -0,0 +1,125 @@
# API Development
Learn how to build REST APIs with the FastAPI Boilerplate. This section covers everything you need to create robust, production-ready APIs.
## What You'll Learn
- **[Endpoints](endpoints.md)** - Create CRUD endpoints with authentication and validation
- **[Pagination](pagination.md)** - Add pagination to handle large datasets
- **[Exception Handling](exceptions.md)** - Handle errors properly with built-in exceptions
- **[API Versioning](versioning.md)** - Version your APIs and maintain backward compatibility
- **Database Integration** - Use the boilerplate's CRUD layer and schemas
## Quick Overview
The boilerplate provides everything you need for API development:
```python
from fastapi import APIRouter, Depends
from app.crud.crud_users import crud_users
from app.schemas.user import UserRead, UserCreate
from app.core.db.database import async_get_db
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[UserRead])
async def get_users(db: Annotated[AsyncSession, Depends(async_get_db)]):
users = await crud_users.get_multi(db=db, schema_to_select=UserRead)
return users["data"]
@router.post("/", response_model=UserRead, status_code=201)
async def create_user(
user_data: UserCreate,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
return await crud_users.create(db=db, object=user_data)
```
## Key Features
### 🔐 **Built-in Authentication**
Add authentication to any endpoint:
```python
from app.api.dependencies import get_current_user
@router.get("/me", response_model=UserRead)
async def get_profile(current_user: Annotated[dict, Depends(get_current_user)]):
return current_user
```
### 📊 **Easy Pagination**
Paginate any endpoint with one line:
```python
from fastcrud.paginated import PaginatedListResponse
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(page: int = 1, items_per_page: int = 10):
# Add pagination to any endpoint
```
### ✅ **Automatic Validation**
Request and response validation is handled automatically:
```python
@router.post("/", response_model=UserRead)
async def create_user(user_data: UserCreate): # ← Validates input
return await crud_users.create(object=user_data) # ← Validates output
```
### 🛡️ **Error Handling**
Use built-in exceptions for consistent error responses:
```python
from app.core.exceptions.http_exceptions import NotFoundException
@router.get("/{user_id}")
async def get_user(user_id: int):
user = await crud_users.get(id=user_id)
if not user:
raise NotFoundException("User not found") # Returns proper 404
return user
```
## Architecture
The boilerplate follows a layered architecture:
```
API Endpoint
Pydantic Schema (validation)
CRUD Layer (database operations)
SQLAlchemy Model (database)
```
This separation makes your code:
- **Testable** - Mock any layer easily
- **Maintainable** - Clear separation of concerns
- **Scalable** - Add features without breaking existing code
## Directory Structure
```text
src/app/api/
├── dependencies.py # Shared dependencies (auth, rate limiting)
└── v1/ # API version 1
├── users.py # User endpoints
├── posts.py # Post endpoints
├── login.py # Authentication
└── ... # Other endpoints
```
## What's Next
Start with the basics:
1. **[Endpoints](endpoints.md)** - Learn the common patterns for creating API endpoints
2. **[Pagination](pagination.md)** - Add pagination to handle large datasets
3. **[Exception Handling](exceptions.md)** - Handle errors properly with built-in exceptions
4. **[API Versioning](versioning.md)** - Version your APIs and maintain backward compatibility
Then dive deeper into the foundation:
5. **[Database Schemas](../database/schemas.md)** - Create schemas for your data
6. **[CRUD Operations](../database/crud.md)** - Understand the database layer
Each guide builds on the previous one with practical examples you can use immediately.

View File

@ -0,0 +1,316 @@
# API Pagination
This guide shows you how to add pagination to your API endpoints using the boilerplate's built-in utilities. Pagination helps you handle large datasets efficiently.
## Quick Start
Here's how to add basic pagination to any endpoint:
```python
from fastcrud.paginated import PaginatedListResponse
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
page: int = 1,
items_per_page: int = 10,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * items_per_page,
limit=items_per_page,
schema_to_select=UserRead,
return_as_model=True,
return_total_count=True
)
return paginated_response(
crud_data=users,
page=page,
items_per_page=items_per_page
)
```
That's it! Your endpoint now returns paginated results with metadata.
## What You Get
The response includes everything frontends need:
```json
{
"data": [
{
"id": 1,
"name": "John Doe",
"username": "johndoe",
"email": "john@example.com"
}
// ... more users
],
"total_count": 150,
"has_more": true,
"page": 1,
"items_per_page": 10,
"total_pages": 15
}
```
## Adding Filters
You can easily add filtering to paginated endpoints:
```python
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
page: int = 1,
items_per_page: int = 10,
# Add filter parameters
search: str | None = None,
is_active: bool | None = None,
tier_id: int | None = None,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Build filters
filters = {}
if search:
filters["name__icontains"] = search # Search by name
if is_active is not None:
filters["is_active"] = is_active
if tier_id:
filters["tier_id"] = tier_id
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * items_per_page,
limit=items_per_page,
schema_to_select=UserRead,
return_as_model=True,
return_total_count=True,
**filters
)
return paginated_response(
crud_data=users,
page=page,
items_per_page=items_per_page
)
```
Now you can call:
- `/users/?search=john` - Find users with "john" in their name
- `/users/?is_active=true` - Only active users
- `/users/?tier_id=1&page=2` - Users in tier 1, page 2
## Adding Sorting
Add sorting options to your paginated endpoints:
```python
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
page: int = 1,
items_per_page: int = 10,
# Add sorting parameters
sort_by: str = "created_at",
sort_order: str = "desc",
db: Annotated[AsyncSession, Depends(async_get_db)]
):
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * items_per_page,
limit=items_per_page,
schema_to_select=UserRead,
return_as_model=True,
return_total_count=True,
sort_columns=sort_by,
sort_orders=sort_order
)
return paginated_response(
crud_data=users,
page=page,
items_per_page=items_per_page
)
```
Usage:
- `/users/?sort_by=name&sort_order=asc` - Sort by name A-Z
- `/users/?sort_by=created_at&sort_order=desc` - Newest first
## Validation
Add validation to prevent issues:
```python
from fastapi import Query
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
page: Annotated[int, Query(ge=1)] = 1, # Must be >= 1
items_per_page: Annotated[int, Query(ge=1, le=100)] = 10, # Between 1-100
db: Annotated[AsyncSession, Depends(async_get_db)]
):
# Your pagination logic here
```
## Complete Example
Here's a full-featured paginated endpoint:
```python
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
# Pagination
page: Annotated[int, Query(ge=1)] = 1,
items_per_page: Annotated[int, Query(ge=1, le=100)] = 10,
# Filtering
search: Annotated[str | None, Query(max_length=100)] = None,
is_active: bool | None = None,
tier_id: int | None = None,
# Sorting
sort_by: str = "created_at",
sort_order: str = "desc",
db: Annotated[AsyncSession, Depends(async_get_db)]
):
"""Get paginated users with filtering and sorting."""
# Build filters
filters = {"is_deleted": False} # Always exclude deleted users
if is_active is not None:
filters["is_active"] = is_active
if tier_id:
filters["tier_id"] = tier_id
# Handle search
search_criteria = []
if search:
from sqlalchemy import or_, func
search_criteria = [
or_(
func.lower(User.name).contains(search.lower()),
func.lower(User.username).contains(search.lower()),
func.lower(User.email).contains(search.lower())
)
]
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * items_per_page,
limit=items_per_page,
schema_to_select=UserRead,
return_as_model=True,
return_total_count=True,
sort_columns=sort_by,
sort_orders=sort_order,
**filters,
**{"filter_criteria": search_criteria} if search_criteria else {}
)
return paginated_response(
crud_data=users,
page=page,
items_per_page=items_per_page
)
```
This endpoint supports:
- `/users/` - First 10 users
- `/users/?page=2&items_per_page=20` - Page 2, 20 items
- `/users/?search=john&is_active=true` - Active users named john
- `/users/?sort_by=name&sort_order=asc` - Sorted by name
## Simple List (No Pagination)
Sometimes you just want a simple list without pagination:
```python
@router.get("/all", response_model=list[UserRead])
async def get_all_users(
limit: int = 100, # Prevent too many results
db: Annotated[AsyncSession, Depends(async_get_db)]
):
users = await crud_users.get_multi(
db=db,
limit=limit,
schema_to_select=UserRead,
return_as_model=True
)
return users["data"]
```
## Performance Tips
1. **Always set a maximum page size**:
```python
items_per_page: Annotated[int, Query(ge=1, le=100)] = 10 # Max 100 items
```
2. **Use `schema_to_select` to only fetch needed fields**:
```python
users = await crud_users.get_multi(
schema_to_select=UserRead, # Only fetch UserRead fields
return_as_model=True
)
```
3. **Add database indexes** for columns you sort by:
```sql
-- In your migration
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_users_name ON users(name);
```
## Common Patterns
### Admin List with All Users
```python
@router.get("/admin", dependencies=[Depends(get_current_superuser)])
async def get_all_users_admin(
include_deleted: bool = False,
page: int = 1,
items_per_page: int = 50,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
filters = {}
if not include_deleted:
filters["is_deleted"] = False
users = await crud_users.get_multi(db=db, **filters)
return paginated_response(users, page, items_per_page)
```
### User's Own Items
```python
@router.get("/my-posts", response_model=PaginatedListResponse[PostRead])
async def get_my_posts(
page: int = 1,
items_per_page: int = 10,
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)]
):
posts = await crud_posts.get_multi(
db=db,
author_id=current_user["id"], # Only user's own posts
offset=(page - 1) * items_per_page,
limit=items_per_page
)
return paginated_response(posts, page, items_per_page)
```
## What's Next
Now that you understand pagination:
- **[Database CRUD](../database/crud.md)** - Learn more about the CRUD operations
- **[Database Schemas](../database/schemas.md)** - Create schemas for your data
- **[Authentication](../authentication/index.md)** - Add user authentication to your endpoints
The boilerplate makes pagination simple - just use these patterns!

View File

@ -0,0 +1,418 @@
# API Versioning
Learn how to version your APIs properly using the boilerplate's built-in versioning structure and best practices for maintaining backward compatibility.
## Quick Start
The boilerplate is already set up for versioning with a `v1` structure:
```text
src/app/api/
├── dependencies.py # Shared across all versions
└── v1/ # Version 1 of your API
├── __init__.py # Router registration
├── users.py # User endpoints
├── posts.py # Post endpoints
└── ... # Other endpoints
```
Your endpoints are automatically available at `/api/v1/...`:
- `GET /api/v1/users/` - Get users
- `POST /api/v1/users/` - Create user
- `GET /api/v1/posts/` - Get posts
## Current Structure
### Version 1 (v1)
The current API version is in `src/app/api/v1/`:
```python
# src/app/api/v1/__init__.py
from fastapi import APIRouter
from .users import router as users_router
from .posts import router as posts_router
from .login import router as login_router
# Main v1 router
api_router = APIRouter()
# Include all v1 endpoints
api_router.include_router(users_router)
api_router.include_router(posts_router)
api_router.include_router(login_router)
```
### Main App Registration
In `src/app/main.py`, v1 is registered:
```python
from fastapi import FastAPI
from app.api.v1 import api_router as api_v1_router
app = FastAPI()
# Register v1 API
app.include_router(api_v1_router, prefix="/api/v1")
```
## Adding Version 2
When you need to make breaking changes, create a new version:
### Step 1: Create v2 Directory
```text
src/app/api/
├── dependencies.py
├── v1/ # Keep v1 unchanged
│ ├── __init__.py
│ ├── users.py
│ └── ...
└── v2/ # New version
├── __init__.py
├── users.py # Updated user endpoints
└── ...
```
### Step 2: Create v2 Router
```python
# src/app/api/v2/__init__.py
from fastapi import APIRouter
from .users import router as users_router
# Import other v2 routers
# Main v2 router
api_router = APIRouter()
# Include v2 endpoints
api_router.include_router(users_router)
```
### Step 3: Register v2 in Main App
```python
# src/app/main.py
from fastapi import FastAPI
from app.api.v1 import api_router as api_v1_router
from app.api.v2 import api_router as api_v2_router
app = FastAPI()
# Register both versions
app.include_router(api_v1_router, prefix="/api/v1")
app.include_router(api_v2_router, prefix="/api/v2")
```
## Version 2 Example
Here's how you might evolve the user endpoints in v2:
### v1 User Endpoint
```python
# src/app/api/v1/users.py
from app.schemas.user import UserRead, UserCreate
@router.get("/", response_model=list[UserRead])
async def get_users():
users = await crud_users.get_multi(db=db, schema_to_select=UserRead)
return users["data"]
@router.post("/", response_model=UserRead)
async def create_user(user_data: UserCreate):
return await crud_users.create(db=db, object=user_data)
```
### v2 User Endpoint (with breaking changes)
```python
# src/app/api/v2/users.py
from app.schemas.user import UserReadV2, UserCreateV2 # New schemas
from fastcrud.paginated import PaginatedListResponse
# Breaking change: Always return paginated response
@router.get("/", response_model=PaginatedListResponse[UserReadV2])
async def get_users(page: int = 1, items_per_page: int = 10):
users = await crud_users.get_multi(
db=db,
offset=(page - 1) * items_per_page,
limit=items_per_page,
schema_to_select=UserReadV2
)
return paginated_response(users, page, items_per_page)
# Breaking change: Require authentication
@router.post("/", response_model=UserReadV2)
async def create_user(
user_data: UserCreateV2,
current_user: Annotated[dict, Depends(get_current_user)] # Now required
):
return await crud_users.create(db=db, object=user_data)
```
## Schema Versioning
Create separate schemas for different versions:
### Version 1 Schema
```python
# src/app/schemas/user.py (existing)
class UserRead(BaseModel):
id: int
name: str
username: str
email: str
profile_image_url: str
tier_id: int | None
class UserCreate(BaseModel):
name: str
username: str
email: str
password: str
```
### Version 2 Schema (with changes)
```python
# src/app/schemas/user_v2.py (new file)
from datetime import datetime
class UserReadV2(BaseModel):
id: int
name: str
username: str
email: str
avatar_url: str # Changed from profile_image_url
subscription_tier: str # Changed from tier_id to string
created_at: datetime # New field
is_verified: bool # New field
class UserCreateV2(BaseModel):
name: str
username: str
email: str
password: str
accept_terms: bool # New required field
```
## Gradual Migration Strategy
### 1. Keep Both Versions Running
```python
# Both versions work simultaneously
# v1: GET /api/v1/users/ -> list[UserRead]
# v2: GET /api/v2/users/ -> PaginatedListResponse[UserReadV2]
```
### 2. Add Deprecation Warnings
```python
# src/app/api/v1/users.py
import warnings
from fastapi import HTTPException
@router.get("/", response_model=list[UserRead])
async def get_users(response: Response):
# Add deprecation header
response.headers["X-API-Deprecation"] = "v1 is deprecated. Use v2."
response.headers["X-API-Sunset"] = "2024-12-31" # When v1 will be removed
users = await crud_users.get_multi(db=db, schema_to_select=UserRead)
return users["data"]
```
### 3. Monitor Usage
Track which versions are being used:
```python
# src/app/api/middleware.py
from fastapi import Request
import logging
logger = logging.getLogger(__name__)
async def version_tracking_middleware(request: Request, call_next):
if request.url.path.startswith("/api/v1/"):
logger.info(f"v1 usage: {request.method} {request.url.path}")
elif request.url.path.startswith("/api/v2/"):
logger.info(f"v2 usage: {request.method} {request.url.path}")
response = await call_next(request)
return response
```
## Shared Code Between Versions
Keep common logic in shared modules:
### Shared Dependencies
```python
# src/app/api/dependencies.py - shared across all versions
async def get_current_user(...):
# Authentication logic used by all versions
pass
async def get_db():
# Database connection used by all versions
pass
```
### Shared CRUD Operations
```python
# The CRUD layer can be shared between versions
# Only the schemas and endpoints change
# v1 endpoint
@router.get("/", response_model=list[UserRead])
async def get_users_v1():
users = await crud_users.get_multi(schema_to_select=UserRead)
return users["data"]
# v2 endpoint
@router.get("/", response_model=PaginatedListResponse[UserReadV2])
async def get_users_v2():
users = await crud_users.get_multi(schema_to_select=UserReadV2)
return paginated_response(users, page, items_per_page)
```
## Version Discovery
Let clients discover available versions:
```python
# src/app/api/versions.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/versions")
async def get_api_versions():
return {
"available_versions": ["v1", "v2"],
"current_version": "v2",
"deprecated_versions": [],
"sunset_dates": {
"v1": "2024-12-31"
}
}
```
Register it in main.py:
```python
# src/app/main.py
from app.api.versions import router as versions_router
app.include_router(versions_router, prefix="/api")
# Now available at GET /api/versions
```
## Testing Multiple Versions
Test both versions to ensure compatibility:
```python
# tests/test_api_versioning.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_v1_users(client: AsyncClient):
"""Test v1 returns simple list"""
response = await client.get("/api/v1/users/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list) # v1 returns list
@pytest.mark.asyncio
async def test_v2_users(client: AsyncClient):
"""Test v2 returns paginated response"""
response = await client.get("/api/v2/users/")
assert response.status_code == 200
data = response.json()
assert "data" in data # v2 returns paginated response
assert "total_count" in data
assert "page" in data
```
## OpenAPI Documentation
Each version gets its own docs:
```python
# src/app/main.py
from fastapi import FastAPI
# Create separate apps for documentation
v1_app = FastAPI(title="My API v1", version="1.0.0")
v2_app = FastAPI(title="My API v2", version="2.0.0")
# Register routes
v1_app.include_router(api_v1_router)
v2_app.include_router(api_v2_router)
# Mount as sub-applications
main_app = FastAPI()
main_app.mount("/api/v1", v1_app)
main_app.mount("/api/v2", v2_app)
```
Now you have separate documentation:
- `/api/v1/docs` - v1 documentation
- `/api/v2/docs` - v2 documentation
## Best Practices
### 1. Semantic Versioning
- **v1.0** → **v1.1**: New features (backward compatible)
- **v1.1** → **v2.0**: Breaking changes (new version)
### 2. Clear Migration Path
```python
# Document what changed in v2
"""
API v2 Changes:
- GET /users/ now returns paginated response instead of array
- POST /users/ now requires authentication
- UserRead.profile_image_url renamed to avatar_url
- UserRead.tier_id changed to subscription_tier (string)
- Added UserRead.created_at and is_verified fields
- UserCreate now requires accept_terms field
"""
```
### 3. Gradual Deprecation
1. Release v2 alongside v1
2. Add deprecation warnings to v1
3. Set sunset date for v1
4. Monitor v1 usage
5. Remove v1 after sunset date
### 4. Consistent Patterns
Keep the same patterns across versions:
- Same URL structure: `/api/v{number}/resource`
- Same HTTP methods and status codes
- Same authentication approach
- Same error response format
## What's Next
Now that you understand API versioning:
- **[Database Migrations](../database/migrations.md)** - Handle database schema changes
- **[Testing](../testing.md)** - Test multiple API versions
- **[Production](../production.md)** - Deploy versioned APIs
Proper versioning lets you evolve your API without breaking existing clients!