initial commit
This commit is contained in:
327
docs/user-guide/api/endpoints.md
Normal file
327
docs/user-guide/api/endpoints.md
Normal 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!
|
||||
465
docs/user-guide/api/exceptions.md
Normal file
465
docs/user-guide/api/exceptions.md
Normal 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!
|
||||
125
docs/user-guide/api/index.md
Normal file
125
docs/user-guide/api/index.md
Normal 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.
|
||||
316
docs/user-guide/api/pagination.md
Normal file
316
docs/user-guide/api/pagination.md
Normal 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!
|
||||
418
docs/user-guide/api/versioning.md
Normal file
418
docs/user-guide/api/versioning.md
Normal 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!
|
||||
Reference in New Issue
Block a user