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,717 @@
# Development Guide
This guide covers everything you need to know about extending, customizing, and developing with the FastAPI boilerplate.
## Extending the Boilerplate
### Adding New Models
Follow this step-by-step process to add new entities to your application:
#### 1. Create SQLAlchemy Model
Create a new file in `src/app/models/` (e.g., `category.py`):
```python
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.db.database import Base
class Category(Base):
__tablename__ = "category"
id: Mapped[int] = mapped_column(
"id",
autoincrement=True,
nullable=False,
unique=True,
primary_key=True,
init=False
)
name: Mapped[str] = mapped_column(String(50))
description: Mapped[str | None] = mapped_column(String(255), default=None)
# Relationships
posts: Mapped[list["Post"]] = relationship(back_populates="category")
```
#### 2. Create Pydantic Schemas
Create `src/app/schemas/category.py`:
```python
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, Field, ConfigDict
class CategoryBase(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=50)]
description: Annotated[str | None, Field(max_length=255, default=None)]
class CategoryCreate(CategoryBase):
model_config = ConfigDict(extra="forbid")
class CategoryRead(CategoryBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class CategoryUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: Annotated[str | None, Field(min_length=1, max_length=50, default=None)]
description: Annotated[str | None, Field(max_length=255, default=None)]
class CategoryUpdateInternal(CategoryUpdate):
updated_at: datetime
class CategoryDelete(BaseModel):
model_config = ConfigDict(extra="forbid")
is_deleted: bool
deleted_at: datetime
```
#### 3. Create CRUD Operations
Create `src/app/crud/crud_categories.py`:
```python
from fastcrud import FastCRUD
from ..models.category import Category
from ..schemas.category import CategoryCreate, CategoryUpdate, CategoryUpdateInternal, CategoryDelete
CRUDCategory = FastCRUD[Category, CategoryCreate, CategoryUpdate, CategoryUpdateInternal, CategoryDelete]
crud_categories = CRUDCategory(Category)
```
#### 4. Update Model Imports
Add your new model to `src/app/models/__init__.py`:
```python
from .category import Category
from .user import User
from .post import Post
# ... other imports
```
#### 5. Create Database Migration
Generate and apply the migration:
```bash
# From the src/ directory
uv run alembic revision --autogenerate -m "Add category model"
uv run alembic upgrade head
```
#### 6. Create API Endpoints
Create `src/app/api/v1/categories.py`:
```python
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from fastcrud.paginated import PaginatedListResponse, compute_offset
from sqlalchemy.ext.asyncio import AsyncSession
from ...api.dependencies import get_current_superuser, get_current_user
from ...core.db.database import async_get_db
from ...core.exceptions.http_exceptions import DuplicateValueException, NotFoundException
from ...crud.crud_categories import crud_categories
from ...schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
router = APIRouter(tags=["categories"])
@router.post("/category", response_model=CategoryRead, status_code=201)
async def write_category(
request: Request,
category: CategoryCreate,
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)],
):
category_row = await crud_categories.exists(db=db, name=category.name)
if category_row:
raise DuplicateValueException("Category name already exists")
return await crud_categories.create(db=db, object=category)
@router.get("/categories", response_model=PaginatedListResponse[CategoryRead])
async def read_categories(
request: Request,
db: Annotated[AsyncSession, Depends(async_get_db)],
page: int = 1,
items_per_page: int = 10,
):
categories_data = await crud_categories.get_multi(
db=db,
offset=compute_offset(page, items_per_page),
limit=items_per_page,
schema_to_select=CategoryRead,
is_deleted=False,
)
return categories_data
@router.get("/category/{category_id}", response_model=CategoryRead)
async def read_category(
request: Request,
category_id: int,
db: Annotated[AsyncSession, Depends(async_get_db)],
):
db_category = await crud_categories.get(
db=db,
schema_to_select=CategoryRead,
id=category_id,
is_deleted=False
)
if not db_category:
raise NotFoundException("Category not found")
return db_category
@router.patch("/category/{category_id}", response_model=CategoryRead)
async def patch_category(
request: Request,
category_id: int,
values: CategoryUpdate,
current_user: Annotated[dict, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)],
):
db_category = await crud_categories.get(db=db, id=category_id, is_deleted=False)
if not db_category:
raise NotFoundException("Category not found")
if values.name:
category_row = await crud_categories.exists(db=db, name=values.name)
if category_row and category_row["id"] != category_id:
raise DuplicateValueException("Category name already exists")
return await crud_categories.update(db=db, object=values, id=category_id)
@router.delete("/category/{category_id}")
async def erase_category(
request: Request,
category_id: int,
current_user: Annotated[dict, Depends(get_current_superuser)],
db: Annotated[AsyncSession, Depends(async_get_db)],
):
db_category = await crud_categories.get(db=db, id=category_id, is_deleted=False)
if not db_category:
raise NotFoundException("Category not found")
await crud_categories.delete(db=db, db_row=db_category, garbage_collection=False)
return {"message": "Category deleted"}
```
#### 7. Register Router
Add your router to `src/app/api/v1/__init__.py`:
```python
from fastapi import APIRouter
from .categories import router as categories_router
# ... other imports
router = APIRouter()
router.include_router(categories_router, prefix="/categories")
# ... other router includes
```
### Creating Custom Middleware
Create middleware in `src/app/middleware/`:
```python
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
class CustomHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Pre-processing
start_time = time.time()
# Process request
response = await call_next(request)
# Post-processing
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
```
Register in `src/app/main.py`:
```python
from .middleware.custom_header_middleware import CustomHeaderMiddleware
app.add_middleware(CustomHeaderMiddleware)
```
## Testing
### Test Configuration
The boilerplate uses pytest for testing. Test configuration is in `pytest.ini` and test dependencies in `pyproject.toml`.
### Database Testing Setup
Create test database fixtures in `tests/conftest.py`:
```python
import asyncio
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from src.app.core.config import settings
from src.app.core.db.database import Base, async_get_db
from src.app.main import app
# Test database URL
TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db"
# Create test engine
test_engine = create_async_engine(TEST_DATABASE_URL, echo=True)
TestSessionLocal = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
@pytest_asyncio.fixture
async def async_session():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionLocal() as session:
yield session
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def async_client(async_session):
def get_test_db():
return async_session
app.dependency_overrides[async_get_db] = get_test_db
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
```
### Writing Tests
#### Model Tests
```python
# tests/test_models.py
import pytest
from src.app.models.user import User
@pytest_asyncio.fixture
async def test_user(async_session):
user = User(
name="Test User",
username="testuser",
email="test@example.com",
hashed_password="hashed_password"
)
async_session.add(user)
await async_session.commit()
await async_session.refresh(user)
return user
async def test_user_creation(test_user):
assert test_user.name == "Test User"
assert test_user.username == "testuser"
assert test_user.email == "test@example.com"
```
#### API Endpoint Tests
```python
# tests/test_api.py
import pytest
from httpx import AsyncClient
async def test_create_user(async_client: AsyncClient):
user_data = {
"name": "New User",
"username": "newuser",
"email": "new@example.com",
"password": "SecurePass123!"
}
response = await async_client.post("/api/v1/users", json=user_data)
assert response.status_code == 201
data = response.json()
assert data["name"] == "New User"
assert data["username"] == "newuser"
assert "hashed_password" not in data # Ensure password not exposed
async def test_read_users(async_client: AsyncClient):
response = await async_client.get("/api/v1/users")
assert response.status_code == 200
data = response.json()
assert "data" in data
assert "total_count" in data
```
#### CRUD Tests
```python
# tests/test_crud.py
import pytest
from src.app.crud.crud_users import crud_users
from src.app.schemas.user import UserCreate
async def test_crud_create_user(async_session):
user_data = UserCreate(
name="CRUD User",
username="cruduser",
email="crud@example.com",
password="password123"
)
user = await crud_users.create(db=async_session, object=user_data)
assert user["name"] == "CRUD User"
assert user["username"] == "cruduser"
async def test_crud_get_user(async_session, test_user):
retrieved_user = await crud_users.get(
db=async_session,
id=test_user.id
)
assert retrieved_user["name"] == test_user.name
```
### Running Tests
```bash
# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov=src
# Run specific test file
uv run pytest tests/test_api.py
# Run with verbose output
uv run pytest -v
# Run tests matching pattern
uv run pytest -k "test_user"
```
## Customization
### Environment-Specific Configuration
Create environment-specific settings:
```python
# src/app/core/config.py
class LocalSettings(Settings):
ENVIRONMENT: str = "local"
DEBUG: bool = True
class ProductionSettings(Settings):
ENVIRONMENT: str = "production"
DEBUG: bool = False
# Production-specific settings
def get_settings():
env = os.getenv("ENVIRONMENT", "local")
if env == "production":
return ProductionSettings()
return LocalSettings()
settings = get_settings()
```
### Custom Logging
Configure logging in `src/app/core/config.py`:
```python
import logging
from pythonjsonlogger import jsonlogger
def setup_logging():
# JSON logging for production
if settings.ENVIRONMENT == "production":
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)
else:
# Simple logging for development
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
```
## Opting Out of Services
### Disabling Redis Caching
1. Remove cache decorators from endpoints
2. Update dependencies in `src/app/core/config.py`:
```python
class Settings(BaseSettings):
# Comment out or remove Redis cache settings
# REDIS_CACHE_HOST: str = "localhost"
# REDIS_CACHE_PORT: int = 6379
pass
```
3. Remove Redis cache imports and usage
### Disabling Background Tasks (ARQ)
1. Remove ARQ from `pyproject.toml` dependencies
2. Remove worker configuration from `docker-compose.yml`
3. Delete `src/app/core/worker/` directory
4. Remove task-related endpoints
### Disabling Rate Limiting
1. Remove rate limiting dependencies from endpoints:
```python
# Remove this dependency
dependencies=[Depends(rate_limiter_dependency)]
```
2. Remove rate limiting models and schemas
3. Update database migrations to remove rate limit tables
### Disabling Authentication
1. Remove JWT dependencies from protected endpoints
2. Remove user-related models and endpoints
3. Update database to remove user tables
4. Remove authentication middleware
### Minimal FastAPI Setup
For a minimal setup with just basic FastAPI:
```python
# src/app/main.py (minimal version)
from fastapi import FastAPI
app = FastAPI(
title="Minimal API",
description="Basic FastAPI application",
version="1.0.0"
)
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
```
## Best Practices
### Code Organization
- Keep models, schemas, and CRUD operations in separate files
- Use consistent naming conventions across the application
- Group related functionality in modules
- Follow FastAPI and Pydantic best practices
### Database Operations
- Always use transactions for multi-step operations
- Implement soft deletes for important data
- Use database constraints for data integrity
- Index frequently queried columns
### API Design
- Use consistent response formats
- Implement proper error handling
- Version your APIs from the start
- Document all endpoints with proper schemas
### Security
- Never expose sensitive data in API responses
- Use proper authentication and authorization
- Validate all input data
- Implement rate limiting for public endpoints
- Use HTTPS in production
### Performance
- Use async/await consistently
- Implement caching for expensive operations
- Use database connection pooling
- Monitor and optimize slow queries
- Use pagination for large datasets
## Troubleshooting
### Common Issues
**Import Errors**: Ensure all new models are imported in `__init__.py` files
**Migration Failures**: Check model definitions and relationships before generating migrations
**Test Failures**: Verify test database configuration and isolation
**Performance Issues**: Check for N+1 queries and missing database indexes
**Authentication Problems**: Verify JWT configuration and token expiration settings
### Debugging Tips
- Use FastAPI's automatic interactive docs at `/docs`
- Enable SQL query logging in development
- Use proper logging throughout the application
- Test endpoints with realistic data volumes
- Monitor database performance with query analysis
## Database Migrations
!!! warning "Important Setup for Docker Users"
If you're using the database in Docker, you need to expose the port to run migrations. Change this in `docker-compose.yml`:
```yaml
db:
image: postgres:13
env_file:
- ./src/.env
volumes:
- postgres-data:/var/lib/postgresql/data
# -------- replace with comment to run migrations with docker --------
ports:
- 5432:5432
# expose:
# - "5432"
```
### Creating Migrations
!!! warning "Model Import Requirement"
To create tables if you haven't created endpoints yet, ensure you import the models in `src/app/models/__init__.py`. This step is crucial for Alembic to detect new tables.
While in the `src` folder, run Alembic migrations:
```bash
# Generate migration file
uv run alembic revision --autogenerate -m "Description of changes"
# Apply migrations
uv run alembic upgrade head
```
!!! note "Without uv"
If you don't have uv, run `pip install alembic` first, then use `alembic` commands directly.
### Migration Workflow
1. **Make Model Changes** - Modify your SQLAlchemy models
2. **Import Models** - Ensure models are imported in `src/app/models/__init__.py`
3. **Generate Migration** - Run `alembic revision --autogenerate`
4. **Review Migration** - Check the generated migration file in `src/migrations/versions/`
5. **Apply Migration** - Run `alembic upgrade head`
6. **Test Changes** - Verify your changes work as expected
### Common Migration Tasks
#### Adding a New Model
```python
# 1. Create the model file (e.g., src/app/models/category.py)
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
description: Mapped[str] = mapped_column(String(255), nullable=True)
```
```python
# 2. Import in src/app/models/__init__.py
from .user import User
from .post import Post
from .tier import Tier
from .rate_limit import RateLimit
from .category import Category # Add this line
```
```bash
# 3. Generate and apply migration
cd src
uv run alembic revision --autogenerate -m "Add categories table"
uv run alembic upgrade head
```
#### Modifying Existing Models
```python
# 1. Modify your model
class User(Base):
# ... existing fields ...
bio: Mapped[str] = mapped_column(String(500), nullable=True) # New field
```
```bash
# 2. Generate migration
uv run alembic revision --autogenerate -m "Add bio field to users"
# 3. Review the generated migration file
# 4. Apply migration
uv run alembic upgrade head
```
This guide provides the foundation for extending and customizing the FastAPI boilerplate. For specific implementation details, refer to the existing code examples throughout the boilerplate.