From 0ab6cb356ef54cf38bac06b3a8515128fc0a3159 Mon Sep 17 00:00:00 2001 From: Igor Benav Date: Tue, 10 Jun 2025 03:01:02 -0300 Subject: [PATCH] initial documentation --- README.md | 54 +- docs/assets/FastAPI-boilerplate.png | Bin 0 -> 399217 bytes docs/getting-started/configuration.md | 163 ++++ docs/getting-started/first-run.md | 594 ++++++++++++ docs/getting-started/index.md | 180 ++++ docs/getting-started/installation.md | 366 ++++++++ docs/index.md | 120 +++ docs/stylesheets/extra.css | 20 + docs/user-guide/api/endpoints.md | 328 +++++++ docs/user-guide/api/exceptions.md | 465 +++++++++ docs/user-guide/api/index.md | 125 +++ docs/user-guide/api/pagination.md | 316 +++++++ docs/user-guide/api/versioning.md | 418 +++++++++ docs/user-guide/authentication/index.md | 198 ++++ docs/user-guide/authentication/jwt-tokens.md | 669 +++++++++++++ docs/user-guide/authentication/permissions.md | 634 +++++++++++++ .../authentication/user-management.md | 879 ++++++++++++++++++ docs/user-guide/background-tasks/index.md | 92 ++ docs/user-guide/caching/cache-strategies.md | 191 ++++ docs/user-guide/caching/client-cache.md | 509 ++++++++++ docs/user-guide/caching/index.md | 77 ++ docs/user-guide/caching/redis-cache.md | 357 +++++++ docs/user-guide/configuration/docker-setup.md | 539 +++++++++++ .../configuration/environment-specific.md | 692 ++++++++++++++ .../configuration/environment-variables.md | 651 +++++++++++++ docs/user-guide/configuration/index.md | 311 +++++++ .../configuration/settings-classes.md | 537 +++++++++++ docs/user-guide/database/crud.md | 491 ++++++++++ docs/user-guide/database/index.md | 235 +++++ docs/user-guide/database/migrations.md | 470 ++++++++++ docs/user-guide/database/models.md | 484 ++++++++++ docs/user-guide/database/schemas.md | 650 +++++++++++++ docs/user-guide/development.md | 717 ++++++++++++++ docs/user-guide/index.md | 78 ++ docs/user-guide/production.md | 709 ++++++++++++++ docs/user-guide/project-structure.md | 296 ++++++ docs/user-guide/rate-limiting/index.md | 428 +++++++++ docs/user-guide/testing.md | 810 ++++++++++++++++ mkdocs.yml | 150 +++ 39 files changed, 14998 insertions(+), 5 deletions(-) create mode 100644 docs/assets/FastAPI-boilerplate.png create mode 100644 docs/getting-started/configuration.md create mode 100644 docs/getting-started/first-run.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/index.md create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/user-guide/api/endpoints.md create mode 100644 docs/user-guide/api/exceptions.md create mode 100644 docs/user-guide/api/index.md create mode 100644 docs/user-guide/api/pagination.md create mode 100644 docs/user-guide/api/versioning.md create mode 100644 docs/user-guide/authentication/index.md create mode 100644 docs/user-guide/authentication/jwt-tokens.md create mode 100644 docs/user-guide/authentication/permissions.md create mode 100644 docs/user-guide/authentication/user-management.md create mode 100644 docs/user-guide/background-tasks/index.md create mode 100644 docs/user-guide/caching/cache-strategies.md create mode 100644 docs/user-guide/caching/client-cache.md create mode 100644 docs/user-guide/caching/index.md create mode 100644 docs/user-guide/caching/redis-cache.md create mode 100644 docs/user-guide/configuration/docker-setup.md create mode 100644 docs/user-guide/configuration/environment-specific.md create mode 100644 docs/user-guide/configuration/environment-variables.md create mode 100644 docs/user-guide/configuration/index.md create mode 100644 docs/user-guide/configuration/settings-classes.md create mode 100644 docs/user-guide/database/crud.md create mode 100644 docs/user-guide/database/index.md create mode 100644 docs/user-guide/database/migrations.md create mode 100644 docs/user-guide/database/models.md create mode 100644 docs/user-guide/database/schemas.md create mode 100644 docs/user-guide/development.md create mode 100644 docs/user-guide/index.md create mode 100644 docs/user-guide/production.md create mode 100644 docs/user-guide/project-structure.md create mode 100644 docs/user-guide/rate-limiting/index.md create mode 100644 docs/user-guide/testing.md create mode 100644 mkdocs.yml diff --git a/README.md b/README.md index d220158..38378f5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -

Fast FastAPI boilerplate

+

Benav Labs FastAPI boilerplate

Yet another template to speed your FastAPI development up.

- Blue Rocket with FastAPI Logo as its window. There is a word FAST written + Purple Rocket with FastAPI Logo as its window.

@@ -33,6 +33,16 @@

+--- + +## 📖 Documentation + +📚 **[Visit our comprehensive documentation at benavlabs.github.io/fastapi-boilerplate](https://benavlabs.github.io/fastapi-boilerplate/)** + +This README provides a quick reference for LLMs and developers, but the full documentation contains detailed guides, examples, and best practices. + +--- + ## 0. About **FastAPI boilerplate** creates an extendable async API using FastAPI, Pydantic V2, SQLAlchemy 2.0 and PostgreSQL: @@ -47,7 +57,7 @@ - [`NGINX`](https://nginx.org/en/) High-performance low resource consumption web server used for Reverse Proxy and Load Balancing. > \[!TIP\] -> If you want the `SQLModel` version instead, head to [SQLModel-boilerplate](https://github.com/igorbenav/SQLModel-boilerplate). +> There's a `SQLModel` version as well, but it's no longer updated: [SQLModel-boilerplate](https://github.com/igorbenav/SQLModel-boilerplate). ## 1. Features @@ -62,8 +72,6 @@ - ⎘ Out of the box offset and cursor pagination support with fastcrud - 🛑 Rate Limiter dependency - 👮 FastAPI docs behind authentication and hidden based on the environment -- 🦾 Easily extendable -- 🤸‍♂️ Flexible - 🚚 Easy running with docker compose - ⚖️ NGINX Reverse Proxy and Load Balancing @@ -118,6 +126,8 @@ ______________________________________________________________________ ## 3. Prerequisites +> 📖 **[See detailed installation guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/getting-started/installation/)** + ### 3.0 Start Start by using the template, and naming the repository to what you want. @@ -144,6 +154,8 @@ git clone https://github.com/igormagalhaesr/FastAPI-boilerplate ### 3.1 Environment Variables (.env) +> 📖 **[See complete configuration guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/getting-started/configuration/)** + Then create a `.env` file inside `src` directory: ```sh @@ -302,6 +314,8 @@ pip install uv ## 4. Usage +> 📖 **[See complete first run guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/getting-started/first-run/)** + ### 4.1 Docker Compose If you used docker compose, your setup is done. You just need to ensure that when you run (while in the base folder): @@ -530,8 +544,12 @@ uv run alembic upgrade head ## 5. Extending +> 📖 **[See comprehensive development guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/development/)** + ### 5.1 Project Structure +> 📖 **[See detailed project structure guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/project-structure/)** + First, you may want to take a look at the project structure and understand what each file is doing. ```sh @@ -661,6 +679,8 @@ Note that this table is used to blacklist the `JWT` tokens (it's how you log a u ### 5.3 SQLAlchemy Models +> 📖 **[See database models guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/database/models/)** + Inside `app/models`, create a new `entity.py` for each new entity (replacing entity with the name) and define the attributes according to [SQLAlchemy 2.0 standards](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#orm-mapping-styles): > \[!WARNING\] @@ -683,6 +703,8 @@ class Entity(Base): ### 5.4 Pydantic Schemas +> 📖 **[See database schemas guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/database/schemas/)** + Inside `app/schemas`, create a new `entity.py` for each new entity (replacing entity with the name) and create the schemas according to [Pydantic V2](https://docs.pydantic.dev/latest/#pydantic-examples) standards: ```python @@ -731,6 +753,8 @@ class EntityDelete(BaseModel): ### 5.5 Alembic Migrations +> 📖 **[See database migrations guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/database/migrations/)** + > \[!WARNING\] > To create the tables if you did not create the endpoints, ensure that you import the models in src/app/models/__init__.py. This step is crucial to create the new models. @@ -748,6 +772,8 @@ uv run alembic upgrade head ### 5.6 CRUD +> 📖 **[See CRUD operations guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/database/crud/)** + Inside `app/crud`, create a new `crud_entities.py` inheriting from `FastCRUD` for each new entity: ```python @@ -953,6 +979,8 @@ crud_user.get(db=db, username="myusername", schema_to_select=UserRead) ### 5.7 Routes +> 📖 **[See API endpoints guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/api/endpoints/)** + Inside `app/api/v1`, create a new `entities.py` file and create the desired routes ```python @@ -993,6 +1021,8 @@ router.include_router(entity_router) #### 5.7.1 Paginated Responses +> 📖 **[See API pagination guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/api/pagination/)** + With the `get_multi` method we get a python `dict` with full suport for pagination: ```javascript @@ -1057,6 +1087,8 @@ async def read_entities( #### 5.7.2 HTTP Exceptions +> 📖 **[See API exceptions guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/api/exceptions/)** + To add exceptions you may just import from `app/core/exceptions/http_exceptions` and optionally add a detail: ```python @@ -1084,6 +1116,8 @@ if not post: ### 5.8 Caching +> 📖 **[See comprehensive caching guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/caching/)** + The `cache` decorator allows you to cache the results of FastAPI endpoint functions, enhancing response times and reducing the load on your application by storing and retrieving data in a cache. Caching the response of an endpoint is really simple, just apply the `cache` decorator to the endpoint function. @@ -1247,6 +1281,8 @@ For `client-side caching`, all you have to do is let the `Settings` class define ### 5.10 ARQ Job Queues +> 📖 **[See background tasks guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/background-tasks/)** + Depending on the problem your API is solving, you might want to implement a job queue. A job queue allows you to run tasks in the background, and is usually aimed at functions that require longer run times and don't directly impact user response in your frontend. As a rule of thumb, if a task takes more than 2 seconds to run, can be executed asynchronously, and its result is not needed for the next step of the user's interaction, then it is a good candidate for the job queue. > [!TIP] @@ -1342,6 +1378,8 @@ async def your_background_function( ### 5.11 Rate Limiting +> 📖 **[See rate limiting guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/rate-limiting/)** + To limit how many times a user can make a request in a certain interval of time (very useful to create subscription plans or just to protect your API against DDOS), you may just use the `rate_limiter_dependency` dependency: ```python @@ -1456,6 +1494,8 @@ Note that for flexibility (since this is a boilerplate), it's not necessary to p ### 5.12 JWT Authentication +> 📖 **[See authentication guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/authentication/)** + #### 5.12.1 Details The JWT in this boilerplate is created in the following way: @@ -1641,6 +1681,8 @@ volumes: ## 6. Running in Production +> 📖 **[See production deployment guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/production/)** + ### 6.1 Uvicorn Workers with Gunicorn In production you may want to run using gunicorn to manage uvicorn workers: @@ -1834,6 +1876,8 @@ And finally, on your browser: `http://localhost/docs`. ## 7. Testing +> 📖 **[See comprehensive testing guide in our docs](https://benavlabs.github.io/fastapi-boilerplate/user-guide/testing/)** + This project uses **fast unit tests** that don't require external services like databases or Redis. Tests are isolated using mocks and run in milliseconds. ### 7.1 Writing Tests diff --git a/docs/assets/FastAPI-boilerplate.png b/docs/assets/FastAPI-boilerplate.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7985ad46746b7fb164a55a869a662713d9ab2c GIT binary patch literal 399217 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

YHPG*0IVnR}mwD>*>h*;nL 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%0D(XINZ0?%(yR1 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%0AVY!-5wszB(h$P2|?38NG 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 +``` + +**Database Connection Error**: +```bash +# Check PostgreSQL status +sudo systemctl status postgresql + +# Restart PostgreSQL +sudo systemctl restart postgresql +``` + +**Redis Connection Error**: +```bash +# Check Redis status +redis-cli ping + +# Start Redis +redis-server +``` + +**Permission Errors**: +```bash +# Fix Docker permissions +sudo usermod -aG docker $USER +# Log out and back in +``` + +### Docker Issues + +**Clean Reset**: +```bash +# Stop all containers +docker compose down + +# Remove volumes (⚠️ deletes data) +docker compose down -v + +# Rebuild images +docker compose build --no-cache + +# Start fresh +docker compose up +``` + +## Next Steps + +After successful installation: + +1. **[Configuration Guide](configuration.md)** - Set up your environment +2. **[First Run](first-run.md)** - Test your installation +3. **[Project Structure](../user-guide/project-structure.md)** - Understand the codebase + +## Need Help? + +If you encounter issues: + +- Check the [GitHub Issues](https://github.com/benavlabs/fastapi-boilerplate/issues) for common problems +- Search [existing issues](https://github.com/benavlabs/fastapi-boilerplate/issues) +- Create a [new issue](https://github.com/benavlabs/fastapi-boilerplate/issues/new) with details \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..8e53b0d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,120 @@ +# Benav Labs FastAPI Boilerplate + +

+ Purple Rocket with FastAPI Logo as its window. +

+ +

+ A production-ready FastAPI boilerplate to speed up your development. +

+ +

+ + FastAPI + + + Pydantic + + + PostgreSQL + + + Redis + + + Docker + +

+ +## What is FastAPI Boilerplate? + +FastAPI Boilerplate is a comprehensive, production-ready template that provides everything you need to build scalable, async APIs using modern Python technologies. It combines the power of FastAPI with industry best practices to give you a solid foundation for your next project. + +## Core Technologies + +This boilerplate leverages cutting-edge Python technologies: + +- **[FastAPI](https://fastapi.tiangolo.com)** - Modern, fast web framework for building APIs with Python 3.7+ +- **[Pydantic V2](https://docs.pydantic.dev/2.4/)** - Data validation library rewritten in Rust (5x-50x faster) +- **[SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)** - Python SQL toolkit and Object Relational Mapper +- **[PostgreSQL](https://www.postgresql.org)** - Advanced open source relational database +- **[Redis](https://redis.io)** - In-memory data store for caching and message brokering +- **[ARQ](https://arq-docs.helpmanual.io)** - Job queues and RPC with asyncio and Redis +- **[Docker](https://docs.docker.com/compose/)** - Containerization for easy deployment +- **[NGINX](https://nginx.org/en/)** - High-performance web server for reverse proxy and load balancing + +## Key Features + +### Performance & Scalability +- Fully async architecture +- Pydantic V2 for ultra-fast data validation +- SQLAlchemy 2.0 with efficient query patterns +- Built-in caching with Redis +- Horizontal scaling with NGINX load balancing + +### Security & Authentication +- JWT-based authentication with refresh tokens +- Cookie-based secure token storage +- Role-based access control with user tiers +- Rate limiting to prevent abuse +- Production-ready security configurations + +### Developer Experience +- Comprehensive CRUD operations with [FastCRUD](https://github.com/igorbenav/fastcrud) +- Automatic API documentation +- Database migrations with Alembic +- Background task processing +- Extensive test coverage +- Docker Compose for easy development + +### Production Ready +- Environment-based configuration +- Structured logging +- Health checks and monitoring +- NGINX reverse proxy setup +- Gunicorn with Uvicorn workers +- Database connection pooling + +## Quick Start + +Get up and running in less than 5 minutes: + +```bash +# Clone the repository +git clone https://github.com/benavlabs/fastapi-boilerplate +cd fastapi-boilerplate + +# Start with Docker Compose +docker compose up +``` + +That's it! Your API will be available at `http://localhost:8000/docs` + +**[Continue with the Getting Started Guide →](getting-started/index.md)** + +## Documentation Structure + +### For New Users +- **[Getting Started](getting-started/index.md)** - Quick setup and first steps +- **[User Guide](user-guide/index.md)** - Comprehensive feature documentation + +### For Developers +- **[Development](user-guide/development.md)** - Extending and customizing the boilerplate +- **[Testing](user-guide/testing.md)** - Testing strategies and best practices +- **[Production](user-guide/production.md)** - Production deployment guides + +## Perfect For + +- **REST APIs** - Build robust, scalable REST APIs +- **Microservices** - Create microservice architectures +- **Smll Applications** - Multi-tenant applications with user tiers +- **Data APIs** - APIs for data processing and analytics + +## Community & Support + +- **[GitHub Issues](https://github.com/benavlabs/fastapi-boilerplate/issues)** - Bug reports and feature requests + +
+ + Powered by Benav Labs - benav.io + \ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..578f14c --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,20 @@ +/* Make only the header/favicon logo white, keep other instances purple */ +.md-header__button.md-logo img, +.md-nav__button.md-logo img { + filter: brightness(0) invert(1); +} + +/* Ensure header logo is white in both light and dark modes */ +[data-md-color-scheme="default"] .md-header__button.md-logo img, +[data-md-color-scheme="default"] .md-nav__button.md-logo img { + filter: brightness(0) invert(1); +} + +[data-md-color-scheme="slate"] .md-header__button.md-logo img, +[data-md-color-scheme="slate"] .md-nav__button.md-logo img { + filter: brightness(0) invert(1); +} + +:root { + --md-primary-fg-color: #cd4bfb; +} \ No newline at end of file diff --git a/docs/user-guide/api/endpoints.md b/docs/user-guide/api/endpoints.md new file mode 100644 index 0000000..e6c0118 --- /dev/null +++ b/docs/user-guide/api/endpoints.md @@ -0,0 +1,328 @@ +# 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 + +@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 +- **[Database Schemas](../database/schemas.md)** - Create schemas for your data +- **[CRUD Operations](../database/crud.md)** - Understand the CRUD layer + +The boilerplate provides everything you need - just follow these patterns! \ No newline at end of file diff --git a/docs/user-guide/api/exceptions.md b/docs/user-guide/api/exceptions.md new file mode 100644 index 0000000..d15ac15 --- /dev/null +++ b/docs/user-guide/api/exceptions.md @@ -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 +- **[Database CRUD](../database/crud.md)** - Understand the database operations +- **[Authentication](../authentication/index.md)** - Add user authentication to your APIs + +Proper error handling makes your API much more user-friendly and easier to debug! \ No newline at end of file diff --git a/docs/user-guide/api/index.md b/docs/user-guide/api/index.md new file mode 100644 index 0000000..e76860e --- /dev/null +++ b/docs/user-guide/api/index.md @@ -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. \ No newline at end of file diff --git a/docs/user-guide/api/pagination.md b/docs/user-guide/api/pagination.md new file mode 100644 index 0000000..26ffb5b --- /dev/null +++ b/docs/user-guide/api/pagination.md @@ -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! \ No newline at end of file diff --git a/docs/user-guide/api/versioning.md b/docs/user-guide/api/versioning.md new file mode 100644 index 0000000..299fa62 --- /dev/null +++ b/docs/user-guide/api/versioning.md @@ -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! \ No newline at end of file diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md new file mode 100644 index 0000000..a78f380 --- /dev/null +++ b/docs/user-guide/authentication/index.md @@ -0,0 +1,198 @@ +# Authentication & Security + +Learn how to implement secure authentication in your FastAPI application. The boilerplate provides a complete JWT-based authentication system with user management, permissions, and security best practices. + +## What You'll Learn + +- **[JWT Tokens](jwt-tokens.md)** - Understand access and refresh token management +- **[User Management](user-management.md)** - Handle registration, login, and user profiles +- **[Permissions](permissions.md)** - Implement role-based access control and authorization + +## Authentication Overview + +The system uses JWT tokens with refresh token rotation for secure, stateless authentication: + +```python +# Basic login flow +@router.post("/login", response_model=Token) +async def login_for_access_token(response: Response, form_data: OAuth2PasswordRequestForm): + user = await authenticate_user(form_data.username, form_data.password, db) + access_token = await create_access_token(data={"sub": user["username"]}) + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + + # Set secure HTTP-only cookie for refresh token + response.set_cookie("refresh_token", refresh_token, httponly=True, secure=True) + return {"access_token": access_token, "token_type": "bearer"} +``` + +## Key Features + +### JWT Token System +- **Access tokens**: Short-lived (30 minutes), for API requests +- **Refresh tokens**: Long-lived (7 days), stored in secure cookies +- **Token blacklisting**: Secure logout implementation +- **Automatic expiration**: Built-in token lifecycle management + +### User Management +- **Flexible authentication**: Username or email login +- **Secure passwords**: bcrypt hashing with salt +- **Profile management**: Complete user CRUD operations +- **Soft delete**: User deactivation without data loss + +### Permission System +- **Superuser privileges**: Administrative access control +- **Resource ownership**: User-specific data access +- **User tiers**: Subscription-based feature access +- **Rate limiting**: Per-user and per-tier API limits + +## Authentication Patterns + +### Endpoint Protection + +```python +# Required authentication +@router.get("/protected") +async def protected_endpoint(current_user: dict = Depends(get_current_user)): + return {"message": f"Hello {current_user['username']}"} + +# Optional authentication +@router.get("/public") +async def public_endpoint(user: dict | None = Depends(get_optional_user)): + if user: + return {"premium_content": True} + return {"premium_content": False} + +# Superuser only +@router.get("/admin", dependencies=[Depends(get_current_superuser)]) +async def admin_endpoint(): + return {"admin_data": "sensitive"} +``` + +### Resource Ownership + +```python +@router.patch("/posts/{post_id}") +async def update_post(post_id: int, current_user: dict = Depends(get_current_user)): + post = await crud_posts.get(db=db, id=post_id) + + # Check ownership or admin privileges + if post["created_by_user_id"] != current_user["id"] and not current_user["is_superuser"]: + raise ForbiddenException("Cannot update other users' posts") + + return await crud_posts.update(db=db, id=post_id, object=updates) +``` + +## Security Features + +### Token Security +- Short-lived access tokens limit exposure +- HTTP-only refresh token cookies prevent XSS +- Token blacklisting enables secure logout +- Configurable token expiration times + +### Password Security +- bcrypt hashing with automatic salt generation +- Configurable password complexity requirements +- No plain text passwords stored anywhere +- Rate limiting on authentication endpoints + +### API Protection +- CORS policies for cross-origin request control +- Rate limiting prevents brute force attacks +- Input validation prevents injection attacks +- Consistent error messages prevent information disclosure + +## Configuration + +### JWT Settings +```env +SECRET_KEY="your-super-secret-key-here" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +### Security Settings +```env +# Cookie security +COOKIE_SECURE=true +COOKIE_SAMESITE="lax" + +# Password requirements +PASSWORD_MIN_LENGTH=8 +ENABLE_PASSWORD_COMPLEXITY=true +``` + +## Getting Started + +Follow this progressive learning path: + +### 1. **[JWT Tokens](jwt-tokens.md)** - Foundation +Understand how JWT tokens work, including access and refresh token management, verification, and blacklisting. + +### 2. **[User Management](user-management.md)** - Core Features +Implement user registration, login, profile management, and administrative operations. + +### 3. **[Permissions](permissions.md)** - Access Control +Set up role-based access control, resource ownership checking, and tier-based permissions. + +## Implementation Examples + +### Quick Authentication Setup + +```python +# Protect an endpoint +@router.get("/my-data") +async def get_my_data(current_user: dict = Depends(get_current_user)): + return await get_user_specific_data(current_user["id"]) + +# Check user permissions +def check_tier_access(user: dict, required_tier: str): + if not user.get("tier") or user["tier"]["name"] != required_tier: + raise ForbiddenException(f"Requires {required_tier} tier") + +# Custom authentication dependency +async def get_premium_user(current_user: dict = Depends(get_current_user)): + check_tier_access(current_user, "Pro") + return current_user +``` + +### Frontend Integration + +```javascript +// Basic authentication flow +class AuthManager { + async login(username, password) { + const response = await fetch('/api/v1/login', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({username, password}) + }); + + const tokens = await response.json(); + localStorage.setItem('access_token', tokens.access_token); + return tokens; + } + + async makeAuthenticatedRequest(url, options = {}) { + const token = localStorage.getItem('access_token'); + return fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${token}` + } + }); + } +} +``` + +## What's Next + +Start building your authentication system: + +1. **[JWT Tokens](jwt-tokens.md)** - Learn token creation, verification, and lifecycle management +2. **[User Management](user-management.md)** - Implement registration, login, and profile operations +3. **[Permissions](permissions.md)** - Add authorization patterns and access control + +The authentication system provides a secure foundation for your API. Each guide includes practical examples and implementation details for production-ready authentication. \ No newline at end of file diff --git a/docs/user-guide/authentication/jwt-tokens.md b/docs/user-guide/authentication/jwt-tokens.md new file mode 100644 index 0000000..1b4d30c --- /dev/null +++ b/docs/user-guide/authentication/jwt-tokens.md @@ -0,0 +1,669 @@ +# JWT Tokens + +JSON Web Tokens (JWT) form the backbone of modern web authentication. This comprehensive guide explains how the boilerplate implements a secure, stateless authentication system using access and refresh tokens. + +## Understanding JWT Authentication + +JWT tokens are self-contained, digitally signed packages of information that can be safely transmitted between parties. Unlike traditional session-based authentication that requires server-side storage, JWT tokens are stateless - all the information needed to verify a user's identity is contained within the token itself. + +### Why Use JWT? + +**Stateless Design**: No need to store session data on the server, making it perfect for distributed systems and microservices. + +**Scalability**: Since tokens contain all necessary information, they work seamlessly across multiple servers without shared session storage. + +**Security**: Digital signatures ensure tokens can't be tampered with, and expiration times limit exposure if compromised. + +**Cross-Domain Support**: Unlike cookies, JWT tokens work across different domains and can be used in mobile applications. + +## Token Types + +The authentication system uses a **dual-token approach** for maximum security and user experience: + +### Access Tokens +Access tokens are short-lived credentials that prove a user's identity for API requests. Think of them as temporary keys that grant access to protected resources. + +- **Purpose**: Authenticate API requests and authorize actions +- **Lifetime**: 30 minutes (configurable) - short enough to limit damage if compromised +- **Storage**: Authorization header (`Bearer `) - sent with each API request +- **Usage**: Include in every call to protected endpoints + +**Why Short-Lived?** If an access token is stolen (e.g., through XSS), the damage window is limited to 30 minutes before it expires naturally. + +### Refresh Tokens +Refresh tokens are longer-lived credentials used solely to generate new access tokens. They provide a balance between security and user convenience. + +- **Purpose**: Generate new access tokens without requiring re-login +- **Lifetime**: 7 days (configurable) - long enough for good UX, short enough for security +- **Storage**: Secure HTTP-only cookie - inaccessible to JavaScript, preventing XSS attacks +- **Usage**: Automatically used by the browser when access tokens need refreshing + +**Why HTTP-Only Cookies?** This prevents malicious JavaScript from accessing refresh tokens, providing protection against XSS attacks while allowing automatic renewal. + +## Token Creation + +Understanding how tokens are created helps you customize the authentication system for your specific needs. + +### Creating Access Tokens + +Access tokens are generated during login and token refresh operations. The process involves encoding user information with an expiration time and signing it with your secret key. + +```python +from datetime import timedelta +from app.core.security import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES + +# Basic access token with default expiration +access_token = await create_access_token(data={"sub": username}) + +# Custom expiration for special cases (e.g., admin sessions) +custom_expires = timedelta(minutes=60) +access_token = await create_access_token( + data={"sub": username}, + expires_delta=custom_expires +) +``` + +**When to Customize Expiration:** +- **High-security environments**: Shorter expiration (15 minutes) +- **Development/testing**: Longer expiration for convenience +- **Admin operations**: Variable expiration based on sensitivity + +### Creating Refresh Tokens + +Refresh tokens follow the same creation pattern but with longer expiration times. They're typically created only during login. + +```python +from app.core.security import create_refresh_token, REFRESH_TOKEN_EXPIRE_DAYS + +# Standard refresh token +refresh_token = await create_refresh_token(data={"sub": username}) + +# Extended refresh token for "remember me" functionality +extended_expires = timedelta(days=30) +refresh_token = await create_refresh_token( + data={"sub": username}, + expires_delta=extended_expires +) +``` + +### Token Structure + +JWT tokens consist of three parts separated by dots: `header.payload.signature`. The payload contains the actual user information and metadata. + +```python +# Access token payload structure +{ + "sub": "username", # Subject (user identifier) + "exp": 1234567890, # Expiration timestamp (Unix) + "token_type": "access", # Distinguishes from refresh tokens + "iat": 1234567890 # Issued at (automatic) +} + +# Refresh token payload structure +{ + "sub": "username", # Same user identifier + "exp": 1234567890, # Longer expiration time + "token_type": "refresh", # Prevents confusion/misuse + "iat": 1234567890 # Issue timestamp +} +``` + +**Key Fields Explained:** +- **`sub` (Subject)**: Identifies the user - can be username, email, or user ID +- **`exp` (Expiration)**: Unix timestamp when token becomes invalid +- **`token_type`**: Custom field preventing tokens from being used incorrectly +- **`iat` (Issued At)**: Useful for token rotation and audit trails + +## Token Verification + +Token verification is a multi-step process that ensures both the token's authenticity and the user's current authorization status. + +### Verifying Access Tokens + +Every protected endpoint must verify the access token before processing the request. This involves checking the signature, expiration, and blacklist status. + +```python +from app.core.security import verify_token, TokenType + +# Verify access token in endpoint +token_data = await verify_token(token, TokenType.ACCESS, db) +if token_data: + username = token_data.username_or_email + # Token is valid, proceed with request processing +else: + # Token is invalid, expired, or blacklisted + raise UnauthorizedException("Invalid or expired token") +``` + +### Verifying Refresh Tokens + +Refresh token verification follows the same process but with different validation rules and outcomes. + +```python +# Verify refresh token for renewal +token_data = await verify_token(token, TokenType.REFRESH, db) +if token_data: + # Generate new access token + new_access_token = await create_access_token( + data={"sub": token_data.username_or_email} + ) + return {"access_token": new_access_token, "token_type": "bearer"} +else: + # Refresh token invalid - user must log in again + raise UnauthorizedException("Invalid refresh token") +``` + +### Token Verification Process + +The verification process includes several security checks to prevent various attack vectors: + +```python +async def verify_token(token: str, expected_token_type: TokenType, db: AsyncSession) -> TokenData | None: + # 1. Check blacklist first (prevents use of logged-out tokens) + is_blacklisted = await crud_token_blacklist.exists(db, token=token) + if is_blacklisted: + return None + + try: + # 2. Verify signature and decode payload + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + + # 3. Extract and validate claims + username_or_email: str | None = payload.get("sub") + token_type: str | None = payload.get("token_type") + + # 4. Ensure token type matches expectation + if username_or_email is None or token_type != expected_token_type: + return None + + # 5. Return validated data + return TokenData(username_or_email=username_or_email) + + except JWTError: + # Token is malformed, expired, or signature invalid + return None +``` + +**Security Checks Explained:** + +1. **Blacklist Check**: Prevents use of tokens from logged-out users +2. **Signature Verification**: Ensures token hasn't been tampered with +3. **Expiration Check**: Automatically handled by JWT library +4. **Type Validation**: Prevents refresh tokens from being used as access tokens +5. **Subject Validation**: Ensures token contains valid user identifier + +## Client-Side Authentication Flow + +Understanding the complete authentication flow helps frontend developers integrate properly with the API. + +### Recommended Client Flow + +**1. Login Process** +```javascript +// Send credentials to login endpoint +const response = await fetch('/api/v1/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=user&password=pass', + credentials: 'include' // Important: includes cookies +}); + +const { access_token, token_type } = await response.json(); + +// Store access token in memory (not localStorage) +sessionStorage.setItem('access_token', access_token); +``` + +**2. Making Authenticated Requests** +```javascript +// Include access token in Authorization header +const response = await fetch('/api/v1/protected-endpoint', { + headers: { + 'Authorization': `Bearer ${sessionStorage.getItem('access_token')}` + }, + credentials: 'include' +}); +``` + +**3. Handling Token Expiration** +```javascript +// Automatic token refresh on 401 errors +async function apiCall(url, options = {}) { + let response = await fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${sessionStorage.getItem('access_token')}` + }, + credentials: 'include' + }); + + // If token expired, try to refresh + if (response.status === 401) { + const refreshResponse = await fetch('/api/v1/refresh', { + method: 'POST', + credentials: 'include' // Sends refresh token cookie + }); + + if (refreshResponse.ok) { + const { access_token } = await refreshResponse.json(); + sessionStorage.setItem('access_token', access_token); + + // Retry original request + response = await fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${access_token}` + }, + credentials: 'include' + }); + } else { + // Refresh failed - redirect to login + window.location.href = '/login'; + } + } + + return response; +} +``` + +**4. Logout Process** +```javascript +// Clear tokens and call logout endpoint +await fetch('/api/v1/logout', { + method: 'POST', + credentials: 'include' +}); + +sessionStorage.removeItem('access_token'); +// Refresh token cookie is cleared by server +``` + +### Cookie Configuration + +The refresh token cookie is configured for maximum security: + +```python +response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, # Prevents JavaScript access (XSS protection) + secure=True, # HTTPS only in production + samesite="Lax", # CSRF protection with good usability + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 +) +``` + +**SameSite Options:** + +- **`Lax`** (Recommended): Cookies sent on top-level navigation but not cross-site requests +- **`Strict`**: Maximum security but may break some user flows +- **`None`**: Required for cross-origin requests (must use with Secure) + +## Token Blacklisting + +Token blacklisting solves a fundamental problem with JWT tokens: once issued, they remain valid until expiration, even if the user logs out. Blacklisting provides immediate token revocation. + +### Why Blacklisting Matters + +Without blacklisting, logged-out users could continue accessing your API until their tokens naturally expire. This creates security risks, especially on shared computers or if tokens are compromised. + +### Blacklisting Implementation + +The system uses a database table to track invalidated tokens: + +```python +# models/token_blacklist.py +class TokenBlacklist(Base): + __tablename__ = "token_blacklist" + + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(unique=True, index=True) # Full token string + expires_at: Mapped[datetime] = mapped_column() # When to clean up + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) +``` + +**Design Considerations:** +- **Unique constraint**: Prevents duplicate entries +- **Index on token**: Fast lookup during verification +- **Expires_at field**: Enables automatic cleanup of old entries + +### Blacklisting Tokens + +The system provides functions for both single token and dual token blacklisting: + +```python +from app.core.security import blacklist_token, blacklist_tokens + +# Single token blacklisting (for specific scenarios) +await blacklist_token(token, db) + +# Dual token blacklisting (standard logout) +await blacklist_tokens(access_token, refresh_token, db) +``` + +### Blacklisting Process + +The blacklisting process extracts the expiration time from the token to set an appropriate cleanup schedule: + +```python +async def blacklist_token(token: str, db: AsyncSession) -> None: + # 1. Decode token to extract expiration (no verification needed) + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + exp_timestamp = payload.get("exp") + + if exp_timestamp is not None: + # 2. Convert Unix timestamp to datetime + expires_at = datetime.fromtimestamp(exp_timestamp) + + # 3. Store in blacklist with expiration + await crud_token_blacklist.create( + db, + object=TokenBlacklistCreate(token=token, expires_at=expires_at) + ) +``` + +**Cleanup Strategy**: Blacklisted tokens can be automatically removed from the database after their natural expiration time, preventing unlimited database growth. + +## Login Flow Implementation + +### Complete Login Endpoint + +```python +@router.post("/login", response_model=Token) +async def login_for_access_token( + response: Response, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[AsyncSession, Depends(async_get_db)], +) -> dict[str, str]: + # 1. Authenticate user + user = await authenticate_user( + username_or_email=form_data.username, + password=form_data.password, + db=db + ) + + if not user: + raise HTTPException( + status_code=401, + detail="Incorrect username or password" + ) + + # 2. Create access token + access_token = await create_access_token(data={"sub": user["username"]}) + + # 3. Create refresh token + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + + # 4. Set refresh token as HTTP-only cookie + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict", + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + ) + + return {"access_token": access_token, "token_type": "bearer"} +``` + +### Token Refresh Endpoint + +```python +@router.post("/refresh", response_model=Token) +async def refresh_access_token( + response: Response, + db: Annotated[AsyncSession, Depends(async_get_db)], + refresh_token: str = Cookie(None) +) -> dict[str, str]: + if not refresh_token: + raise HTTPException(status_code=401, detail="Refresh token missing") + + # 1. Verify refresh token + token_data = await verify_token(refresh_token, TokenType.REFRESH, db) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + # 2. Create new access token + new_access_token = await create_access_token( + data={"sub": token_data.username_or_email} + ) + + # 3. Optionally create new refresh token (token rotation) + new_refresh_token = await create_refresh_token( + data={"sub": token_data.username_or_email} + ) + + # 4. Blacklist old refresh token + await blacklist_token(refresh_token, db) + + # 5. Set new refresh token cookie + response.set_cookie( + key="refresh_token", + value=new_refresh_token, + httponly=True, + secure=True, + samesite="strict", + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + ) + + return {"access_token": new_access_token, "token_type": "bearer"} +``` + +### Logout Implementation + +```python +@router.post("/logout") +async def logout( + response: Response, + db: Annotated[AsyncSession, Depends(async_get_db)], + current_user: dict = Depends(get_current_user), + token: str = Depends(oauth2_scheme), + refresh_token: str = Cookie(None) +) -> dict[str, str]: + # 1. Blacklist access token + await blacklist_token(token, db) + + # 2. Blacklist refresh token if present + if refresh_token: + await blacklist_token(refresh_token, db) + + # 3. Clear refresh token cookie + response.delete_cookie( + key="refresh_token", + httponly=True, + secure=True, + samesite="strict" + ) + + return {"message": "Successfully logged out"} +``` + +## Authentication Dependencies + +### get_current_user + +```python +async def get_current_user( + db: AsyncSession = Depends(async_get_db), + token: str = Depends(oauth2_scheme) +) -> dict: + # 1. Verify token + token_data = await verify_token(token, TokenType.ACCESS, db) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid token") + + # 2. Get user from database + user = await crud_users.get( + db=db, + username=token_data.username_or_email, + schema_to_select=UserRead + ) + + if user is None: + raise HTTPException(status_code=401, detail="User not found") + + return user +``` + +### get_optional_user + +```python +async def get_optional_user( + db: AsyncSession = Depends(async_get_db), + token: str = Depends(optional_oauth2_scheme) +) -> dict | None: + if not token: + return None + + try: + return await get_current_user(db=db, token=token) + except HTTPException: + return None +``` + +### get_current_superuser + +```python +async def get_current_superuser( + current_user: dict = Depends(get_current_user) +) -> dict: + if not current_user.get("is_superuser", False): + raise HTTPException( + status_code=403, + detail="Not enough permissions" + ) + return current_user +``` + +## Configuration + +### Environment Variables + +```bash +# JWT Configuration +SECRET_KEY=your-secret-key-here +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Security Headers +SECURE_COOKIES=true +CORS_ORIGINS=["http://localhost:3000", "https://yourapp.com"] +``` + +### Security Configuration + +```python +# app/core/config.py +class Settings(BaseSettings): + SECRET_KEY: SecretStr + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # Cookie settings + SECURE_COOKIES: bool = True + COOKIE_DOMAIN: str | None = None + COOKIE_SAMESITE: str = "strict" +``` + +## Security Best Practices + +### Token Security + +- **Use strong secrets**: Generate cryptographically secure SECRET_KEY +- **Rotate secrets**: Regularly change SECRET_KEY in production +- **Environment separation**: Different secrets for dev/staging/production +- **Secure transmission**: Always use HTTPS in production + +### Cookie Security + +- **HttpOnly flag**: Prevents JavaScript access to refresh tokens +- **Secure flag**: Ensures cookies only sent over HTTPS +- **SameSite attribute**: Prevents CSRF attacks +- **Domain restrictions**: Set cookie domain appropriately + +### Implementation Security + +- **Input validation**: Validate all token inputs +- **Rate limiting**: Implement login attempt limits +- **Audit logging**: Log authentication events +- **Token rotation**: Regularly refresh tokens + +## Common Patterns + +### API Key Authentication + +For service-to-service communication: + +```python +async def get_api_key_user( + api_key: str = Header(None), + db: AsyncSession = Depends(async_get_db) +) -> dict: + if not api_key: + raise HTTPException(status_code=401, detail="API key required") + + # Verify API key + user = await crud_users.get(db=db, api_key=api_key) + if not user: + raise HTTPException(status_code=401, detail="Invalid API key") + + return user +``` + +### Multiple Authentication Methods + +```python +async def get_authenticated_user( + db: AsyncSession = Depends(async_get_db), + token: str = Depends(optional_oauth2_scheme), + api_key: str = Header(None) +) -> dict: + # Try JWT token first + if token: + try: + return await get_current_user(db=db, token=token) + except HTTPException: + pass + + # Fall back to API key + if api_key: + return await get_api_key_user(api_key=api_key, db=db) + + raise HTTPException(status_code=401, detail="Authentication required") +``` + +## Troubleshooting + +### Common Issues + +**Token Expired**: Implement automatic refresh using refresh tokens +**Invalid Signature**: Check SECRET_KEY consistency across environments +**Blacklisted Token**: User logged out - redirect to login +**Missing Token**: Ensure Authorization header is properly set + +### Debugging Tips + +```python +# Enable debug logging +import logging +logging.getLogger("app.core.security").setLevel(logging.DEBUG) + +# Test token validation +async def debug_token(token: str, db: AsyncSession): + try: + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + print(f"Token payload: {payload}") + + is_blacklisted = await crud_token_blacklist.exists(db, token=token) + print(f"Is blacklisted: {is_blacklisted}") + + except JWTError as e: + print(f"JWT Error: {e}") +``` + +This comprehensive JWT implementation provides secure, scalable authentication for your FastAPI application. \ No newline at end of file diff --git a/docs/user-guide/authentication/permissions.md b/docs/user-guide/authentication/permissions.md new file mode 100644 index 0000000..c1daddf --- /dev/null +++ b/docs/user-guide/authentication/permissions.md @@ -0,0 +1,634 @@ +# Permissions and Authorization + +Authorization determines what authenticated users can do within your application. While authentication answers "who are you?", authorization answers "what can you do?". This section covers the permission system, access control patterns, and how to implement secure authorization in your endpoints. + +## Understanding Authorization + +Authorization is a multi-layered security concept that protects resources and operations based on user identity, roles, and contextual information. The boilerplate implements several authorization patterns to handle different security requirements. + +### Authorization vs Authentication + +**Authentication**: Verifies user identity - confirms the user is who they claim to be +**Authorization**: Determines user permissions - decides what the authenticated user can access + +These work together: you must authenticate first (prove identity) before you can authorize (check permissions). + +### Authorization Patterns + +The system implements several common authorization patterns: + +1. **Role-Based Access Control (RBAC)**: Users have roles (superuser, regular user) that determine permissions +2. **Resource Ownership**: Users can only access resources they own +3. **Tiered Access**: Different user tiers have different capabilities and limits +4. **Contextual Authorization**: Permissions based on request context (rate limits, time-based access) + +## Core Authorization Patterns + +### Superuser Permissions + +Superusers have elevated privileges for administrative operations. This pattern is essential for system management but must be carefully controlled. + +```python +from app.api.dependencies import get_current_superuser + +# Superuser-only endpoint +@router.get("/admin/users/", dependencies=[Depends(get_current_superuser)]) +async def get_all_users( + db: AsyncSession = Depends(async_get_db) +) -> list[UserRead]: + # Only superusers can access this endpoint + users = await crud_users.get_multi( + db=db, + schema_to_select=UserRead, + return_as_model=True + ) + return users.data +``` + +**When to Use Superuser Authorization:** + +- **User management operations**: Creating, deleting, or modifying other users +- **System configuration**: Changing application settings or configuration +- **Data export/import**: Bulk operations on sensitive data +- **Administrative reporting**: Access to system-wide analytics and logs + +**Security Considerations:** + +- **Minimal Assignment**: Only assign superuser status when absolutely necessary +- **Regular Audits**: Periodically review who has superuser access +- **Activity Logging**: Log all superuser actions for security monitoring +- **Time-Limited Access**: Consider temporary superuser elevation for specific tasks + +### Resource Ownership + +Resource ownership ensures users can only access and modify their own data. This is the most common authorization pattern in user-facing applications. + +```python +@router.get("/posts/me/") +async def get_my_posts( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> list[PostRead]: + # Get posts owned by current user + posts = await crud_posts.get_multi( + db=db, + created_by_user_id=current_user["id"], + schema_to_select=PostRead, + return_as_model=True + ) + return posts.data + +@router.delete("/posts/{post_id}") +async def delete_post( + post_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> dict[str, str]: + # 1. Get the post + post = await crud_posts.get(db=db, id=post_id) + if not post: + raise NotFoundException("Post not found") + + # 2. Check ownership + if post["created_by_user_id"] != current_user["id"]: + raise ForbiddenException("You can only delete your own posts") + + # 3. Delete the post + await crud_posts.delete(db=db, id=post_id) + return {"message": "Post deleted"} +``` + +**Ownership Validation Pattern:** + +1. **Retrieve Resource**: Get the resource from the database +2. **Check Ownership**: Compare resource owner with current user +3. **Authorize or Deny**: Allow action if user owns resource, deny otherwise + +### User Tiers and Rate Limiting + +User tiers provide differentiated access based on subscription levels or user status. This enables business models with different feature sets for different user types. + +```python +@router.post("/posts/", response_model=PostRead) +async def create_post( + post: PostCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> PostRead: + # Check rate limits based on user tier + await check_rate_limit( + resource="posts", + user_id=current_user["id"], + tier_id=current_user.get("tier_id"), + db=db + ) + + # Create post with user association + post_internal = PostCreateInternal( + **post.model_dump(), + created_by_user_id=current_user["id"] + ) + + created_post = await crud_posts.create(db=db, object=post_internal) + return created_post +``` + +**Rate Limiting Implementation:** + +```python +async def check_rate_limit( + resource: str, + user_id: int, + tier_id: int | None, + db: AsyncSession +) -> None: + # 1. Get user's tier information + if tier_id: + tier = await crud_tiers.get(db=db, id=tier_id) + limit = tier["rate_limit_posts"] if tier else 10 # Default limit + else: + limit = 5 # Free tier limit + + # 2. Count recent posts (last 24 hours) + recent_posts = await crud_posts.count( + db=db, + created_by_user_id=user_id, + created_at__gte=datetime.utcnow() - timedelta(hours=24) + ) + + # 3. Check if limit exceeded + if recent_posts >= limit: + raise RateLimitException(f"Daily {resource} limit exceeded ({limit})") +``` + +**Tier-Based Authorization Benefits:** + +- **Business Model Support**: Different features for different subscription levels +- **Resource Protection**: Prevents abuse by limiting free tier usage +- **Progressive Enhancement**: Encourages upgrades by showing tier benefits +- **Fair Usage**: Ensures equitable resource distribution among users + +### Custom Permission Helpers + +Custom permission functions provide reusable authorization logic for complex scenarios. + +```python +# Permission helper functions +async def can_edit_post(user: dict, post_id: int, db: AsyncSession) -> bool: + """Check if user can edit a specific post.""" + post = await crud_posts.get(db=db, id=post_id) + if not post: + return False + + # Superusers can edit any post + if user.get("is_superuser", False): + return True + + # Users can edit their own posts + if post["created_by_user_id"] == user["id"]: + return True + + return False + +async def can_access_admin_panel(user: dict) -> bool: + """Check if user can access admin panel.""" + return user.get("is_superuser", False) + +async def has_tier_feature(user: dict, feature: str, db: AsyncSession) -> bool: + """Check if user's tier includes a specific feature.""" + tier_id = user.get("tier_id") + if not tier_id: + return False # Free tier - no premium features + + tier = await crud_tiers.get(db=db, id=tier_id) + if not tier: + return False + + # Check tier features (example) + return tier.get(f"allows_{feature}", False) + +# Usage in endpoints +@router.put("/posts/{post_id}") +async def update_post( + post_id: int, + post_updates: PostUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> PostRead: + # Use permission helper + if not await can_edit_post(current_user, post_id, db): + raise ForbiddenException("Cannot edit this post") + + updated_post = await crud_posts.update( + db=db, + object=post_updates, + id=post_id + ) + return updated_post +``` + +**Permission Helper Benefits:** + +- **Reusability**: Same logic used across multiple endpoints +- **Consistency**: Ensures uniform permission checking +- **Maintainability**: Changes to permissions only need updates in one place +- **Testability**: Permission logic can be unit tested separately + +## Authorization Dependencies + +### Basic Authorization Dependencies + +```python +# Required authentication +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(async_get_db) +) -> dict: + """Get currently authenticated user.""" + token_data = await verify_token(token, TokenType.ACCESS, db) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid token") + + user = await crud_users.get(db=db, username=token_data.username_or_email) + if not user: + raise HTTPException(status_code=401, detail="User not found") + + return user + +# Optional authentication +async def get_optional_user( + token: str = Depends(optional_oauth2_scheme), + db: AsyncSession = Depends(async_get_db) +) -> dict | None: + """Get currently authenticated user, or None if not authenticated.""" + if not token: + return None + + try: + return await get_current_user(token=token, db=db) + except HTTPException: + return None + +# Superuser requirement +async def get_current_superuser( + current_user: dict = Depends(get_current_user) +) -> dict: + """Get current user and ensure they are a superuser.""" + if not current_user.get("is_superuser", False): + raise HTTPException(status_code=403, detail="Not enough permissions") + return current_user +``` + +### Advanced Authorization Dependencies + +```python +# Tier-based access control +def require_tier(minimum_tier: str): + """Factory function for tier-based dependencies.""" + async def check_user_tier( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) + ) -> dict: + tier_id = current_user.get("tier_id") + if not tier_id: + raise HTTPException(status_code=403, detail="No subscription tier") + + tier = await crud_tiers.get(db=db, id=tier_id) + if not tier or tier["name"] != minimum_tier: + raise HTTPException( + status_code=403, + detail=f"Requires {minimum_tier} tier" + ) + + return current_user + + return check_user_tier + +# Resource ownership dependency +def require_resource_ownership(resource_type: str): + """Factory function for resource ownership dependencies.""" + async def check_ownership( + resource_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) + ) -> dict: + if resource_type == "post": + resource = await crud_posts.get(db=db, id=resource_id) + owner_field = "created_by_user_id" + else: + raise ValueError(f"Unknown resource type: {resource_type}") + + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + + # Superusers can access any resource + if current_user.get("is_superuser", False): + return current_user + + # Check ownership + if resource[owner_field] != current_user["id"]: + raise HTTPException( + status_code=403, + detail="You don't own this resource" + ) + + return current_user + + return check_ownership + +# Usage examples +@router.get("/premium-feature", dependencies=[Depends(require_tier("Premium"))]) +async def premium_feature(): + return {"message": "Premium feature accessed"} + +@router.put("/posts/{post_id}") +async def update_post( + post_id: int, + post_update: PostUpdate, + current_user: dict = Depends(require_resource_ownership("post")), + db: AsyncSession = Depends(async_get_db) +) -> PostRead: + # User ownership already verified by dependency + updated_post = await crud_posts.update(db=db, object=post_update, id=post_id) + return updated_post +``` + +## Security Best Practices + +### Principle of Least Privilege + +Always grant the minimum permissions necessary for users to complete their tasks. + +**Implementation:** + +- **Default Deny**: Start with no permissions and explicitly grant what's needed +- **Regular Review**: Periodically audit user permissions and remove unnecessary access +- **Role Segregation**: Separate administrative and user-facing permissions +- **Temporary Elevation**: Use temporary permissions for one-time administrative tasks + +### Defense in Depth + +Implement multiple layers of authorization checks throughout your application. + +**Authorization Layers:** + +1. **API Gateway**: Route-level permission checks +2. **Endpoint Dependencies**: FastAPI dependency injection for common patterns +3. **Business Logic**: Method-level permission validation +4. **Database**: Row-level security where applicable + +### Input Validation and Sanitization + +Always validate and sanitize user input, even from authorized users. + +```python +@router.post("/admin/users/{user_id}/tier") +async def update_user_tier( + user_id: int, + tier_update: UserTierUpdate, + current_user: dict = Depends(get_current_superuser), + db: AsyncSession = Depends(async_get_db) +) -> dict[str, str]: + # 1. Validate tier exists + tier = await crud_tiers.get(db=db, id=tier_update.tier_id) + if not tier: + raise NotFoundException("Tier not found") + + # 2. Validate user exists + user = await crud_users.get(db=db, id=user_id) + if not user: + raise NotFoundException("User not found") + + # 3. Prevent self-demotion (optional business rule) + if user_id == current_user["id"] and tier["name"] == "free": + raise ForbiddenException("Cannot demote yourself to free tier") + + # 4. Update user tier + await crud_users.update( + db=db, + object={"tier_id": tier_update.tier_id}, + id=user_id + ) + + return {"message": f"User tier updated to {tier['name']}"} +``` + +### Audit Logging + +Log all significant authorization decisions for security monitoring and compliance. + +```python +import logging + +security_logger = logging.getLogger("security") + +async def log_authorization_event( + user_id: int, + action: str, + resource: str, + result: str, + details: dict = None +): + """Log authorization events for security auditing.""" + security_logger.info( + f"Authorization {result}: User {user_id} attempted {action} on {resource}", + extra={ + "user_id": user_id, + "action": action, + "resource": resource, + "result": result, + "details": details or {} + } + ) + +# Usage in permission checks +async def delete_user_account(user_id: int, current_user: dict, db: AsyncSession): + if current_user["id"] != user_id and not current_user.get("is_superuser"): + await log_authorization_event( + user_id=current_user["id"], + action="delete_account", + resource=f"user:{user_id}", + result="denied", + details={"reason": "insufficient_permissions"} + ) + raise ForbiddenException("Cannot delete other users' accounts") + + await log_authorization_event( + user_id=current_user["id"], + action="delete_account", + resource=f"user:{user_id}", + result="granted" + ) + + # Proceed with deletion + await crud_users.delete(db=db, id=user_id) +``` + +## Common Authorization Patterns + +### Multi-Tenant Authorization + +For applications serving multiple organizations or tenants: + +```python +@router.get("/organizations/{org_id}/users/") +async def get_organization_users( + org_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> list[UserRead]: + # Check if user belongs to organization + membership = await crud_org_members.get( + db=db, + organization_id=org_id, + user_id=current_user["id"] + ) + + if not membership: + raise ForbiddenException("Not a member of this organization") + + # Check if user has admin role in organization + if membership.role not in ["admin", "owner"]: + raise ForbiddenException("Insufficient organization permissions") + + # Get organization users + users = await crud_users.get_multi( + db=db, + organization_id=org_id, + schema_to_select=UserRead, + return_as_model=True + ) + + return users.data +``` + +### Time-Based Permissions + +For permissions that change based on time or schedule: + +```python +from datetime import datetime, time + +async def check_business_hours_access(user: dict) -> bool: + """Check if user can access during business hours only.""" + now = datetime.now() + business_start = time(9, 0) # 9 AM + business_end = time(17, 0) # 5 PM + + # Superusers can always access + if user.get("is_superuser", False): + return True + + # Regular users only during business hours + current_time = now.time() + return business_start <= current_time <= business_end + +# Usage in dependency +async def require_business_hours( + current_user: dict = Depends(get_current_user) +) -> dict: + """Require access during business hours for non-admin users.""" + if not await check_business_hours_access(current_user): + raise ForbiddenException("Access only allowed during business hours") + return current_user + +@router.post("/business-operation", dependencies=[Depends(require_business_hours)]) +async def business_operation(): + return {"message": "Business operation completed"} +``` + +### Role-Based Access Control (RBAC) + +For more complex permission systems: + +```python +# Role definitions +class Role(str, Enum): + USER = "user" + MODERATOR = "moderator" + ADMIN = "admin" + SUPERUSER = "superuser" + +# Permission checking +def has_role(user: dict, required_role: Role) -> bool: + """Check if user has required role or higher.""" + role_hierarchy = { + Role.USER: 0, + Role.MODERATOR: 1, + Role.ADMIN: 2, + Role.SUPERUSER: 3 + } + + user_role = Role(user.get("role", "user")) + return role_hierarchy[user_role] >= role_hierarchy[required_role] + +# Role-based dependency +def require_role(minimum_role: Role): + """Factory for role-based dependencies.""" + async def check_role(current_user: dict = Depends(get_current_user)) -> dict: + if not has_role(current_user, minimum_role): + raise HTTPException( + status_code=403, + detail=f"Requires {minimum_role.value} role or higher" + ) + return current_user + + return check_role + +# Usage +@router.delete("/posts/{post_id}", dependencies=[Depends(require_role(Role.MODERATOR))]) +async def moderate_delete_post(post_id: int, db: AsyncSession = Depends(async_get_db)): + await crud_posts.delete(db=db, id=post_id) + return {"message": "Post deleted by moderator"} +``` + +### Feature Flags and Permissions + +For gradual feature rollouts: + +```python +async def has_feature_access(user: dict, feature: str, db: AsyncSession) -> bool: + """Check if user has access to a specific feature.""" + # Check feature flags + feature_flag = await crud_feature_flags.get(db=db, name=feature) + if not feature_flag or not feature_flag.enabled: + return False + + # Check user tier permissions + if feature_flag.requires_tier: + tier_id = user.get("tier_id") + if not tier_id: + return False + + tier = await crud_tiers.get(db=db, id=tier_id) + if not tier or tier["level"] < feature_flag["minimum_tier_level"]: + return False + + # Check beta user status + if feature_flag.beta_only: + return user.get("is_beta_user", False) + + return True + +# Feature flag dependency +def require_feature(feature_name: str): + """Factory for feature flag dependencies.""" + async def check_feature_access( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) + ) -> dict: + if not await has_feature_access(current_user, feature_name, db): + raise HTTPException( + status_code=403, + detail=f"Access to {feature_name} feature not available" + ) + return current_user + + return check_feature_access + +@router.get("/beta-feature", dependencies=[Depends(require_feature("beta_analytics"))]) +async def get_beta_analytics(): + return {"analytics": "beta_data"} +``` + +This comprehensive permissions system provides flexible, secure authorization patterns that can be adapted to your specific application requirements while maintaining security best practices. diff --git a/docs/user-guide/authentication/user-management.md b/docs/user-guide/authentication/user-management.md new file mode 100644 index 0000000..af2be65 --- /dev/null +++ b/docs/user-guide/authentication/user-management.md @@ -0,0 +1,879 @@ +# User Management + +User management forms the core of any authentication system, handling everything from user registration and login to profile updates and account deletion. This section covers the complete user lifecycle with secure authentication flows and administrative operations. + +## Understanding User Lifecycle + +The user lifecycle in the boilerplate follows a secure, well-defined process that protects user data while providing a smooth experience. Understanding this flow helps you customize the system for your specific needs. + +**Registration → Authentication → Profile Management → Administrative Operations** + +Each stage has specific security considerations and business logic that ensure data integrity and user safety. + +## User Registration + +User registration is the entry point to your application. The process must be secure, user-friendly, and prevent common issues like duplicate accounts or weak passwords. + +### Registration Process + +The registration endpoint performs several validation steps before creating a user account. This multi-step validation prevents common registration issues and ensures data quality. + +```python +# User registration endpoint +@router.post("/user", response_model=UserRead, status_code=201) +async def write_user( + user: UserCreate, + db: AsyncSession +) -> UserRead: + # 1. Check if email exists + email_row = await crud_users.exists(db=db, email=user.email) + if email_row: + raise DuplicateValueException("Email is already registered") + + # 2. Check if username exists + username_row = await crud_users.exists(db=db, username=user.username) + if username_row: + raise DuplicateValueException("Username not available") + + # 3. Hash password + user_internal_dict = user.model_dump() + user_internal_dict["hashed_password"] = get_password_hash( + password=user_internal_dict["password"] + ) + del user_internal_dict["password"] + + # 4. Create user + user_internal = UserCreateInternal(**user_internal_dict) + created_user = await crud_users.create(db=db, object=user_internal) + + return created_user +``` + +**Security Steps Explained:** + +1. **Email Uniqueness**: Prevents multiple accounts with the same email, which could cause confusion and security issues +2. **Username Uniqueness**: Ensures usernames are unique identifiers within your system +3. **Password Hashing**: Converts plain text passwords into secure hashes before database storage +4. **Data Separation**: Plain text passwords are immediately removed from memory after hashing + +### Registration Schema + +The registration schema defines what data is required and how it's validated. This ensures consistent data quality and prevents malformed user accounts. + +```python +# User registration input +class UserCreate(UserBase): + model_config = ConfigDict(extra="forbid") + + password: Annotated[ + str, + Field( + pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", + examples=["Str1ngst!"] + ) + ] + +# Internal schema for database storage +class UserCreateInternal(UserBase): + hashed_password: str +``` + +**Schema Design Principles:** + +- **`extra="forbid"`**: Rejects unexpected fields, preventing injection of unauthorized data +- **Password Patterns**: Enforces minimum security requirements for passwords +- **Separation of Concerns**: External schema accepts passwords, internal schema stores hashes + +## User Authentication + +Authentication verifies user identity using credentials. The process must be secure against common attacks while remaining user-friendly. + +### Authentication Process + +```python +async def authenticate_user(username_or_email: str, password: str, db: AsyncSession) -> dict | False: + # 1. Get user by email or username + if "@" in username_or_email: + db_user = await crud_users.get(db=db, email=username_or_email, is_deleted=False) + else: + db_user = await crud_users.get(db=db, username=username_or_email, is_deleted=False) + + if not db_user: + return False + + # 2. Verify password + if not await verify_password(password, db_user["hashed_password"]): + return False + + return db_user +``` + +**Security Considerations:** + +- **Flexible Login**: Accepts both username and email for better user experience +- **Soft Delete Check**: `is_deleted=False` prevents deleted users from logging in +- **Consistent Timing**: Both user lookup and password verification take similar time + +### Password Security + +Password security is critical for protecting user accounts. The system uses industry-standard bcrypt hashing with automatic salt generation. + +```python +import bcrypt + +async def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against its hash.""" + correct_password: bool = bcrypt.checkpw( + plain_password.encode(), + hashed_password.encode() + ) + return correct_password + +def get_password_hash(password: str) -> str: + """Generate password hash with salt.""" + hashed_password: str = bcrypt.hashpw( + password.encode(), + bcrypt.gensalt() + ).decode() + return hashed_password +``` + +**Why bcrypt?** + +- **Adaptive Hashing**: Computationally expensive, making brute force attacks impractical +- **Automatic Salt**: Each password gets a unique salt, preventing rainbow table attacks +- **Future-Proof**: Can increase computational cost as hardware improves + +### Login Validation + +Client-side validation provides immediate feedback but should never be the only validation layer. + +```python +# Password validation pattern +PASSWORD_PATTERN = r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$" + +# Frontend validation (example) +function validatePassword(password) { + const minLength = password.length >= 8; + const hasNumber = /[0-9]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasSpecial = /[^a-zA-Z0-9]/.test(password); + + return minLength && hasNumber && hasUpper && hasLower && hasSpecial; +} +``` + +**Validation Strategy:** + +- **Server-Side**: Always validate on the server - client validation can be bypassed +- **Client-Side**: Provides immediate feedback for better user experience +- **Progressive**: Validate as user types to catch issues early + +## Profile Management + +Profile management allows users to update their information while maintaining security and data integrity. + +### Get Current User Profile + +Retrieving the current user's profile is a fundamental operation that should be fast and secure. + +```python +@router.get("/user/me/", response_model=UserRead) +async def read_users_me(current_user: dict = Depends(get_current_user)) -> dict: + return current_user + +# Frontend usage +async function getCurrentUser() { + const token = localStorage.getItem('access_token'); + const response = await fetch('/api/v1/user/me/', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + return await response.json(); + } + throw new Error('Failed to get user profile'); +} +``` + +**Design Decisions:** + +- **`/me` Endpoint**: Common pattern that's intuitive for users and developers +- **Current User Dependency**: Automatically handles authentication and user lookup +- **Minimal Data**: Returns only safe, user-relevant information + +### Update User Profile + +Profile updates require careful validation to prevent unauthorized changes and maintain data integrity. + +```python +@router.patch("/user/{username}") +async def patch_user( + values: UserUpdate, + username: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db), +) -> dict[str, str]: + # 1. Get user from database + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + # 2. Check ownership (users can only update their own profile) + if db_user["username"] != current_user["username"]: + raise ForbiddenException("Cannot update other users") + + # 3. Validate unique constraints + if values.username and values.username != db_user["username"]: + existing_username = await crud_users.exists(db=db, username=values.username) + if existing_username: + raise DuplicateValueException("Username not available") + + if values.email and values.email != db_user["email"]: + existing_email = await crud_users.exists(db=db, email=values.email) + if existing_email: + raise DuplicateValueException("Email is already registered") + + # 4. Update user + await crud_users.update(db=db, object=values, username=username) + return {"message": "User updated"} +``` + +**Security Measures:** + +1. **Ownership Verification**: Users can only update their own profiles +2. **Uniqueness Checks**: Prevents conflicts when changing username/email +3. **Partial Updates**: Only provided fields are updated +4. **Input Validation**: Pydantic schemas validate all input data + +## User Deletion + +User deletion requires careful consideration of data retention, user rights, and system integrity. + +### Self-Deletion + +Users should be able to delete their own accounts, but the process should be secure and potentially reversible. + +```python +@router.delete("/user/{username}") +async def erase_user( + username: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db), + token: str = Depends(oauth2_scheme), +) -> dict[str, str]: + # 1. Get user from database + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if not db_user: + raise NotFoundException("User not found") + + # 2. Check ownership + if username != current_user["username"]: + raise ForbiddenException() + + # 3. Soft delete user + await crud_users.delete(db=db, username=username) + + # 4. Blacklist current token + await blacklist_token(token=token, db=db) + + return {"message": "User deleted"} +``` + +**Soft Delete Benefits:** + +- **Data Recovery**: Users can be restored if needed +- **Audit Trail**: Maintain records for compliance +- **Relationship Integrity**: Related data (posts, comments) remain accessible +- **Gradual Cleanup**: Allow time for data migration or backup + +### Admin Deletion (Hard Delete) + +Administrators may need to permanently remove users in specific circumstances. + +```python +@router.delete("/db_user/{username}", dependencies=[Depends(get_current_superuser)]) +async def erase_db_user( + username: str, + db: AsyncSession = Depends(async_get_db), + token: str = Depends(oauth2_scheme), +) -> dict[str, str]: + # 1. Check if user exists + db_user = await crud_users.exists(db=db, username=username) + if not db_user: + raise NotFoundException("User not found") + + # 2. Hard delete from database + await crud_users.db_delete(db=db, username=username) + + # 3. Blacklist current token + await blacklist_token(token=token, db=db) + + return {"message": "User deleted from the database"} +``` + +**When to Use Hard Delete:** + +- **Legal Requirements**: GDPR "right to be forgotten" requests +- **Data Breach Response**: Complete removal of compromised accounts +- **Spam/Abuse**: Permanent removal of malicious accounts + +## Administrative Operations + +### List All Users + +```python +@router.get("/users", response_model=PaginatedListResponse[UserRead]) +async def read_users( + db: AsyncSession = Depends(async_get_db), + page: int = 1, + items_per_page: int = 10 +) -> dict: + users_data = await crud_users.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + is_deleted=False, + ) + + response: dict[str, Any] = paginated_response( + crud_data=users_data, + page=page, + items_per_page=items_per_page + ) + return response +``` + +### Get User by Username + +```python +@router.get("/user/{username}", response_model=UserRead) +async def read_user( + username: str, + db: AsyncSession = Depends(async_get_db) +) -> UserRead: + db_user = await crud_users.get( + db=db, + username=username, + is_deleted=False, + schema_to_select=UserRead + ) + if db_user is None: + raise NotFoundException("User not found") + + return db_user +``` + +### User with Tier Information + +```python +@router.get("/user/{username}/tier") +async def read_user_tier( + username: str, + db: AsyncSession = Depends(async_get_db) +) -> dict | None: + # 1. Get user + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + # 2. Return None if no tier assigned + if db_user["tier_id"] is None: + return None + + # 3. Get tier information + db_tier = await crud_tiers.get(db=db, id=db_user["tier_id"], schema_to_select=TierRead) + if not db_tier: + raise NotFoundException("Tier not found") + + # 4. Combine user and tier data + user_dict = dict(db_user) # Convert to dict if needed + tier_dict = dict(db_tier) # Convert to dict if needed + + for key, value in tier_dict.items(): + user_dict[f"tier_{key}"] = value + + return user_dict +``` + +## User Tiers and Permissions + +### Assign User Tier + +```python +@router.patch("/user/{username}/tier", dependencies=[Depends(get_current_superuser)]) +async def patch_user_tier( + username: str, + values: UserTierUpdate, + db: AsyncSession = Depends(async_get_db) +) -> dict[str, str]: + # 1. Verify user exists + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + # 2. Verify tier exists + tier_exists = await crud_tiers.exists(db=db, id=values.tier_id) + if not tier_exists: + raise NotFoundException("Tier not found") + + # 3. Update user tier + await crud_users.update(db=db, object=values, username=username) + return {"message": "User tier updated"} + +# Tier update schema +class UserTierUpdate(BaseModel): + tier_id: int +``` + +### User Rate Limits + +```python +@router.get("/user/{username}/rate_limits", dependencies=[Depends(get_current_superuser)]) +async def read_user_rate_limits( + username: str, + db: AsyncSession = Depends(async_get_db) +) -> dict[str, Any]: + # 1. Get user + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + user_dict = dict(db_user) # Convert to dict if needed + + # 2. No tier assigned + if db_user["tier_id"] is None: + user_dict["tier_rate_limits"] = [] + return user_dict + + # 3. Get tier and rate limits + db_tier = await crud_tiers.get(db=db, id=db_user["tier_id"], schema_to_select=TierRead) + if db_tier is None: + raise NotFoundException("Tier not found") + + db_rate_limits = await crud_rate_limits.get_multi(db=db, tier_id=db_tier["id"]) + user_dict["tier_rate_limits"] = db_rate_limits["data"] + + return user_dict +``` + +## User Model Structure + +### Database Model + +```python +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(30)) + username: Mapped[str] = mapped_column(String(20), unique=True, index=True) + email: Mapped[str] = mapped_column(String(50), unique=True, index=True) + hashed_password: Mapped[str] + profile_image_url: Mapped[str] = mapped_column(default="https://www.profileimageurl.com") + is_superuser: Mapped[bool] = mapped_column(default=False) + tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), default=None) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) + updated_at: Mapped[datetime | None] = mapped_column(default=None) + + # Soft delete + is_deleted: Mapped[bool] = mapped_column(default=False) + deleted_at: Mapped[datetime | None] = mapped_column(default=None) + + # Relationships + tier: Mapped["Tier"] = relationship(back_populates="users") + posts: Mapped[list["Post"]] = relationship(back_populates="created_by_user") +``` + +### User Schemas + +```python +# Base schema with common fields +class UserBase(BaseModel): + name: Annotated[str, Field(min_length=2, max_length=30)] + username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$")] + email: Annotated[EmailStr, Field(examples=["user@example.com"])] + +# Reading user data (API responses) +class UserRead(BaseModel): + id: int + name: str + username: str + email: str + profile_image_url: str + tier_id: int | None + +# Full user data (internal use) +class User(TimestampSchema, UserBase, UUIDSchema, PersistentDeletion): + profile_image_url: str = "https://www.profileimageurl.com" + hashed_password: str + is_superuser: bool = False + tier_id: int | None = None +``` + +## Common User Operations + +### Check User Existence + +```python +# By email +email_exists = await crud_users.exists(db=db, email="user@example.com") + +# By username +username_exists = await crud_users.exists(db=db, username="johndoe") + +# By ID +user_exists = await crud_users.exists(db=db, id=123) +``` + +### Search Users + +```python +# Get active users only +active_users = await crud_users.get_multi( + db=db, + is_deleted=False, + limit=10 +) + +# Get users by tier +tier_users = await crud_users.get_multi( + db=db, + tier_id=1, + is_deleted=False +) + +# Get superusers +superusers = await crud_users.get_multi( + db=db, + is_superuser=True, + is_deleted=False +) +``` + +### User Statistics + +```python +async def get_user_stats(db: AsyncSession) -> dict: + # Total users + total_users = await crud_users.count(db=db, is_deleted=False) + + # Active users (logged in recently) + # This would require tracking last_login_at + + # Users by tier + tier_stats = {} + tiers = await crud_tiers.get_multi(db=db) + for tier in tiers["data"]: + count = await crud_users.count(db=db, tier_id=tier["id"], is_deleted=False) + tier_stats[tier["name"]] = count + + return { + "total_users": total_users, + "tier_distribution": tier_stats + } +``` + +## Frontend Integration + +### Complete User Management Component + +```javascript +class UserManager { + constructor(baseUrl = '/api/v1') { + this.baseUrl = baseUrl; + this.token = localStorage.getItem('access_token'); + } + + async register(userData) { + const response = await fetch(`${this.baseUrl}/user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + return await response.json(); + } + + async login(username, password) { + const response = await fetch(`${this.baseUrl}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + username: username, + password: password + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + const tokens = await response.json(); + localStorage.setItem('access_token', tokens.access_token); + this.token = tokens.access_token; + + return tokens; + } + + async getProfile() { + const response = await fetch(`${this.baseUrl}/user/me/`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get profile'); + } + + return await response.json(); + } + + async updateProfile(username, updates) { + const response = await fetch(`${this.baseUrl}/user/${username}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + return await response.json(); + } + + async deleteAccount(username) { + const response = await fetch(`${this.baseUrl}/user/${username}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + // Clear local storage + localStorage.removeItem('access_token'); + this.token = null; + + return await response.json(); + } + + async logout() { + const response = await fetch(`${this.baseUrl}/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + // Clear local storage regardless of response + localStorage.removeItem('access_token'); + this.token = null; + + if (response.ok) { + return await response.json(); + } + } +} + +// Usage +const userManager = new UserManager(); + +// Register new user +try { + const user = await userManager.register({ + name: "John Doe", + username: "johndoe", + email: "john@example.com", + password: "SecurePass123!" + }); + console.log('User registered:', user); +} catch (error) { + console.error('Registration failed:', error.message); +} + +// Login +try { + const tokens = await userManager.login('johndoe', 'SecurePass123!'); + console.log('Login successful'); + + // Get profile + const profile = await userManager.getProfile(); + console.log('User profile:', profile); +} catch (error) { + console.error('Login failed:', error.message); +} +``` + +## Security Considerations + +### Input Validation + +```python +# Server-side validation +class UserCreate(UserBase): + password: Annotated[ + str, + Field( + min_length=8, + pattern=r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]", + description="Password must contain uppercase, lowercase, number, and special character" + ) + ] +``` + +### Rate Limiting + +```python +# Protect registration endpoint +@router.post("/user", dependencies=[Depends(rate_limiter_dependency)]) +async def write_user(user: UserCreate, db: AsyncSession): + # Registration logic + pass + +# Protect login endpoint +@router.post("/login", dependencies=[Depends(rate_limiter_dependency)]) +async def login_for_access_token(): + # Login logic + pass +``` + +### Data Sanitization + +```python +def sanitize_user_input(user_data: dict) -> dict: + """Sanitize user input to prevent XSS and injection.""" + import html + + sanitized = {} + for key, value in user_data.items(): + if isinstance(value, str): + # HTML escape + sanitized[key] = html.escape(value.strip()) + else: + sanitized[key] = value + + return sanitized +``` + +## Next Steps + +Now that you understand user management: + +1. **[Permissions](permissions.md)** - Learn about role-based access control and authorization +2. **[Production Guide](../production.md)** - Implement production-grade security measures +3. **[JWT Tokens](jwt-tokens.md)** - Review token management if needed + +User management provides the core functionality for authentication systems. Master these patterns before implementing advanced permission systems. + +## Common Authentication Tasks + +### Protect New Endpoints + +```python +# Add authentication dependency to your router +@router.get("/my-endpoint") +async def my_endpoint(current_user: dict = Depends(get_current_user)): + # Endpoint now requires authentication + return {"user_specific_data": f"Hello {current_user['username']}"} + +# Optional authentication for public endpoints +@router.get("/public-endpoint") +async def public_endpoint(user: dict | None = Depends(get_optional_user)): + if user: + return {"message": f"Hello {user['username']}", "premium_features": True} + return {"message": "Hello anonymous user", "premium_features": False} +``` + +### Complete Authentication Flow + +```python +# 1. User registration +user_data = UserCreate( + name="John Doe", + username="johndoe", + email="john@example.com", + password="SecurePassword123!" +) +user = await crud_users.create(db=db, object=user_data) + +# 2. User login +form_data = {"username": "johndoe", "password": "SecurePassword123!"} +user = await authenticate_user(form_data["username"], form_data["password"], db) + +# 3. Token generation (handled in login endpoint) +access_token = await create_access_token(data={"sub": user["username"]}) +refresh_token = await create_refresh_token(data={"sub": user["username"]}) + +# 4. API access with token +headers = {"Authorization": f"Bearer {access_token}"} +response = requests.get("/api/v1/users/me", headers=headers) + +# 5. Token refresh when access token expires +response = requests.post("/api/v1/refresh") # Uses refresh token cookie +new_access_token = response.json()["access_token"] + +# 6. Secure logout (blacklists both tokens) +await logout_user(access_token=access_token, refresh_token=refresh_token, db=db) +``` + +### Check User Permissions + +```python +def check_user_permission(user: dict, required_tier: str = None): + """Check if user has required permissions.""" + if not user.get("is_active", True): + raise UnauthorizedException("User account is disabled") + + if required_tier and user.get("tier", {}).get("name") != required_tier: + raise ForbiddenException(f"Requires {required_tier} tier") + +# Usage in endpoint +@router.get("/premium-feature") +async def premium_feature(current_user: dict = Depends(get_current_user)): + check_user_permission(current_user, "Pro") + return {"premium_data": "exclusive_content"} +``` + +### Custom Authentication Logic + +```python +async def get_user_with_posts(current_user: dict = Depends(get_current_user)): + """Custom dependency that adds user's posts.""" + posts = await crud_posts.get_multi(db=db, created_by_user_id=current_user["id"]) + current_user["posts"] = posts + return current_user + +# Usage +@router.get("/dashboard") +async def get_dashboard(user_with_posts: dict = Depends(get_user_with_posts)): + return { + "user": user_with_posts, + "post_count": len(user_with_posts["posts"]) + } +``` \ No newline at end of file diff --git a/docs/user-guide/background-tasks/index.md b/docs/user-guide/background-tasks/index.md new file mode 100644 index 0000000..70c2a18 --- /dev/null +++ b/docs/user-guide/background-tasks/index.md @@ -0,0 +1,92 @@ +# Background Tasks + +The boilerplate includes a robust background task system built on ARQ (Async Redis Queue) for handling long-running operations asynchronously. This enables your API to remain responsive while processing intensive tasks in the background. + +## Overview + +Background tasks are essential for operations that: + +- **Take longer than 2 seconds** to complete +- **Don't block user interactions** in your frontend +- **Can be processed asynchronously** without immediate user feedback +- **Require intensive computation** or external API calls + +## Quick Example + +```python +# Define a background task +async def send_welcome_email(ctx: Worker, user_id: int, email: str) -> str: + # Send email logic here + await send_email_service(email, "Welcome!") + return f"Welcome email sent to {email}" + +# Enqueue the task from an API endpoint +@router.post("/users/", response_model=UserRead) +async def create_user(user_data: UserCreate): + # Create user in database + user = await crud_users.create(db=db, object=user_data) + + # Queue welcome email in background + await queue.pool.enqueue_job("send_welcome_email", user["id"], user["email"]) + + return user +``` + +## Architecture + +### ARQ Worker System +- **Redis-Based**: Uses Redis as the message broker for job queues +- **Async Processing**: Fully asynchronous task execution +- **Worker Pool**: Multiple workers can process tasks concurrently +- **Job Persistence**: Tasks survive application restarts + +### Task Lifecycle +1. **Enqueue**: Tasks are added to Redis queue from API endpoints +2. **Processing**: ARQ workers pick up and execute tasks +3. **Results**: Task results are stored and can be retrieved +4. **Monitoring**: Track task status and execution history + +## Key Features + +**Scalable Processing** +- Multiple worker instances for high throughput +- Automatic load balancing across workers +- Configurable concurrency per worker + +**Reliable Execution** +- Task retry mechanisms for failed jobs +- Dead letter queues for problematic tasks +- Graceful shutdown and task cleanup + +**Database Integration** +- Shared database sessions with main application +- CRUD operations available in background tasks +- Transaction management and error handling + +## Common Use Cases + +- **Email Processing**: Welcome emails, notifications, newsletters +- **File Operations**: Image processing, PDF generation, file uploads +- **External APIs**: Third-party integrations, webhooks, data sync +- **Data Processing**: Report generation, analytics, batch operations +- **ML/AI Tasks**: Model inference, data analysis, predictions + +## Getting Started + +The boilerplate provides everything needed to start using background tasks immediately. Simply define your task functions, register them in the worker settings, and enqueue them from your API endpoints. + +## Configuration + +Basic Redis queue configuration: + +```bash +# Redis Queue Settings +REDIS_QUEUE_HOST=localhost +REDIS_QUEUE_PORT=6379 +``` + +The system automatically handles Redis connection pooling and worker lifecycle management. + +## Next Steps + +Check the [ARQ documentation](https://arq-docs.helpmanual.io/) for advanced usage patterns and refer to the boilerplate's example implementation in `src/app/core/worker/` and `src/app/api/v1/tasks.py`. \ No newline at end of file diff --git a/docs/user-guide/caching/cache-strategies.md b/docs/user-guide/caching/cache-strategies.md new file mode 100644 index 0000000..bbdd527 --- /dev/null +++ b/docs/user-guide/caching/cache-strategies.md @@ -0,0 +1,191 @@ +# Cache Strategies + +Effective cache strategies balance performance gains with data consistency. This section covers invalidation patterns, cache warming, and optimization techniques for building robust caching systems. + +## Cache Invalidation Strategies + +Cache invalidation is one of the hardest problems in computer science. The boilerplate provides several strategies to handle different scenarios while maintaining data consistency. + +### Understanding Cache Invalidation + +**Cache invalidation** ensures that cached data doesn't become stale when the underlying data changes. Poor invalidation leads to users seeing outdated information, while over-aggressive invalidation negates caching benefits. + +### Basic Invalidation Patterns + +#### Time-Based Expiration (TTL) + +The simplest strategy relies on cache expiration times: + +```python +# Set different TTL based on data characteristics +@cache(key_prefix="user_profile", expiration=3600) # 1 hour for profiles +@cache(key_prefix="post_content", expiration=1800) # 30 min for posts +@cache(key_prefix="live_stats", expiration=60) # 1 min for live data +``` + +**Pros:** + +- Simple to implement and understand +- Guarantees cache freshness within TTL period +- Works well for data with predictable change patterns + +**Cons:** + +- May serve stale data until TTL expires +- Difficult to optimize TTL for all scenarios +- Cache miss storms when many keys expire simultaneously + +#### Write-Through Invalidation + +Automatically invalidate cache when data is modified: + +```python +@router.put("/posts/{post_id}") +@cache( + key_prefix="post_cache", + resource_id_name="post_id", + to_invalidate_extra={ + "user_posts": "{user_id}", # User's post list + "category_posts": "{category_id}", # Category post list + "recent_posts": "global" # Global recent posts + } +) +async def update_post( + request: Request, + post_id: int, + post_data: PostUpdate, + user_id: int, + category_id: int +): + # Update triggers automatic cache invalidation + updated_post = await crud_posts.update(db=db, id=post_id, object=post_data) + return updated_post +``` + +**Pros:** + +- Immediate consistency when data changes +- No stale data served to users +- Precise control over what gets invalidated + +**Cons:** + +- More complex implementation +- Can impact write performance +- Risk of over-invalidation + +### Advanced Invalidation Patterns + +#### Pattern-Based Invalidation + +Use Redis pattern matching for bulk invalidation: + +```python +@router.put("/users/{user_id}/profile") +@cache( + key_prefix="user_profile", + resource_id_name="user_id", + pattern_to_invalidate_extra=[ + "user_{user_id}_*", # All user-related caches + "*_user_{user_id}_*", # Caches containing this user + "leaderboard_*", # Leaderboards might change + "search_users_*" # User search results + ] +) +async def update_user_profile(request: Request, user_id: int, profile_data: ProfileUpdate): + await crud_users.update(db=db, id=user_id, object=profile_data) + return {"message": "Profile updated"} +``` + +**Pattern Examples:** +```python +# User-specific patterns +"user_{user_id}_posts_*" # All paginated post lists for user +"user_{user_id}_*_cache" # All cached data for user +"*_following_{user_id}" # All caches tracking this user's followers + +# Content patterns +"posts_category_{category_id}_*" # All posts in category +"comments_post_{post_id}_*" # All comments for post +"search_*_{query}" # All search results for query + +# Time-based patterns +"daily_stats_*" # All daily statistics +"hourly_*" # All hourly data +"temp_*" # Temporary cache entries +``` + +## Cache Warming Strategies + +Cache warming proactively loads data into cache to avoid cache misses during peak usage. + +### Application Startup Warming + +```python +# core/startup.py +async def warm_critical_caches(): + """Warm up critical caches during application startup.""" + + logger.info("Starting cache warming...") + + # Warm up reference data + await warm_reference_data() + + # Warm up popular content + await warm_popular_content() + + # Warm up user session data for active users + await warm_active_user_data() + + logger.info("Cache warming completed") + +async def warm_reference_data(): + """Warm up reference data that rarely changes.""" + + # Countries, currencies, timezones, etc. + reference_data = await crud_reference.get_all_countries() + for country in reference_data: + cache_key = f"country:{country['code']}" + await cache.client.set(cache_key, json.dumps(country), ex=86400) # 24 hours + + # Categories + categories = await crud_categories.get_all() + await cache.client.set("all_categories", json.dumps(categories), ex=3600) + +async def warm_popular_content(): + """Warm up frequently accessed content.""" + + # Most viewed posts + popular_posts = await crud_posts.get_popular(limit=100) + for post in popular_posts: + cache_key = f"post_cache:{post['id']}" + await cache.client.set(cache_key, json.dumps(post), ex=1800) + + # Trending topics + trending = await crud_posts.get_trending_topics(limit=50) + await cache.client.set("trending_topics", json.dumps(trending), ex=600) + +async def warm_active_user_data(): + """Warm up data for recently active users.""" + + # Get users active in last 24 hours + active_users = await crud_users.get_recently_active(hours=24) + + for user in active_users: + # Warm user profile + profile_key = f"user_profile:{user['id']}" + await cache.client.set(profile_key, json.dumps(user), ex=3600) + + # Warm user's recent posts + user_posts = await crud_posts.get_user_posts(user['id'], limit=10) + posts_key = f"user_{user['id']}_posts:page_1" + await cache.client.set(posts_key, json.dumps(user_posts), ex=1800) + +# Add to startup events +@app.on_event("startup") +async def startup_event(): + await create_redis_cache_pool() + await warm_critical_caches() +``` + +These cache strategies provide a comprehensive approach to building performant, consistent caching systems that scale with your application's needs while maintaining data integrity. \ No newline at end of file diff --git a/docs/user-guide/caching/client-cache.md b/docs/user-guide/caching/client-cache.md new file mode 100644 index 0000000..a4ec8c1 --- /dev/null +++ b/docs/user-guide/caching/client-cache.md @@ -0,0 +1,509 @@ +# Client Cache + +Client-side caching leverages HTTP cache headers to instruct browsers and CDNs to cache responses locally. This reduces server load and improves user experience by serving cached content directly from the client. + +## Understanding Client Caching + +Client caching works by setting HTTP headers that tell browsers, proxies, and CDNs how long they should cache responses. When implemented correctly, subsequent requests for the same resource are served instantly from the local cache. + +### Benefits of Client Caching + +**Reduced Latency**: Instant response from local cache eliminates network round trips +**Lower Server Load**: Fewer requests reach your server infrastructure +**Bandwidth Savings**: Cached responses don't consume network bandwidth +**Better User Experience**: Faster page loads and improved responsiveness +**Cost Reduction**: Lower server resource usage and bandwidth costs + +## Cache-Control Headers + +The `Cache-Control` header is the primary mechanism for controlling client-side caching behavior. + +### Header Components + +```http +Cache-Control: public, max-age=3600, s-maxage=7200, must-revalidate +``` + +**Directive Breakdown:** + +- **`public`**: Response can be cached by any cache (browsers, CDNs, proxies) +- **`private`**: Response can only be cached by browsers, not shared caches +- **`max-age=3600`**: Cache for 3600 seconds (1 hour) in browsers +- **`s-maxage=7200`**: Cache for 7200 seconds (2 hours) in shared caches (CDNs) +- **`must-revalidate`**: Must check with server when cache expires +- **`no-cache`**: Must revalidate with server before using cached response +- **`no-store`**: Must not store any part of the response + +### Common Cache Patterns + +```python +# Static assets (images, CSS, JS) +"Cache-Control: public, max-age=31536000, immutable" # 1 year + +# API data that changes rarely +"Cache-Control: public, max-age=3600" # 1 hour + +# User-specific data +"Cache-Control: private, max-age=1800" # 30 minutes, browser only + +# Real-time data +"Cache-Control: no-cache, must-revalidate" # Always validate + +# Sensitive data +"Cache-Control: no-store, no-cache, must-revalidate" # Never cache +``` + +## Middleware Implementation + +The boilerplate includes middleware that automatically adds cache headers to responses. + +### ClientCacheMiddleware + +```python +# middleware/client_cache_middleware.py +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +class ClientCacheMiddleware(BaseHTTPMiddleware): + """Middleware to set Cache-Control headers for client-side caching.""" + + def __init__(self, app: FastAPI, max_age: int = 60) -> None: + super().__init__(app) + self.max_age = max_age + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response: Response = await call_next(request) + response.headers["Cache-Control"] = f"public, max-age={self.max_age}" + return response +``` + +### Adding Middleware to Application + +```python +# main.py +from fastapi import FastAPI +from app.middleware.client_cache_middleware import ClientCacheMiddleware + +app = FastAPI() + +# Add client caching middleware +app.add_middleware( + ClientCacheMiddleware, + max_age=300 # 5 minutes default cache +) +``` + +### Custom Middleware Configuration + +```python +class AdvancedClientCacheMiddleware(BaseHTTPMiddleware): + """Advanced client cache middleware with path-specific configurations.""" + + def __init__( + self, + app: FastAPI, + default_max_age: int = 300, + path_configs: dict[str, dict] = None + ): + super().__init__(app) + self.default_max_age = default_max_age + self.path_configs = path_configs or {} + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response = await call_next(request) + + # Get path-specific configuration + cache_config = self._get_cache_config(request.url.path) + + # Set cache headers based on configuration + if cache_config.get("no_cache", False): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + else: + max_age = cache_config.get("max_age", self.default_max_age) + visibility = "private" if cache_config.get("private", False) else "public" + + cache_control = f"{visibility}, max-age={max_age}" + + if cache_config.get("must_revalidate", False): + cache_control += ", must-revalidate" + + if cache_config.get("immutable", False): + cache_control += ", immutable" + + response.headers["Cache-Control"] = cache_control + + return response + + def _get_cache_config(self, path: str) -> dict: + """Get cache configuration for a specific path.""" + for pattern, config in self.path_configs.items(): + if path.startswith(pattern): + return config + return {} + +# Usage with path-specific configurations +app.add_middleware( + AdvancedClientCacheMiddleware, + default_max_age=300, + path_configs={ + "/api/v1/static/": {"max_age": 31536000, "immutable": True}, # 1 year for static assets + "/api/v1/auth/": {"no_cache": True}, # No cache for auth endpoints + "/api/v1/users/me": {"private": True, "max_age": 900}, # 15 min private cache for user data + "/api/v1/public/": {"max_age": 1800}, # 30 min for public data + } +) +``` + +## Manual Cache Control + +Set cache headers manually in specific endpoints for fine-grained control. + +### Response Header Manipulation + +```python +from fastapi import APIRouter, Response + +router = APIRouter() + +@router.get("/api/v1/static-data") +async def get_static_data(response: Response): + """Endpoint with long-term caching for static data.""" + # Set cache headers for static data + response.headers["Cache-Control"] = "public, max-age=86400, immutable" # 24 hours + response.headers["Last-Modified"] = "Wed, 21 Oct 2023 07:28:00 GMT" + response.headers["ETag"] = '"abc123"' + + return {"data": "static content that rarely changes"} + +@router.get("/api/v1/user-data") +async def get_user_data(response: Response, current_user: dict = Depends(get_current_user)): + """Endpoint with private caching for user-specific data.""" + # Private cache for user-specific data + response.headers["Cache-Control"] = "private, max-age=1800" # 30 minutes + response.headers["Vary"] = "Authorization" # Cache varies by auth header + + return {"user_id": current_user["id"], "preferences": "user data"} + +@router.get("/api/v1/real-time-data") +async def get_real_time_data(response: Response): + """Endpoint that should not be cached.""" + # Prevent caching for real-time data + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return {"timestamp": datetime.utcnow(), "live_data": "current status"} +``` + +### Conditional Caching + +Implement conditional caching based on request parameters: + +```python +@router.get("/api/v1/posts") +async def get_posts( + response: Response, + page: int = 1, + per_page: int = 10, + category: str = None +): + """Conditional caching based on parameters.""" + + # Different cache strategies based on parameters + if category: + # Category-specific data changes less frequently + response.headers["Cache-Control"] = "public, max-age=1800" # 30 minutes + elif page == 1: + # First page cached more aggressively + response.headers["Cache-Control"] = "public, max-age=600" # 10 minutes + else: + # Other pages cached for shorter duration + response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes + + # Add ETag for efficient revalidation + content_hash = hashlib.md5(f"{page}{per_page}{category}".encode()).hexdigest() + response.headers["ETag"] = f'"{content_hash}"' + + posts = await crud_posts.get_multi( + db=db, + offset=(page - 1) * per_page, + limit=per_page, + category=category + ) + + return {"posts": posts, "page": page, "per_page": per_page} +``` + +## ETag Implementation + +ETags enable efficient cache validation by allowing clients to check if content has changed. + +### ETag Generation + +```python +import hashlib +from typing import Any + +def generate_etag(data: Any) -> str: + """Generate ETag from data content.""" + content = json.dumps(data, sort_keys=True, default=str) + return hashlib.md5(content.encode()).hexdigest() + +@router.get("/api/v1/users/{user_id}") +async def get_user( + request: Request, + response: Response, + user_id: int +): + """Endpoint with ETag support for efficient caching.""" + + user = await crud_users.get(db=db, id=user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate ETag from user data + etag = generate_etag(user) + + # Check if client has current version + if_none_match = request.headers.get("If-None-Match") + if if_none_match == f'"{etag}"': + # Content hasn't changed, return 304 Not Modified + response.status_code = 304 + return Response(status_code=304) + + # Set ETag and cache headers + response.headers["ETag"] = f'"{etag}"' + response.headers["Cache-Control"] = "private, max-age=1800, must-revalidate" + + return user +``` + +### Last-Modified Headers + +Use Last-Modified headers for time-based cache validation: + +```python +@router.get("/api/v1/posts/{post_id}") +async def get_post( + request: Request, + response: Response, + post_id: int +): + """Endpoint with Last-Modified header support.""" + + post = await crud_posts.get(db=db, id=post_id) + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + # Use post's updated_at timestamp + last_modified = post["updated_at"] + + # Check If-Modified-Since header + if_modified_since = request.headers.get("If-Modified-Since") + if if_modified_since: + client_time = datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") + if last_modified <= client_time: + response.status_code = 304 + return Response(status_code=304) + + # Set Last-Modified header + response.headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT") + response.headers["Cache-Control"] = "public, max-age=3600, must-revalidate" + + return post +``` + +## Cache Strategy by Content Type + +Different types of content require different caching strategies. + +### Static Assets + +```python +@router.get("/static/{file_path:path}") +async def serve_static(response: Response, file_path: str): + """Serve static files with aggressive caching.""" + + # Static assets can be cached for a long time + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" # 1 year + response.headers["Vary"] = "Accept-Encoding" # Vary by compression + + # Add file-specific ETag based on file modification time + file_stat = os.stat(f"static/{file_path}") + etag = hashlib.md5(f"{file_path}{file_stat.st_mtime}".encode()).hexdigest() + response.headers["ETag"] = f'"{etag}"' + + return FileResponse(f"static/{file_path}") +``` + +### API Responses + +```python +# Reference data (rarely changes) +@router.get("/api/v1/countries") +async def get_countries(response: Response, db: AsyncSession = Depends(async_get_db)): + response.headers["Cache-Control"] = "public, max-age=86400" # 24 hours + return await crud_countries.get_all(db=db) + +# User-generated content (moderate changes) +@router.get("/api/v1/posts") +async def get_posts(response: Response, db: AsyncSession = Depends(async_get_db)): + response.headers["Cache-Control"] = "public, max-age=1800" # 30 minutes + return await crud_posts.get_multi(db=db, is_deleted=False) + +# Personal data (private caching only) +@router.get("/api/v1/users/me/notifications") +async def get_notifications( + response: Response, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +): + response.headers["Cache-Control"] = "private, max-age=300" # 5 minutes + response.headers["Vary"] = "Authorization" + return await crud_notifications.get_user_notifications(db=db, user_id=current_user["id"]) + +# Real-time data (no caching) +@router.get("/api/v1/system/status") +async def get_system_status(response: Response): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return {"status": "online", "timestamp": datetime.utcnow()} +``` + +## Vary Header Usage + +The `Vary` header tells caches which request headers affect the response, enabling proper cache key generation. + +### Common Vary Patterns + +```python +# Cache varies by authorization (user-specific content) +response.headers["Vary"] = "Authorization" + +# Cache varies by accepted language +response.headers["Vary"] = "Accept-Language" + +# Cache varies by compression support +response.headers["Vary"] = "Accept-Encoding" + +# Multiple varying headers +response.headers["Vary"] = "Authorization, Accept-Language, Accept-Encoding" + +# Example implementation +@router.get("/api/v1/dashboard") +async def get_dashboard( + request: Request, + response: Response, + current_user: dict = Depends(get_current_user) +): + """Dashboard content that varies by user and language.""" + + # Content varies by user (Authorization) and language preference + response.headers["Vary"] = "Authorization, Accept-Language" + response.headers["Cache-Control"] = "private, max-age=900" # 15 minutes + + language = request.headers.get("Accept-Language", "en") + + dashboard_data = await generate_dashboard( + user_id=current_user["id"], + language=language + ) + + return dashboard_data +``` + +## CDN Integration + +Configure cache headers for optimal CDN performance. + +### CDN-Specific Headers + +```python +@router.get("/api/v1/public-content") +async def get_public_content(response: Response): + """Content optimized for CDN caching.""" + + # Different cache times for browser vs CDN + response.headers["Cache-Control"] = "public, max-age=300, s-maxage=3600" # 5 min browser, 1 hour CDN + + # CDN-specific headers (CloudFlare example) + response.headers["CF-Cache-Tag"] = "public-content,api-v1" # Cache tags for purging + response.headers["CF-Edge-Cache"] = "max-age=86400" # Edge cache for 24 hours + + return await get_public_content_data() +``` + +### Cache Purging + +Implement cache purging for content updates: + +```python +@router.put("/api/v1/posts/{post_id}") +async def update_post( + response: Response, + post_id: int, + post_data: PostUpdate, + current_user: dict = Depends(get_current_user) +): + """Update post and invalidate related caches.""" + + # Update the post + updated_post = await crud_posts.update(db=db, id=post_id, object=post_data) + + # Set headers to indicate cache invalidation is needed + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Cache-Purge"] = f"post-{post_id},user-{current_user['id']}-posts" + + # In production, trigger CDN purge here + # await purge_cdn_cache([f"post-{post_id}", f"user-{current_user['id']}-posts"]) + + return updated_post +``` + +## Best Practices + +### Cache Duration Guidelines + +```python +# Choose appropriate cache durations based on content characteristics: + +# Static assets (CSS, JS, images with versioning) +max_age = 31536000 # 1 year + +# API reference data (countries, categories) +max_age = 86400 # 24 hours + +# User-generated content (posts, comments) +max_age = 1800 # 30 minutes + +# User-specific data (profiles, preferences) +max_age = 900 # 15 minutes + +# Search results +max_age = 600 # 10 minutes + +# Real-time data (live scores, chat) +max_age = 0 # No caching +``` + +### Security Considerations + +```python +# Never cache sensitive data +@router.get("/api/v1/admin/secrets") +async def get_secrets(response: Response): + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return {"secret": "sensitive_data"} + +# Use private caching for user-specific content +@router.get("/api/v1/users/me/private-data") +async def get_private_data(response: Response): + response.headers["Cache-Control"] = "private, max-age=300, must-revalidate" + response.headers["Vary"] = "Authorization" + return {"private": "user_data"} +``` + +Client-side caching, when properly implemented, provides significant performance improvements while maintaining security and data freshness through intelligent cache control strategies. \ No newline at end of file diff --git a/docs/user-guide/caching/index.md b/docs/user-guide/caching/index.md new file mode 100644 index 0000000..45ffed0 --- /dev/null +++ b/docs/user-guide/caching/index.md @@ -0,0 +1,77 @@ +# Caching + +The boilerplate includes a comprehensive caching system built on Redis that improves performance through server-side caching and client-side cache control. This section covers the complete caching implementation. + +## Overview + +The caching system provides multiple layers of optimization: + +- **Server-Side Caching**: Redis-based caching with automatic invalidation +- **Client-Side Caching**: HTTP cache headers for browser optimization +- **Cache Invalidation**: Smart invalidation strategies for data consistency + +## Quick Example + +```python +from app.core.utils.cache import cache + +@router.get("/posts/{post_id}") +@cache(key_prefix="post_cache", expiration=3600) +async def get_post(request: Request, post_id: int): + # Cached for 1 hour, automatic invalidation on updates + return await crud_posts.get(db=db, id=post_id) +``` + +## Architecture + +### Server-Side Caching +- **Redis Integration**: Connection pooling and async operations +- **Decorator-Based**: Simple `@cache` decorator for endpoints +- **Smart Invalidation**: Automatic cache clearing on data changes +- **Pattern Matching**: Bulk invalidation using Redis patterns + +### Client-Side Caching +- **HTTP Headers**: Cache-Control headers for browser caching +- **Middleware**: Automatic header injection +- **Configurable TTL**: Customizable cache duration + +## Key Features + +**Automatic Cache Management** +- Caches GET requests automatically +- Invalidates cache on PUT/POST/DELETE operations +- Supports complex invalidation patterns + +**Flexible Configuration** +- Per-endpoint expiration times +- Custom cache key generation +- Environment-specific Redis settings + +**Performance Optimization** +- Connection pooling for Redis +- Efficient key pattern matching +- Minimal overhead for cache operations + +## Getting Started + +1. **[Redis Cache](redis-cache.md)** - Server-side caching with Redis +2. **[Client Cache](client-cache.md)** - Browser caching with HTTP headers +3. **[Cache Strategies](cache-strategies.md)** - Invalidation patterns and best practices + +Each section provides detailed implementation examples and configuration options for building a robust caching layer. + +## Configuration + +Basic Redis configuration in your environment: + +```bash +# Redis Cache Settings +REDIS_CACHE_HOST=localhost +REDIS_CACHE_PORT=6379 +``` + +The caching system automatically handles connection pooling and provides efficient cache operations for your FastAPI endpoints. + +## Next Steps + +Start with **[Redis Cache](redis-cache.md)** to understand the core server-side caching implementation, then explore client-side caching and advanced invalidation strategies. \ No newline at end of file diff --git a/docs/user-guide/caching/redis-cache.md b/docs/user-guide/caching/redis-cache.md new file mode 100644 index 0000000..cf67e72 --- /dev/null +++ b/docs/user-guide/caching/redis-cache.md @@ -0,0 +1,357 @@ +# Redis Cache + +Redis-based server-side caching provides fast, in-memory storage for API responses. The boilerplate includes a sophisticated caching decorator that automatically handles cache storage, retrieval, and invalidation. + +## Understanding Redis Caching + +Redis serves as a high-performance cache layer between your API and database. When properly implemented, it can reduce response times from hundreds of milliseconds to single-digit milliseconds by serving data directly from memory. + +### Why Redis? + +**Performance**: In-memory storage provides sub-millisecond data access +**Scalability**: Handles thousands of concurrent connections efficiently +**Persistence**: Optional data persistence for cache warm-up after restarts +**Atomic Operations**: Thread-safe operations for concurrent applications +**Pattern Matching**: Advanced key pattern operations for bulk cache invalidation + +## Cache Decorator + +The `@cache` decorator provides a simple interface for adding caching to any FastAPI endpoint. + +### Basic Usage + +```python +from fastapi import APIRouter, Request +from app.core.utils.cache import cache + +router = APIRouter() + +@router.get("/posts/{post_id}") +@cache(key_prefix="post_cache", expiration=3600) +async def get_post(request: Request, post_id: int): + # This function's result will be cached for 1 hour + post = await crud_posts.get(db=db, id=post_id) + return post +``` + +**How It Works:** + +1. **Cache Check**: On GET requests, checks Redis for existing cached data +2. **Cache Miss**: If no cache exists, executes the function and stores the result +3. **Cache Hit**: Returns cached data directly, bypassing function execution +4. **Invalidation**: Automatically removes cache on non-GET requests (POST, PUT, DELETE) + +### Decorator Parameters + +```python +@cache( + key_prefix: str, # Cache key prefix + resource_id_name: str = None, # Explicit resource ID parameter + expiration: int = 3600, # Cache TTL in seconds + resource_id_type: type | tuple[type, ...] = int, # Expected ID type + to_invalidate_extra: dict[str, str] = None, # Additional keys to invalidate + pattern_to_invalidate_extra: list[str] = None # Pattern-based invalidation +) +``` + +#### Key Prefix + +The key prefix creates unique cache identifiers: + +```python +# Simple prefix +@cache(key_prefix="user_data") +# Generates keys like: "user_data:123" + +# Dynamic prefix with placeholders +@cache(key_prefix="{username}_posts") +# Generates keys like: "johndoe_posts:456" + +# Complex prefix with multiple parameters +@cache(key_prefix="user_{user_id}_posts_page_{page}") +# Generates keys like: "user_123_posts_page_2:789" +``` + +#### Resource ID Handling + +```python +# Automatic ID inference (looks for 'id' parameter) +@cache(key_prefix="post_cache") +async def get_post(request: Request, post_id: int): + # Uses post_id automatically + +# Explicit ID parameter +@cache(key_prefix="user_cache", resource_id_name="username") +async def get_user(request: Request, username: str): + # Uses username instead of looking for 'id' + +# Multiple ID types +@cache(key_prefix="search", resource_id_type=(int, str)) +async def search(request: Request, query: str, page: int): + # Accepts either string or int as resource ID +``` + +### Advanced Caching Patterns + +#### Paginated Data Caching + +```python +@router.get("/users/{username}/posts") +@cache( + key_prefix="{username}_posts:page_{page}:items_per_page_{items_per_page}", + resource_id_name="username", + expiration=300 # 5 minutes for paginated data +) +async def get_user_posts( + request: Request, + username: str, + page: int = 1, + items_per_page: int = 10 +): + offset = compute_offset(page, items_per_page) + posts = await crud_posts.get_multi( + db=db, + offset=offset, + limit=items_per_page, + created_by_user_id=user_id + ) + return paginated_response(posts, page, items_per_page) +``` + +#### Hierarchical Data Caching + +```python +@router.get("/organizations/{org_id}/departments/{dept_id}/employees") +@cache( + key_prefix="org_{org_id}_dept_{dept_id}_employees", + resource_id_name="dept_id", + expiration=1800 # 30 minutes +) +async def get_department_employees( + request: Request, + org_id: int, + dept_id: int +): + employees = await crud_employees.get_multi( + db=db, + department_id=dept_id, + organization_id=org_id + ) + return employees +``` + +## Cache Invalidation + +Cache invalidation ensures data consistency when the underlying data changes. + +### Automatic Invalidation + +The cache decorator automatically invalidates cache entries on non-GET requests: + +```python +@router.put("/posts/{post_id}") +@cache(key_prefix="post_cache", resource_id_name="post_id") +async def update_post(request: Request, post_id: int, data: PostUpdate): + # Automatically invalidates "post_cache:123" when called with PUT/POST/DELETE + await crud_posts.update(db=db, id=post_id, object=data) + return {"message": "Post updated"} +``` + +### Extra Key Invalidation + +Invalidate related cache entries when data changes: + +```python +@router.post("/posts") +@cache( + key_prefix="new_post", + resource_id_name="user_id", + to_invalidate_extra={ + "user_posts": "{user_id}", # Invalidate user's post list + "latest_posts": "global", # Invalidate global latest posts + "user_stats": "{user_id}" # Invalidate user statistics + } +) +async def create_post(request: Request, post: PostCreate, user_id: int): + # Creating a post invalidates related cached data + new_post = await crud_posts.create(db=db, object=post) + return new_post +``` + +### Pattern-Based Invalidation + +Use Redis pattern matching for bulk invalidation: + +```python +@router.put("/users/{user_id}/profile") +@cache( + key_prefix="user_profile", + resource_id_name="user_id", + pattern_to_invalidate_extra=[ + "user_{user_id}_*", # All user-related caches + "*_user_{user_id}_*", # Caches that include this user + "search_results_*" # All search result caches + ] +) +async def update_user_profile(request: Request, user_id: int, data: UserUpdate): + # Invalidates all matching cache patterns + await crud_users.update(db=db, id=user_id, object=data) + return {"message": "Profile updated"} +``` + +**Pattern Examples:** + +- `user_*` - All keys starting with "user_" +- `*_posts` - All keys ending with "_posts" +- `user_*_posts_*` - Complex patterns with wildcards +- `temp_*` - Temporary cache entries + +## Configuration + +### Redis Settings + +Configure Redis connection in your environment settings: + +```python +# core/config.py +class RedisCacheSettings(BaseSettings): + REDIS_CACHE_HOST: str = config("REDIS_CACHE_HOST", default="localhost") + REDIS_CACHE_PORT: int = config("REDIS_CACHE_PORT", default=6379) + REDIS_CACHE_PASSWORD: str = config("REDIS_CACHE_PASSWORD", default="") + REDIS_CACHE_DB: int = config("REDIS_CACHE_DB", default=0) + REDIS_CACHE_URL: str = f"redis://:{REDIS_CACHE_PASSWORD}@{REDIS_CACHE_HOST}:{REDIS_CACHE_PORT}/{REDIS_CACHE_DB}" +``` + +### Environment Variables + +```bash +# Basic Configuration +REDIS_CACHE_HOST=localhost +REDIS_CACHE_PORT=6379 + +# Production Configuration +REDIS_CACHE_HOST=redis.production.com +REDIS_CACHE_PORT=6379 +REDIS_CACHE_PASSWORD=your-secure-password +REDIS_CACHE_DB=0 + +# Docker Compose +REDIS_CACHE_HOST=redis +REDIS_CACHE_PORT=6379 +``` + +### Connection Pool Setup + +The boilerplate automatically configures Redis connection pooling: + +```python +# core/setup.py +async def create_redis_cache_pool() -> None: + """Initialize Redis connection pool for caching.""" + cache.pool = redis.ConnectionPool.from_url( + settings.REDIS_CACHE_URL, + max_connections=20, # Maximum connections in pool + retry_on_timeout=True, # Retry on connection timeout + socket_timeout=5.0, # Socket timeout in seconds + health_check_interval=30 # Health check frequency + ) + cache.client = redis.Redis.from_pool(cache.pool) +``` + +### Cache Client Usage + +Direct Redis client access for custom caching logic: + +```python +from app.core.utils.cache import client + +async def custom_cache_operation(): + if client is None: + raise MissingClientError("Redis client not initialized") + + # Set custom cache entry + await client.set("custom_key", "custom_value", ex=3600) + + # Get cached value + cached_value = await client.get("custom_key") + + # Delete cache entry + await client.delete("custom_key") + + # Bulk operations + pipe = client.pipeline() + pipe.set("key1", "value1") + pipe.set("key2", "value2") + pipe.expire("key1", 3600) + await pipe.execute() +``` + +## Performance Optimization + +### Connection Pooling + +Connection pooling prevents the overhead of creating new Redis connections for each request: + +```python +# Benefits of connection pooling: +# - Reuses existing connections +# - Handles connection failures gracefully +# - Provides connection health checks +# - Supports concurrent operations + +# Pool configuration +redis.ConnectionPool.from_url( + settings.REDIS_CACHE_URL, + max_connections=20, # Adjust based on expected load + retry_on_timeout=True, # Handle network issues + socket_keepalive=True, # Keep connections alive + socket_keepalive_options={} +) +``` + +### Cache Key Generation + +The cache decorator automatically generates keys using this pattern: + +```python +# Decorator generates: "{formatted_key_prefix}:{resource_id}" +@cache(key_prefix="post_cache", resource_id_name="post_id") +# Generates: "post_cache:123" + +@cache(key_prefix="{username}_posts:page_{page}") +# Generates: "johndoe_posts:page_1:456" (where 456 is the resource_id) + +# The system handles key formatting automatically - you just provide the prefix template +``` + +**What you control:** +- `key_prefix` template with placeholders like `{username}`, `{page}` +- `resource_id_name` to specify which parameter to use as the ID +- The decorator handles the rest + +**Generated key examples from the boilerplate:** +```python +# From posts.py +"{username}_posts:page_{page}:items_per_page_{items_per_page}" → "john_posts:page_1:items_per_page_10:789" +"{username}_post_cache" → "john_post_cache:123" +``` + +### Expiration Strategies + +Choose appropriate expiration times based on data characteristics: + +```python +# Static reference data (rarely changes) +@cache(key_prefix="countries", expiration=86400) # 24 hours + +# User-generated content (changes moderately) +@cache(key_prefix="user_posts", expiration=1800) # 30 minutes + +# Real-time data (changes frequently) +@cache(key_prefix="live_stats", expiration=60) # 1 minute + +# Search results (can be stale) +@cache(key_prefix="search", expiration=3600) # 1 hour +``` + +This comprehensive Redis caching system provides high-performance data access while maintaining data consistency through intelligent invalidation strategies. \ No newline at end of file diff --git a/docs/user-guide/configuration/docker-setup.md b/docs/user-guide/configuration/docker-setup.md new file mode 100644 index 0000000..db4d1bb --- /dev/null +++ b/docs/user-guide/configuration/docker-setup.md @@ -0,0 +1,539 @@ +# Docker Setup + +Learn how to configure and run the FastAPI Boilerplate using Docker Compose. The project includes a complete containerized setup with PostgreSQL, Redis, background workers, and optional services. + +## Docker Compose Architecture + +The boilerplate includes these core services: + +```yaml +services: + web: # FastAPI application (uvicorn or gunicorn) + worker: # ARQ background task worker + db: # PostgreSQL 13 database + redis: # Redis Alpine for caching/queues + # Optional services (commented out by default): + # pgadmin: # Database administration + # nginx: # Reverse proxy + # create_superuser: # One-time superuser creation + # create_tier: # One-time tier creation +``` + +## Basic Docker Compose + +### Main Configuration + +The main `docker-compose.yml` includes: + +```yaml +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile + # Development mode (reload enabled) + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # Production mode (uncomment for production) + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + ports: + - "8000:8000" + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + + worker: + build: + context: . + dockerfile: Dockerfile + command: arq app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + + db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" + + redis: + image: redis:alpine + volumes: + - redis-data:/data + expose: + - "6379" + +volumes: + postgres-data: + redis-data: +``` + +### Environment File Loading + +All services automatically load environment variables from `./src/.env`: + +```yaml +env_file: + - ./src/.env +``` + +The Docker services use these environment variables: + +- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` for database +- `REDIS_*_HOST` variables automatically resolve to service names +- All application settings from your `.env` file + +## Service Details + +### Web Service (FastAPI Application) + +The web service runs your FastAPI application: + +```yaml +web: + build: + context: . + dockerfile: Dockerfile + # Development: uvicorn with reload + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # Production: gunicorn with multiple workers (commented out) + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + ports: + - "8000:8000" # Direct access in development + volumes: + - ./src/app:/code/app # Live code reloading + - ./src/.env:/code/.env +``` + +**Key Features:** + +- **Development mode**: Uses uvicorn with `--reload` for automatic code reloading +- **Production mode**: Switch to gunicorn with multiple workers (commented out) +- **Live reloading**: Source code mounted as volume for development +- **Port exposure**: Direct access on port 8000 (can be disabled for nginx) + +### Worker Service (Background Tasks) + +Handles background job processing with ARQ: + +```yaml +worker: + build: + context: . + dockerfile: Dockerfile + command: arq app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env +``` + +**Features:** +- Runs ARQ worker for background job processing +- Shares the same codebase and environment as web service +- Automatically connects to Redis for job queues +- Live code reloading in development + +### Database Service (PostgreSQL 13) + +```yaml +db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" # Internal network only +``` + +**Configuration:** +- Uses environment variables: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` +- Data persisted in named volume `postgres-data` +- Only exposed to internal Docker network (no external port) +- To enable external access, uncomment the ports section + +### Redis Service + +```yaml +redis: + image: redis:alpine + volumes: + - redis-data:/data + expose: + - "6379" # Internal network only +``` + +**Features:** +- Lightweight Alpine Linux image +- Data persistence with named volume +- Used for caching, job queues, and rate limiting +- Internal network access only + +## Optional Services + +### Database Administration (pgAdmin) + +Uncomment to enable web-based database management: + +```yaml +pgadmin: + container_name: pgadmin4 + image: dpage/pgadmin4:latest + restart: always + ports: + - "5050:80" + volumes: + - pgadmin-data:/var/lib/pgadmin + env_file: + - ./src/.env + depends_on: + - db +``` + +**Usage:** +- Access at `http://localhost:5050` +- Requires `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` in `.env` +- Connect to database using service name `db` and port `5432` + +### Reverse Proxy (Nginx) + +Uncomment for production-style reverse proxy: + +```yaml +nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - web +``` + +**Configuration:** +The included `default.conf` provides: + +```nginx +server { + listen 80; + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +**When using nginx:** + +1. Uncomment the nginx service +2. Comment out the `ports` section in the web service +3. Uncomment `expose: ["8000"]` in the web service + +### Initialization Services + +#### Create First Superuser + +```yaml +create_superuser: + build: + context: . + dockerfile: Dockerfile + env_file: + - ./src/.env + depends_on: + - db + - web + command: python -m src.scripts.create_first_superuser + volumes: + - ./src:/code/src +``` + +#### Create First Tier + +```yaml +create_tier: + build: + context: . + dockerfile: Dockerfile + env_file: + - ./src/.env + depends_on: + - db + - web + command: python -m src.scripts.create_first_tier + volumes: + - ./src:/code/src +``` + +**Usage:** + +- These are one-time setup services +- Uncomment when you need to initialize data +- Run once, then comment out again + +## Dockerfile Details + +The project uses a multi-stage Dockerfile with `uv` for fast Python package management: + +### Builder Stage + +```dockerfile +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +WORKDIR /app + +# Install dependencies (cached layer) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project + +# Copy and install project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable +``` + +### Final Stage + +```dockerfile +FROM python:3.11-slim-bookworm + +# Create non-root user for security +RUN groupadd --gid 1000 app \ + && useradd --uid 1000 --gid app --shell /bin/bash --create-home app + +# Copy virtual environment from builder +COPY --from=builder --chown=app:app /app/.venv /app/.venv + +ENV PATH="/app/.venv/bin:$PATH" +USER app +WORKDIR /code + +# Default command (can be overridden) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +``` + +**Security Features:** + +- Non-root user execution +- Multi-stage build for smaller final image +- Cached dependency installation + +## Common Docker Commands + +### Development Workflow + +```bash +# Start all services +docker compose up + +# Start in background +docker compose up -d + +# Rebuild and start (after code changes) +docker compose up --build + +# View logs +docker compose logs -f web +docker compose logs -f worker + +# Stop services +docker compose down + +# Stop and remove volumes (reset data) +docker compose down -v +``` + +### Service Management + +```bash +# Start specific services +docker compose up web db redis + +# Scale workers +docker compose up --scale worker=3 + +# Execute commands in running containers +docker compose exec web bash +docker compose exec db psql -U postgres +docker compose exec redis redis-cli + +# View service status +docker compose ps +``` + +### Production Mode + +To switch to production mode: + +1. **Enable Gunicorn:** + ```yaml + # Comment out uvicorn line + # command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # Uncomment gunicorn line + command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + ``` + +2. **Enable Nginx** (optional): + ```yaml + # Uncomment nginx service + nginx: + image: nginx:latest + ports: + - "80:80" + + # In web service, comment out ports and uncomment expose + # ports: + # - "8000:8000" + expose: + - "8000" + ``` + +3. **Remove development volumes:** + ```yaml + # Remove or comment out for production + # volumes: + # - ./src/app:/code/app + # - ./src/.env:/code/.env + ``` + +## Environment Configuration + +### Service Communication + +Services communicate using service names: + +```yaml +# In your .env file for Docker +POSTGRES_SERVER=db # Not localhost +REDIS_CACHE_HOST=redis # Not localhost +REDIS_QUEUE_HOST=redis +REDIS_RATE_LIMIT_HOST=redis +``` + +### Port Management + +**Development (default):** +- Web: `localhost:8000` (direct access) +- Database: `localhost:5432` (uncomment ports to enable) +- Redis: `localhost:6379` (uncomment ports to enable) +- pgAdmin: `localhost:5050` (if enabled) + +**Production with Nginx:** +- Web: `localhost:80` (through nginx) +- Database: Internal only +- Redis: Internal only + +## Troubleshooting + +### Common Issues + +**Container won't start:** +```bash +# Check logs +docker compose logs web + +# Rebuild image +docker compose build --no-cache web + +# Check environment file +docker compose exec web env | grep POSTGRES +``` + +**Database connection issues:** +```bash +# Check if db service is running +docker compose ps db + +# Test connection from web container +docker compose exec web ping db + +# Check database logs +docker compose logs db +``` + +**Port conflicts:** +```bash +# Check what's using the port +lsof -i :8000 + +# Use different ports +ports: + - "8001:8000" # Use port 8001 instead +``` + +### Development vs Production + +**Development features:** + +- Live code reloading with volume mounts +- Direct port access +- uvicorn with `--reload` +- Exposed database/redis ports for debugging + +**Production optimizations:** + +- No volume mounts (code baked into image) +- Nginx reverse proxy +- Gunicorn with multiple workers +- Internal service networking only +- Resource limits and health checks + +## Best Practices + +### Development +- Use volume mounts for live code reloading +- Enable direct port access for debugging +- Use uvicorn with reload for fast development +- Enable optional services (pgAdmin) as needed + +### Production +- Switch to gunicorn with multiple workers +- Use nginx for reverse proxy and load balancing +- Remove volume mounts and bake code into images +- Use internal networking only +- Set resource limits and health checks + +### Security +- Containers run as non-root user +- Use internal networking for service communication +- Don't expose database/redis ports externally +- Use Docker secrets for sensitive data in production + +### Monitoring +- Use `docker compose logs` to monitor services +- Set up health checks for all services +- Monitor resource usage with `docker stats` +- Use structured logging for better observability + +The Docker setup provides everything you need for both development and production. Start with the default configuration and customize as your needs grow! \ No newline at end of file diff --git a/docs/user-guide/configuration/environment-specific.md b/docs/user-guide/configuration/environment-specific.md new file mode 100644 index 0000000..d544cbb --- /dev/null +++ b/docs/user-guide/configuration/environment-specific.md @@ -0,0 +1,692 @@ +# Environment-Specific Configuration + +Learn how to configure your FastAPI application for different environments (development, staging, production) with appropriate security, performance, and monitoring settings. + +## Environment Types + +The boilerplate supports three environment types: + +- **`local`** - Development environment with full debugging +- **`staging`** - Pre-production testing environment +- **`production`** - Production environment with security hardening + +Set the environment type with: + +```env +ENVIRONMENT="local" # or "staging" or "production" +``` + +## Development Environment + +### Local Development Settings + +Create `src/.env.development`: + +```env +# ------------- environment ------------- +ENVIRONMENT="local" +DEBUG=true + +# ------------- app settings ------------- +APP_NAME="MyApp (Development)" +APP_VERSION="0.1.0-dev" + +# ------------- database ------------- +POSTGRES_USER="dev_user" +POSTGRES_PASSWORD="dev_password" +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_DB="myapp_dev" + +# ------------- crypt ------------- +SECRET_KEY="dev-secret-key-not-for-production-use" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=60 # Longer for development +REFRESH_TOKEN_EXPIRE_DAYS=30 # Longer for development + +# ------------- redis ------------- +REDIS_CACHE_HOST="localhost" +REDIS_CACHE_PORT=6379 +REDIS_QUEUE_HOST="localhost" +REDIS_QUEUE_PORT=6379 +REDIS_RATE_LIMIT_HOST="localhost" +REDIS_RATE_LIMIT_PORT=6379 + +# ------------- caching ------------- +CLIENT_CACHE_MAX_AGE=0 # Disable caching for development + +# ------------- rate limiting ------------- +DEFAULT_RATE_LIMIT_LIMIT=1000 # Higher limits for development +DEFAULT_RATE_LIMIT_PERIOD=3600 + +# ------------- admin ------------- +ADMIN_NAME="Dev Admin" +ADMIN_EMAIL="admin@localhost" +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="admin123" + +# ------------- tier ------------- +TIER_NAME="dev_tier" + +# ------------- logging ------------- +DATABASE_ECHO=true # Log all SQL queries +``` + +### Development Features + +```python +# Development-specific features +if settings.ENVIRONMENT == "local": + # Enable detailed error pages + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins in development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Enable API documentation + app.openapi_url = "/openapi.json" + app.docs_url = "/docs" + app.redoc_url = "/redoc" +``` + +### Docker Development Override + +`docker-compose.override.yml`: + +```yaml +version: '3.8' + +services: + web: + environment: + - ENVIRONMENT=local + - DEBUG=true + - DATABASE_ECHO=true + volumes: + - ./src:/code/src:cached + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + ports: + - "8000:8000" + + db: + environment: + - POSTGRES_DB=myapp_dev + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + # Development tools + adminer: + image: adminer + ports: + - "8080:8080" + depends_on: + - db +``` + +## Staging Environment + +### Staging Settings + +Create `src/.env.staging`: + +```env +# ------------- environment ------------- +ENVIRONMENT="staging" +DEBUG=false + +# ------------- app settings ------------- +APP_NAME="MyApp (Staging)" +APP_VERSION="0.1.0-staging" + +# ------------- database ------------- +POSTGRES_USER="staging_user" +POSTGRES_PASSWORD="complex_staging_password_123!" +POSTGRES_SERVER="staging-db.example.com" +POSTGRES_PORT=5432 +POSTGRES_DB="myapp_staging" + +# ------------- crypt ------------- +SECRET_KEY="staging-secret-key-different-from-production" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# ------------- redis ------------- +REDIS_CACHE_HOST="staging-redis.example.com" +REDIS_CACHE_PORT=6379 +REDIS_QUEUE_HOST="staging-redis.example.com" +REDIS_QUEUE_PORT=6379 +REDIS_RATE_LIMIT_HOST="staging-redis.example.com" +REDIS_RATE_LIMIT_PORT=6379 + +# ------------- caching ------------- +CLIENT_CACHE_MAX_AGE=300 # 5 minutes + +# ------------- rate limiting ------------- +DEFAULT_RATE_LIMIT_LIMIT=100 +DEFAULT_RATE_LIMIT_PERIOD=3600 + +# ------------- admin ------------- +ADMIN_NAME="Staging Admin" +ADMIN_EMAIL="admin@staging.example.com" +ADMIN_USERNAME="staging_admin" +ADMIN_PASSWORD="secure_staging_password_456!" + +# ------------- tier ------------- +TIER_NAME="staging_tier" + +# ------------- logging ------------- +DATABASE_ECHO=false +``` + +### Staging Features + +```python +# Staging-specific features +if settings.ENVIRONMENT == "staging": + # Restricted CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["https://staging.example.com"], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], + ) + + # API docs available to superusers only + @app.get("/docs", include_in_schema=False) + async def custom_swagger_ui(current_user: User = Depends(get_current_superuser)): + return get_swagger_ui_html(openapi_url="/openapi.json") +``` + +### Docker Staging Configuration + +`docker-compose.staging.yml`: + +```yaml +version: '3.8' + +services: + web: + environment: + - ENVIRONMENT=staging + - DEBUG=false + deploy: + replicas: 2 + resources: + limits: + memory: 1G + reservations: + memory: 512M + restart: always + + db: + environment: + - POSTGRES_DB=myapp_staging + volumes: + - postgres_staging_data:/var/lib/postgresql/data + restart: always + + redis: + restart: always + + worker: + deploy: + replicas: 2 + restart: always + +volumes: + postgres_staging_data: +``` + +## Production Environment + +### Production Settings + +Create `src/.env.production`: + +```env +# ------------- environment ------------- +ENVIRONMENT="production" +DEBUG=false + +# ------------- app settings ------------- +APP_NAME="MyApp" +APP_VERSION="1.0.0" +CONTACT_NAME="Support Team" +CONTACT_EMAIL="support@example.com" + +# ------------- database ------------- +POSTGRES_USER="prod_user" +POSTGRES_PASSWORD="ultra_secure_production_password_789!" +POSTGRES_SERVER="prod-db.example.com" +POSTGRES_PORT=5433 # Custom port for security +POSTGRES_DB="myapp_production" + +# ------------- crypt ------------- +SECRET_KEY="ultra-secure-production-key-generated-with-openssl-rand-hex-32" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=15 # Shorter for security +REFRESH_TOKEN_EXPIRE_DAYS=3 # Shorter for security + +# ------------- redis ------------- +REDIS_CACHE_HOST="prod-redis.example.com" +REDIS_CACHE_PORT=6380 # Custom port for security +REDIS_QUEUE_HOST="prod-redis.example.com" +REDIS_QUEUE_PORT=6380 +REDIS_RATE_LIMIT_HOST="prod-redis.example.com" +REDIS_RATE_LIMIT_PORT=6380 + +# ------------- caching ------------- +CLIENT_CACHE_MAX_AGE=3600 # 1 hour + +# ------------- rate limiting ------------- +DEFAULT_RATE_LIMIT_LIMIT=100 +DEFAULT_RATE_LIMIT_PERIOD=3600 + +# ------------- admin ------------- +ADMIN_NAME="System Administrator" +ADMIN_EMAIL="admin@example.com" +ADMIN_USERNAME="sysadmin" +ADMIN_PASSWORD="extremely_secure_admin_password_with_symbols_#$%!" + +# ------------- tier ------------- +TIER_NAME="production_tier" + +# ------------- logging ------------- +DATABASE_ECHO=false +``` + +### Production Security Features + +```python +# Production-specific features +if settings.ENVIRONMENT == "production": + # Strict CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["https://example.com", "https://www.example.com"], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Authorization", "Content-Type"], + ) + + # Disable API documentation + app.openapi_url = None + app.docs_url = None + app.redoc_url = None + + # Add security headers + @app.middleware("http") + async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response +``` + +### Docker Production Configuration + +`docker-compose.prod.yml`: + +```yaml +version: '3.8' + +services: + web: + environment: + - ENVIRONMENT=production + - DEBUG=false + deploy: + replicas: 3 + resources: + limits: + memory: 2G + cpus: '1' + reservations: + memory: 1G + cpus: '0.5' + restart: always + ports: [] # No direct exposure + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + - ./nginx/htpasswd:/etc/nginx/htpasswd + depends_on: + - web + restart: always + + db: + environment: + - POSTGRES_DB=myapp_production + volumes: + - postgres_prod_data:/var/lib/postgresql/data + ports: [] # No external access + deploy: + resources: + limits: + memory: 4G + reservations: + memory: 2G + restart: always + + redis: + volumes: + - redis_prod_data:/data + ports: [] # No external access + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + restart: always + + worker: + deploy: + replicas: 2 + resources: + limits: + memory: 1G + reservations: + memory: 512M + restart: always + +volumes: + postgres_prod_data: + redis_prod_data: +``` + +## Environment Detection + +### Runtime Environment Checks + +```python +# src/app/core/config.py +class Settings(BaseSettings): + @computed_field + @property + def IS_DEVELOPMENT(self) -> bool: + return self.ENVIRONMENT == "local" + + @computed_field + @property + def IS_PRODUCTION(self) -> bool: + return self.ENVIRONMENT == "production" + + @computed_field + @property + def IS_STAGING(self) -> bool: + return self.ENVIRONMENT == "staging" + +# Use in application +if settings.IS_DEVELOPMENT: + # Development-only code + pass + +if settings.IS_PRODUCTION: + # Production-only code + pass +``` + +### Environment-Specific Validation + +```python +@model_validator(mode="after") +def validate_environment_config(self) -> "Settings": + if self.ENVIRONMENT == "production": + # Production validation + if self.DEBUG: + raise ValueError("DEBUG must be False in production") + if len(self.SECRET_KEY) < 32: + raise ValueError("SECRET_KEY must be at least 32 characters in production") + if "dev" in self.SECRET_KEY.lower(): + raise ValueError("Production SECRET_KEY cannot contain 'dev'") + + if self.ENVIRONMENT == "local": + # Development warnings + if not self.DEBUG: + logger.warning("DEBUG is False in development environment") + + return self +``` + +## Configuration Management + +### Environment File Templates + +Create template files for each environment: + +```bash +# Create environment templates +cp src/.env.example src/.env.development +cp src/.env.example src/.env.staging +cp src/.env.example src/.env.production + +# Use environment-specific files +ln -sf .env.development src/.env # For development +ln -sf .env.staging src/.env # For staging +ln -sf .env.production src/.env # For production +``` + +### Configuration Validation + +```python +# src/scripts/validate_config.py +import asyncio +from src.app.core.config import settings +from src.app.core.db.database import async_get_db + +async def validate_configuration(): + """Validate configuration for current environment.""" + print(f"Validating configuration for {settings.ENVIRONMENT} environment...") + + # Basic settings validation + assert settings.APP_NAME, "APP_NAME is required" + assert settings.SECRET_KEY, "SECRET_KEY is required" + assert len(settings.SECRET_KEY) >= 32, "SECRET_KEY must be at least 32 characters" + + # Environment-specific validation + if settings.ENVIRONMENT == "production": + assert not settings.DEBUG, "DEBUG must be False in production" + assert "dev" not in settings.SECRET_KEY.lower(), "Production SECRET_KEY invalid" + assert settings.POSTGRES_PORT != 5432, "Use custom PostgreSQL port in production" + + # Test database connection + try: + db = await anext(async_get_db()) + print("✓ Database connection successful") + await db.close() + except Exception as e: + print(f"✗ Database connection failed: {e}") + return False + + print("✓ Configuration validation passed") + return True + +if __name__ == "__main__": + asyncio.run(validate_configuration()) +``` + +### Environment Switching + +```bash +#!/bin/bash +# scripts/switch_env.sh + +ENV=$1 + +if [ -z "$ENV" ]; then + echo "Usage: $0 " + exit 1 +fi + +case $ENV in + development) + ln -sf .env.development src/.env + echo "Switched to development environment" + ;; + staging) + ln -sf .env.staging src/.env + echo "Switched to staging environment" + ;; + production) + ln -sf .env.production src/.env + echo "Switched to production environment" + echo "WARNING: Make sure to review all settings before deployment!" + ;; + *) + echo "Invalid environment: $ENV" + echo "Valid options: development, staging, production" + exit 1 + ;; +esac + +# Validate configuration +python -c "from src.app.core.config import settings; print(f'Current environment: {settings.ENVIRONMENT}')" +``` + +## Security Best Practices + +### Environment-Specific Security + +```python +# Different security levels per environment +SECURITY_CONFIGS = { + "local": { + "token_expire_minutes": 60, + "enable_cors_origins": ["*"], + "enable_docs": True, + "log_level": "DEBUG", + }, + "staging": { + "token_expire_minutes": 30, + "enable_cors_origins": ["https://staging.example.com"], + "enable_docs": True, # For testing + "log_level": "INFO", + }, + "production": { + "token_expire_minutes": 15, + "enable_cors_origins": ["https://example.com"], + "enable_docs": False, + "log_level": "WARNING", + } +} + +config = SECURITY_CONFIGS[settings.ENVIRONMENT] +``` + +### Secrets Management + +```bash +# Use secrets management in production +# Instead of plain text environment variables +POSTGRES_PASSWORD_FILE="/run/secrets/postgres_password" +SECRET_KEY_FILE="/run/secrets/jwt_secret" + +# Docker secrets +services: + web: + secrets: + - postgres_password + - jwt_secret + environment: + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password + - SECRET_KEY_FILE=/run/secrets/jwt_secret + +secrets: + postgres_password: + external: true + jwt_secret: + external: true +``` + +## Monitoring and Logging + +### Environment-Specific Logging + +```python +LOGGING_CONFIG = { + "local": { + "level": "DEBUG", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "handlers": ["console"], + }, + "staging": { + "level": "INFO", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "handlers": ["console", "file"], + }, + "production": { + "level": "WARNING", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s", + "handlers": ["file", "syslog"], + } +} +``` + +### Health Checks by Environment + +```python +@app.get("/health") +async def health_check(): + health_info = { + "status": "healthy", + "environment": settings.ENVIRONMENT, + "version": settings.APP_VERSION, + } + + # Add detailed info in non-production + if not settings.IS_PRODUCTION: + health_info.update({ + "database": await check_database_health(), + "redis": await check_redis_health(), + "worker_queue": await check_worker_health(), + }) + + return health_info +``` + +## Best Practices + +### Security +- Use different secret keys for each environment +- Disable debug mode in staging and production +- Use custom ports in production +- Implement proper CORS policies +- Remove API documentation in production + +### Performance +- Configure appropriate resource limits per environment +- Use caching in staging and production +- Set shorter token expiration in production +- Use connection pooling in production + +### Configuration +- Keep environment files in version control (except production) +- Use validation to prevent misconfiguration +- Document all environment-specific settings +- Test configuration changes in staging first + +### Monitoring +- Use appropriate log levels per environment +- Monitor different metrics in each environment +- Set up alerts for production only +- Use health checks for all environments + +Environment-specific configuration ensures your application runs securely and efficiently in each deployment stage. Start with development settings and progressively harden for production! \ No newline at end of file diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md new file mode 100644 index 0000000..e1714d4 --- /dev/null +++ b/docs/user-guide/configuration/environment-variables.md @@ -0,0 +1,651 @@ +# Configuration Guide + +This guide covers all configuration options available in the FastAPI Boilerplate, including environment variables, settings classes, and advanced deployment configurations. + +## Configuration Overview + +The boilerplate uses a layered configuration approach: + +- **Environment Variables** (`.env` file) - Primary configuration method +- **Settings Classes** (`src/app/core/config.py`) - Python-based configuration +- **Docker Configuration** (`docker-compose.yml`) - Container orchestration +- **Database Configuration** (`alembic.ini`) - Database migrations + +## Environment Variables Reference + +All configuration is managed through environment variables defined in the `.env` file located in the `src/` directory. + +### Application Settings + +Basic application metadata displayed in API documentation: + +```env +# ------------- app settings ------------- +APP_NAME="Your App Name" +APP_DESCRIPTION="Your app description here" +APP_VERSION="0.1.0" +CONTACT_NAME="Your Name" +CONTACT_EMAIL="your.email@example.com" +LICENSE_NAME="MIT" +``` + +**Variables Explained:** + +- `APP_NAME`: Displayed in API documentation and responses +- `APP_DESCRIPTION`: Shown in OpenAPI documentation +- `APP_VERSION`: API version for documentation and headers +- `CONTACT_NAME`: Contact information for API documentation +- `CONTACT_EMAIL`: Support email for API users +- `LICENSE_NAME`: License type for the API + +### Database Configuration + +PostgreSQL database connection settings: + +```env +# ------------- database ------------- +POSTGRES_USER="your_postgres_user" +POSTGRES_PASSWORD="your_secure_password" +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_DB="your_database_name" +``` + +**Variables Explained:** + +- `POSTGRES_USER`: Database user with appropriate permissions +- `POSTGRES_PASSWORD`: Strong password for database access +- `POSTGRES_SERVER`: Hostname or IP of PostgreSQL server +- `POSTGRES_PORT`: PostgreSQL port (default: 5432) +- `POSTGRES_DB`: Name of the database to connect to + +**Environment-Specific Values:** + +```env +# Local development +POSTGRES_SERVER="localhost" + +# Docker Compose +POSTGRES_SERVER="db" + +# Production +POSTGRES_SERVER="your-prod-db-host.com" +``` + +### Security & Authentication + +JWT and password security configuration: + +```env +# ------------- crypt ------------- +SECRET_KEY="your-super-secret-key-here" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +**Variables Explained:** + +- `SECRET_KEY`: Used for JWT token signing (generate with `openssl rand -hex 32`) +- `ALGORITHM`: JWT signing algorithm (HS256 recommended) +- `ACCESS_TOKEN_EXPIRE_MINUTES`: How long access tokens remain valid +- `REFRESH_TOKEN_EXPIRE_DAYS`: How long refresh tokens remain valid + +!!! danger "Security Warning" + Never use default values in production. Generate a strong secret key: + ```bash + openssl rand -hex 32 + ``` + +### Redis Configuration + +Redis is used for caching, job queues, and rate limiting: + +```env +# ------------- redis cache ------------- +REDIS_CACHE_HOST="localhost" # Use "redis" for Docker Compose +REDIS_CACHE_PORT=6379 + +# ------------- redis queue ------------- +REDIS_QUEUE_HOST="localhost" # Use "redis" for Docker Compose +REDIS_QUEUE_PORT=6379 + +# ------------- redis rate limit ------------- +REDIS_RATE_LIMIT_HOST="localhost" # Use "redis" for Docker Compose +REDIS_RATE_LIMIT_PORT=6379 +``` + +**Best Practices:** + +- **Development**: Use the same Redis instance for all services +- **Production**: Use separate Redis instances for better isolation + +```env +# Production example with separate instances +REDIS_CACHE_HOST="cache.redis.example.com" +REDIS_QUEUE_HOST="queue.redis.example.com" +REDIS_RATE_LIMIT_HOST="ratelimit.redis.example.com" +``` + +### Caching Settings + +Client-side and server-side caching configuration: + +```env +# ------------- redis client-side cache ------------- +CLIENT_CACHE_MAX_AGE=30 # seconds +``` + +**Variables Explained:** + +- `CLIENT_CACHE_MAX_AGE`: How long browsers should cache responses + +### Rate Limiting + +Default rate limiting configuration: + +```env +# ------------- default rate limit settings ------------- +DEFAULT_RATE_LIMIT_LIMIT=10 # requests per period +DEFAULT_RATE_LIMIT_PERIOD=3600 # period in seconds (1 hour) +``` + +**Variables Explained:** + +- `DEFAULT_RATE_LIMIT_LIMIT`: Number of requests allowed per period +- `DEFAULT_RATE_LIMIT_PERIOD`: Time window in seconds + +### Admin User + +First superuser account configuration: + +```env +# ------------- admin ------------- +ADMIN_NAME="Admin User" +ADMIN_EMAIL="admin@example.com" +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="secure_admin_password" +``` + +**Variables Explained:** + +- `ADMIN_NAME`: Display name for the admin user +- `ADMIN_EMAIL`: Email address for the admin account +- `ADMIN_USERNAME`: Username for admin login +- `ADMIN_PASSWORD`: Initial password (change after first login) + +### User Tiers + +Initial tier configuration: + +```env +# ------------- first tier ------------- +TIER_NAME="free" +``` + +**Variables Explained:** + +- `TIER_NAME`: Name of the default user tier + +### Environment Type + +Controls API documentation visibility and behavior: + +```env +# ------------- environment ------------- +ENVIRONMENT="local" # local, staging, or production +``` + +**Environment Types:** + +- **local**: Full API docs available publicly at `/docs` +- **staging**: API docs available to superusers only +- **production**: API docs completely disabled + +## Docker Compose Configuration + +### Basic Setup + +Docker Compose automatically loads the `.env` file: + +```yaml +# In docker-compose.yml +services: + web: + env_file: + - ./src/.env +``` + +### Development Overrides + +Create `docker-compose.override.yml` for local customizations: + +```yaml +version: '3.8' +services: + web: + ports: + - "8001:8000" # Use different port + environment: + - DEBUG=true + volumes: + - ./custom-logs:/code/logs +``` + +### Service Configuration + +Understanding each Docker service: + +```yaml +services: + web: # FastAPI application + db: # PostgreSQL database + redis: # Redis for caching/queues + worker: # ARQ background task worker + nginx: # Reverse proxy (optional) +``` + +## Python Settings Classes + +Advanced configuration is handled in `src/app/core/config.py`: + +### Settings Composition + +The main `Settings` class inherits from multiple setting groups: + +```python +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + RedisCacheSettings, + ClientSideCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + DefaultRateLimitSettings, + EnvironmentSettings, +): + pass +``` + +### Adding Custom Settings + +Create your own settings group: + +```python +class CustomSettings(BaseSettings): + CUSTOM_API_KEY: str = "" + CUSTOM_TIMEOUT: int = 30 + ENABLE_FEATURE_X: bool = False + +# Add to main Settings class +class Settings( + AppSettings, + # ... other settings ... + CustomSettings, +): + pass +``` + +### Opting Out of Services + +Remove unused services by excluding their settings: + +```python +# Minimal setup without Redis services +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + # Removed: RedisCacheSettings + # Removed: RedisQueueSettings + # Removed: RedisRateLimiterSettings + EnvironmentSettings, +): + pass +``` + +## Database Configuration + +### Alembic Configuration + +Database migrations are configured in `src/alembic.ini`: + +```ini +[alembic] +script_location = migrations +sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_SERVER)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s +``` + +### Connection Pooling + +SQLAlchemy connection pool settings in `src/app/core/db/database.py`: + +```python +engine = create_async_engine( + DATABASE_URL, + pool_size=20, # Number of connections to maintain + max_overflow=30, # Additional connections allowed + pool_timeout=30, # Seconds to wait for connection + pool_recycle=1800, # Seconds before connection refresh +) +``` + +### Database Best Practices + +**Connection Pool Sizing:** +- Start with `pool_size=20`, `max_overflow=30` +- Monitor connection usage and adjust based on load +- Use connection pooling monitoring tools + +**Migration Strategy:** +- Always backup database before running migrations +- Test migrations on staging environment first +- Use `alembic revision --autogenerate` for model changes + +## Security Configuration + +### JWT Token Configuration + +Customize JWT behavior in `src/app/core/security.py`: + +```python +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) +``` + +### CORS Configuration + +Configure Cross-Origin Resource Sharing in `src/app/main.py`: + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], # Specify allowed origins + allow_credentials=True, + allow_methods=["GET", "POST"], # Specify allowed methods + allow_headers=["*"], +) +``` + +**Production CORS Settings:** + +```python +# Never use wildcard (*) in production +allow_origins=[ + "https://yourapp.com", + "https://www.yourapp.com" +], +``` + +### Security Headers + +Add security headers middleware: + +```python +from starlette.middleware.base import BaseHTTPMiddleware + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-XSS-Protection"] = "1; mode=block" + return response +``` + +## Logging Configuration + +### Basic Logging Setup + +Configure logging in `src/app/core/logger.py`: + +```python +import logging +from logging.handlers import RotatingFileHandler + +# Set log level +LOGGING_LEVEL = logging.INFO + +# Configure file rotation +file_handler = RotatingFileHandler( + 'logs/app.log', + maxBytes=10485760, # 10MB + backupCount=5 # Keep 5 backup files +) +``` + +### Structured Logging + +Use structured logging for better observability: + +```python +import structlog + +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.JSONRenderer() + ], + logger_factory=structlog.stdlib.LoggerFactory(), +) +``` + +### Log Levels by Environment + +```python +# Environment-specific log levels +LOG_LEVELS = { + "local": logging.DEBUG, + "staging": logging.INFO, + "production": logging.WARNING +} + +LOGGING_LEVEL = LOG_LEVELS.get(settings.ENVIRONMENT, logging.INFO) +``` + +## Environment-Specific Configurations + +### Development (.env.development) + +```env +ENVIRONMENT="local" +POSTGRES_SERVER="localhost" +REDIS_CACHE_HOST="localhost" +SECRET_KEY="dev-secret-key-not-for-production" +ACCESS_TOKEN_EXPIRE_MINUTES=60 # Longer for development +DEBUG=true +``` + +### Staging (.env.staging) + +```env +ENVIRONMENT="staging" +POSTGRES_SERVER="staging-db.example.com" +REDIS_CACHE_HOST="staging-redis.example.com" +SECRET_KEY="staging-secret-key-different-from-prod" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +DEBUG=false +``` + +### Production (.env.production) + +```env +ENVIRONMENT="production" +POSTGRES_SERVER="prod-db.example.com" +REDIS_CACHE_HOST="prod-redis.example.com" +SECRET_KEY="ultra-secure-production-key-generated-with-openssl" +ACCESS_TOKEN_EXPIRE_MINUTES=15 +DEBUG=false +REDIS_CACHE_PORT=6380 # Custom port for security +POSTGRES_PORT=5433 # Custom port for security +``` + +## Advanced Configuration + +### Custom Middleware + +Add custom middleware in `src/app/core/setup.py`: + +```python +def create_application(router, settings, **kwargs): + app = FastAPI(...) + + # Add custom middleware + app.add_middleware(CustomMiddleware, setting=value) + app.add_middleware(TimingMiddleware) + app.add_middleware(RequestIDMiddleware) + + return app +``` + +### Feature Toggles + +Implement feature flags: + +```python +class FeatureSettings(BaseSettings): + ENABLE_ADVANCED_CACHING: bool = False + ENABLE_ANALYTICS: bool = True + ENABLE_EXPERIMENTAL_FEATURES: bool = False + ENABLE_API_VERSIONING: bool = True + +# Use in endpoints +if settings.ENABLE_ADVANCED_CACHING: + # Advanced caching logic + pass +``` + +### Health Checks + +Configure health check endpoints: + +```python +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "database": await check_database_health(), + "redis": await check_redis_health(), + "version": settings.APP_VERSION + } +``` + +## Configuration Validation + +### Environment Validation + +Add validation to prevent misconfiguration: + +```python +def validate_settings(): + if not settings.SECRET_KEY: + raise ValueError("SECRET_KEY must be set") + + if settings.ENVIRONMENT == "production": + if settings.SECRET_KEY == "dev-secret-key": + raise ValueError("Production must use secure SECRET_KEY") + + if settings.DEBUG: + raise ValueError("DEBUG must be False in production") +``` + +### Runtime Checks + +Add validation to application startup: + +```python +@app.on_event("startup") +async def startup_event(): + validate_settings() + await check_database_connection() + await check_redis_connection() + logger.info(f"Application started in {settings.ENVIRONMENT} mode") +``` + +## Configuration Troubleshooting + +### Common Issues + +**Environment Variables Not Loading:** +```bash +# Check file location and permissions +ls -la src/.env + +# Check file format (no spaces around =) +cat src/.env | grep "=" | head -5 + +# Verify environment loading in Python +python -c "from src.app.core.config import settings; print(settings.APP_NAME)" +``` + +**Database Connection Failed:** +```bash +# Test connection manually +psql -h localhost -U postgres -d myapp + +# Check if PostgreSQL is running +systemctl status postgresql +# or on macOS +brew services list | grep postgresql +``` + +**Redis Connection Failed:** +```bash +# Test Redis connection +redis-cli -h localhost -p 6379 ping + +# Check Redis status +systemctl status redis +# or on macOS +brew services list | grep redis +``` + +### Configuration Testing + +Test your configuration with a simple script: + +```python +# test_config.py +import asyncio +from src.app.core.config import settings +from src.app.core.db.database import async_get_db + +async def test_config(): + print(f"App: {settings.APP_NAME}") + print(f"Environment: {settings.ENVIRONMENT}") + + # Test database + try: + db = await anext(async_get_db()) + print("✓ Database connection successful") + await db.close() + except Exception as e: + print(f"✗ Database connection failed: {e}") + + # Test Redis (if enabled) + try: + from src.app.core.utils.cache import redis_client + await redis_client.ping() + print("✓ Redis connection successful") + except Exception as e: + print(f"✗ Redis connection failed: {e}") + +if __name__ == "__main__": + asyncio.run(test_config()) +``` + +Run with: +```bash +uv run python test_config.py +``` \ No newline at end of file diff --git a/docs/user-guide/configuration/index.md b/docs/user-guide/configuration/index.md new file mode 100644 index 0000000..ad825d6 --- /dev/null +++ b/docs/user-guide/configuration/index.md @@ -0,0 +1,311 @@ +# Configuration + +Learn how to configure your FastAPI Boilerplate application for different environments and use cases. Everything is configured through environment variables and Python settings classes. + +## What You'll Learn + +- **[Environment Variables](environment-variables.md)** - Configure through `.env` files +- **[Settings Classes](settings-classes.md)** - Python-based configuration management +- **[Docker Setup](docker-setup.md)** - Container and service configuration +- **[Environment-Specific](environment-specific.md)** - Development, staging, and production configs + +## Quick Start + +The boilerplate uses environment variables as the primary configuration method: + +```bash +# Copy the example file +cp src/.env.example src/.env + +# Edit with your values +nano src/.env +``` + +Essential variables to set: + +```env +# Application +APP_NAME="My FastAPI App" +SECRET_KEY="your-super-secret-key-here" + +# Database +POSTGRES_USER="your_user" +POSTGRES_PASSWORD="your_password" +POSTGRES_DB="your_database" + +# Admin Account +ADMIN_EMAIL="admin@example.com" +ADMIN_PASSWORD="secure_password" +``` + +## Configuration Architecture + +The configuration system has three layers: + +``` +Environment Variables (.env files) + ↓ +Settings Classes (Python validation) + ↓ +Application Configuration (Runtime) +``` + +### Layer 1: Environment Variables +Primary configuration through `.env` files: +```env +POSTGRES_USER="myuser" +POSTGRES_PASSWORD="mypassword" +REDIS_CACHE_HOST="localhost" +SECRET_KEY="your-secret-key" +``` + +### Layer 2: Settings Classes +Python classes that validate and structure configuration: +```python +class PostgresSettings(BaseSettings): + POSTGRES_USER: str + POSTGRES_PASSWORD: str = Field(min_length=8) + POSTGRES_SERVER: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str +``` + +### Layer 3: Application Use +Configuration injected throughout the application: +```python +from app.core.config import settings + +# Use anywhere in your code +DATABASE_URL = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_SERVER}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" +``` + +## Key Configuration Areas + +### Security Settings +```env +SECRET_KEY="your-super-secret-key-here" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +### Database Configuration +```env +POSTGRES_USER="your_user" +POSTGRES_PASSWORD="your_password" +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_DB="your_database" +``` + +### Redis Services +```env +# Cache +REDIS_CACHE_HOST="localhost" +REDIS_CACHE_PORT=6379 + +# Background jobs +REDIS_QUEUE_HOST="localhost" +REDIS_QUEUE_PORT=6379 + +# Rate limiting +REDIS_RATE_LIMIT_HOST="localhost" +REDIS_RATE_LIMIT_PORT=6379 +``` + +### Application Settings +```env +APP_NAME="Your App Name" +APP_VERSION="1.0.0" +ENVIRONMENT="local" # local, staging, production +DEBUG=true +``` + +### Rate Limiting +```env +DEFAULT_RATE_LIMIT_LIMIT=100 +DEFAULT_RATE_LIMIT_PERIOD=3600 # 1 hour in seconds +``` + +### Admin User +```env +ADMIN_NAME="Admin User" +ADMIN_EMAIL="admin@example.com" +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="secure_password" +``` + +## Environment-Specific Configurations + +### Development +```env +ENVIRONMENT="local" +DEBUG=true +POSTGRES_SERVER="localhost" +REDIS_CACHE_HOST="localhost" +ACCESS_TOKEN_EXPIRE_MINUTES=60 # Longer for development +``` + +### Staging +```env +ENVIRONMENT="staging" +DEBUG=false +POSTGRES_SERVER="staging-db.example.com" +REDIS_CACHE_HOST="staging-redis.example.com" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +``` + +### Production +```env +ENVIRONMENT="production" +DEBUG=false +POSTGRES_SERVER="prod-db.example.com" +REDIS_CACHE_HOST="prod-redis.example.com" +ACCESS_TOKEN_EXPIRE_MINUTES=15 +# Use custom ports for security +POSTGRES_PORT=5433 +REDIS_CACHE_PORT=6380 +``` + +## Docker Configuration + +### Basic Setup +Docker Compose automatically loads your `.env` file: + +```yaml +services: + web: + env_file: + - ./src/.env + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} +``` + +### Service Overview +```yaml +services: + web: # FastAPI application + db: # PostgreSQL database + redis: # Redis for caching/queues + worker: # Background task worker +``` + +## Common Configuration Patterns + +### Feature Flags +```python +# In settings class +class FeatureSettings(BaseSettings): + ENABLE_CACHING: bool = True + ENABLE_ANALYTICS: bool = False + ENABLE_BACKGROUND_JOBS: bool = True + +# Use in code +if settings.ENABLE_CACHING: + cache_result = await get_from_cache(key) +``` + +### Environment Detection +```python +@app.get("/docs", include_in_schema=False) +async def custom_swagger_ui(): + if settings.ENVIRONMENT == "production": + raise HTTPException(404, "Documentation not available") + return get_swagger_ui_html(openapi_url="/openapi.json") +``` + +### Health Checks +```python +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "environment": settings.ENVIRONMENT, + "version": settings.APP_VERSION, + "database": await check_database_health(), + "redis": await check_redis_health() + } +``` + +## Quick Configuration Tasks + +### Generate Secret Key +```bash +# Generate a secure secret key +openssl rand -hex 32 +``` + +### Test Configuration +```python +# test_config.py +from app.core.config import settings + +print(f"App: {settings.APP_NAME}") +print(f"Environment: {settings.ENVIRONMENT}") +print(f"Database: {settings.POSTGRES_DB}") +``` + +### Environment File Templates +```bash +# Development +cp src/.env.example src/.env.development + +# Staging +cp src/.env.example src/.env.staging + +# Production +cp src/.env.example src/.env.production +``` + +## Best Practices + +### Security +- Never commit `.env` files to version control +- Use different secret keys for each environment +- Disable debug mode in production +- Use secure passwords and keys + +### Performance +- Configure appropriate connection pool sizes +- Set reasonable token expiration times +- Use Redis for caching in production +- Configure proper rate limits + +### Maintenance +- Document all custom environment variables +- Use validation in settings classes +- Test configurations in staging first +- Monitor configuration changes + +### Testing +- Use separate test environment variables +- Mock external services in tests +- Validate configuration on startup +- Test with different environment combinations + +## Getting Started + +Follow this path to configure your application: + +### 1. **[Environment Variables](environment-variables.md)** - Start here +Learn about all available environment variables, their purposes, and recommended values for different environments. + +### 2. **[Settings Classes](settings-classes.md)** - Validation layer +Understand how Python settings classes validate and structure your configuration with type hints and validation rules. + +### 3. **[Docker Setup](docker-setup.md)** - Container configuration +Configure Docker Compose services, networking, and environment-specific overrides. + +### 4. **[Environment-Specific](environment-specific.md)** - Deployment configs +Set up configuration for development, staging, and production environments with best practices. + +## What's Next + +Each guide provides practical examples and copy-paste configurations: + +1. **[Environment Variables](environment-variables.md)** - Complete reference and examples +2. **[Settings Classes](settings-classes.md)** - Custom validation and organization +3. **[Docker Setup](docker-setup.md)** - Service configuration and overrides +4. **[Environment-Specific](environment-specific.md)** - Production-ready configurations + +The boilerplate provides sensible defaults - just customize what you need! \ No newline at end of file diff --git a/docs/user-guide/configuration/settings-classes.md b/docs/user-guide/configuration/settings-classes.md new file mode 100644 index 0000000..2a9e932 --- /dev/null +++ b/docs/user-guide/configuration/settings-classes.md @@ -0,0 +1,537 @@ +# Settings Classes + +Learn how Python settings classes validate, structure, and organize your application configuration. The boilerplate uses Pydantic's `BaseSettings` for type-safe configuration management. + +## Settings Architecture + +The main `Settings` class inherits from multiple specialized setting groups: + +```python +# src/app/core/config.py +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + RedisCacheSettings, + ClientSideCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + DefaultRateLimitSettings, + EnvironmentSettings, +): + pass + +# Single instance used throughout the app +settings = Settings() +``` + +## Built-in Settings Groups + +### Application Settings +Basic app metadata and configuration: + +```python +class AppSettings(BaseSettings): + APP_NAME: str = "FastAPI" + APP_DESCRIPTION: str = "A FastAPI project" + APP_VERSION: str = "0.1.0" + CONTACT_NAME: str = "Your Name" + CONTACT_EMAIL: str = "your.email@example.com" + LICENSE_NAME: str = "MIT" +``` + +### Database Settings +PostgreSQL connection configuration: + +```python +class PostgresSettings(BaseSettings): + POSTGRES_USER: str + POSTGRES_PASSWORD: str + POSTGRES_SERVER: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str + + @computed_field + @property + def DATABASE_URL(self) -> str: + return ( + f"postgresql+asyncpg://{self.POSTGRES_USER}:" + f"{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:" + f"{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) +``` + +### Security Settings +JWT and authentication configuration: + +```python +class CryptSettings(BaseSettings): + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + @field_validator("SECRET_KEY") + @classmethod + def validate_secret_key(cls, v: str) -> str: + if len(v) < 32: + raise ValueError("SECRET_KEY must be at least 32 characters") + return v +``` + +### Redis Settings +Separate Redis instances for different services: + +```python +class RedisCacheSettings(BaseSettings): + REDIS_CACHE_HOST: str = "localhost" + REDIS_CACHE_PORT: int = 6379 + +class RedisQueueSettings(BaseSettings): + REDIS_QUEUE_HOST: str = "localhost" + REDIS_QUEUE_PORT: int = 6379 + +class RedisRateLimiterSettings(BaseSettings): + REDIS_RATE_LIMIT_HOST: str = "localhost" + REDIS_RATE_LIMIT_PORT: int = 6379 +``` + +### Rate Limiting Settings +Default rate limiting configuration: + +```python +class DefaultRateLimitSettings(BaseSettings): + DEFAULT_RATE_LIMIT_LIMIT: int = 10 + DEFAULT_RATE_LIMIT_PERIOD: int = 3600 # 1 hour +``` + +### Admin User Settings +First superuser account creation: + +```python +class FirstUserSettings(BaseSettings): + ADMIN_NAME: str = "Admin" + ADMIN_EMAIL: str + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str + + @field_validator("ADMIN_EMAIL") + @classmethod + def validate_admin_email(cls, v: str) -> str: + if "@" not in v: + raise ValueError("ADMIN_EMAIL must be a valid email") + return v +``` + +## Creating Custom Settings + +### Basic Custom Settings + +Add your own settings group: + +```python +class CustomSettings(BaseSettings): + CUSTOM_API_KEY: str = "" + CUSTOM_TIMEOUT: int = 30 + ENABLE_FEATURE_X: bool = False + MAX_UPLOAD_SIZE: int = 10485760 # 10MB + + @field_validator("MAX_UPLOAD_SIZE") + @classmethod + def validate_upload_size(cls, v: int) -> int: + if v < 1024: # 1KB minimum + raise ValueError("MAX_UPLOAD_SIZE must be at least 1KB") + if v > 104857600: # 100MB maximum + raise ValueError("MAX_UPLOAD_SIZE cannot exceed 100MB") + return v + +# Add to main Settings class +class Settings( + AppSettings, + PostgresSettings, + # ... other settings ... + CustomSettings, # Add your custom settings +): + pass +``` + +### Advanced Custom Settings + +Settings with complex validation and computed fields: + +```python +class EmailSettings(BaseSettings): + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USERNAME: str = "" + SMTP_PASSWORD: str = "" + SMTP_USE_TLS: bool = True + EMAIL_FROM: str = "" + EMAIL_FROM_NAME: str = "" + + @computed_field + @property + def EMAIL_ENABLED(self) -> bool: + return bool(self.SMTP_HOST and self.SMTP_USERNAME) + + @model_validator(mode="after") + def validate_email_config(self) -> "EmailSettings": + if self.SMTP_HOST and not self.EMAIL_FROM: + raise ValueError("EMAIL_FROM required when SMTP_HOST is set") + if self.SMTP_USERNAME and not self.SMTP_PASSWORD: + raise ValueError("SMTP_PASSWORD required when SMTP_USERNAME is set") + return self +``` + +### Feature Flag Settings + +Organize feature toggles: + +```python +class FeatureSettings(BaseSettings): + # Core features + ENABLE_CACHING: bool = True + ENABLE_RATE_LIMITING: bool = True + ENABLE_BACKGROUND_JOBS: bool = True + + # Optional features + ENABLE_ANALYTICS: bool = False + ENABLE_EMAIL_NOTIFICATIONS: bool = False + ENABLE_FILE_UPLOADS: bool = False + + # Experimental features + ENABLE_EXPERIMENTAL_API: bool = False + ENABLE_BETA_FEATURES: bool = False + + @model_validator(mode="after") + def validate_feature_dependencies(self) -> "FeatureSettings": + if self.ENABLE_EMAIL_NOTIFICATIONS and not self.ENABLE_BACKGROUND_JOBS: + raise ValueError("Email notifications require background jobs") + return self +``` + +## Settings Validation + +### Field Validation + +Validate individual fields: + +```python +class DatabaseSettings(BaseSettings): + DB_POOL_SIZE: int = 20 + DB_MAX_OVERFLOW: int = 30 + DB_TIMEOUT: int = 30 + + @field_validator("DB_POOL_SIZE") + @classmethod + def validate_pool_size(cls, v: int) -> int: + if v < 1: + raise ValueError("Pool size must be at least 1") + if v > 100: + raise ValueError("Pool size should not exceed 100") + return v + + @field_validator("DB_TIMEOUT") + @classmethod + def validate_timeout(cls, v: int) -> int: + if v < 5: + raise ValueError("Timeout must be at least 5 seconds") + return v +``` + +### Model Validation + +Validate across multiple fields: + +```python +class SecuritySettings(BaseSettings): + ENABLE_HTTPS: bool = False + SSL_CERT_PATH: str = "" + SSL_KEY_PATH: str = "" + FORCE_SSL: bool = False + + @model_validator(mode="after") + def validate_ssl_config(self) -> "SecuritySettings": + if self.ENABLE_HTTPS: + if not self.SSL_CERT_PATH: + raise ValueError("SSL_CERT_PATH required when HTTPS enabled") + if not self.SSL_KEY_PATH: + raise ValueError("SSL_KEY_PATH required when HTTPS enabled") + + if self.FORCE_SSL and not self.ENABLE_HTTPS: + raise ValueError("Cannot force SSL without enabling HTTPS") + + return self +``` + +### Environment-Specific Validation + +Different validation rules per environment: + +```python +class EnvironmentSettings(BaseSettings): + ENVIRONMENT: str = "local" + DEBUG: bool = True + + @model_validator(mode="after") + def validate_environment_config(self) -> "EnvironmentSettings": + if self.ENVIRONMENT == "production": + if self.DEBUG: + raise ValueError("DEBUG must be False in production") + + if self.ENVIRONMENT not in ["local", "staging", "production"]: + raise ValueError("ENVIRONMENT must be local, staging, or production") + + return self +``` + +## Computed Properties + +### Dynamic Configuration + +Create computed values from other settings: + +```python +class StorageSettings(BaseSettings): + STORAGE_TYPE: str = "local" # local, s3, gcs + + # Local storage + LOCAL_STORAGE_PATH: str = "./uploads" + + # S3 settings + AWS_ACCESS_KEY_ID: str = "" + AWS_SECRET_ACCESS_KEY: str = "" + AWS_BUCKET_NAME: str = "" + AWS_REGION: str = "us-east-1" + + @computed_field + @property + def STORAGE_ENABLED(self) -> bool: + if self.STORAGE_TYPE == "local": + return bool(self.LOCAL_STORAGE_PATH) + elif self.STORAGE_TYPE == "s3": + return bool(self.AWS_ACCESS_KEY_ID and self.AWS_SECRET_ACCESS_KEY and self.AWS_BUCKET_NAME) + return False + + @computed_field + @property + def STORAGE_CONFIG(self) -> dict: + if self.STORAGE_TYPE == "local": + return {"path": self.LOCAL_STORAGE_PATH} + elif self.STORAGE_TYPE == "s3": + return { + "bucket": self.AWS_BUCKET_NAME, + "region": self.AWS_REGION, + "credentials": { + "access_key": self.AWS_ACCESS_KEY_ID, + "secret_key": self.AWS_SECRET_ACCESS_KEY, + } + } + return {} +``` + +## Organizing Settings + +### Service-Based Organization + +Group settings by service or domain: + +```python +# Authentication service settings +class AuthSettings(BaseSettings): + JWT_SECRET_KEY: str + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE: int = 30 + REFRESH_TOKEN_EXPIRE: int = 7200 + PASSWORD_MIN_LENGTH: int = 8 + +# Notification service settings +class NotificationSettings(BaseSettings): + EMAIL_ENABLED: bool = False + SMS_ENABLED: bool = False + PUSH_ENABLED: bool = False + + # Email settings + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + + # SMS settings (example with Twilio) + TWILIO_ACCOUNT_SID: str = "" + TWILIO_AUTH_TOKEN: str = "" + +# Main settings +class Settings( + AppSettings, + AuthSettings, + NotificationSettings, + # ... other settings +): + pass +``` + +### Conditional Settings Loading + +Load different settings based on environment: + +```python +class BaseAppSettings(BaseSettings): + APP_NAME: str = "FastAPI App" + DEBUG: bool = False + +class DevelopmentSettings(BaseAppSettings): + DEBUG: bool = True + LOG_LEVEL: str = "DEBUG" + DATABASE_ECHO: bool = True + +class ProductionSettings(BaseAppSettings): + DEBUG: bool = False + LOG_LEVEL: str = "WARNING" + DATABASE_ECHO: bool = False + +def get_settings() -> BaseAppSettings: + environment = os.getenv("ENVIRONMENT", "local") + + if environment == "production": + return ProductionSettings() + else: + return DevelopmentSettings() + +settings = get_settings() +``` + +## Removing Unused Services + +### Minimal Configuration + +Remove services you don't need: + +```python +# Minimal setup without Redis services +class MinimalSettings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + # Removed: RedisCacheSettings + # Removed: RedisQueueSettings + # Removed: RedisRateLimiterSettings + EnvironmentSettings, +): + pass +``` + +### Service Feature Flags + +Use feature flags to conditionally enable services: + +```python +class ServiceSettings(BaseSettings): + ENABLE_REDIS: bool = True + ENABLE_CELERY: bool = True + ENABLE_MONITORING: bool = False + +class ConditionalSettings( + AppSettings, + PostgresSettings, + CryptSettings, + ServiceSettings, +): + # Add Redis settings only if enabled + def __init__(self, **kwargs): + super().__init__(**kwargs) + + if self.ENABLE_REDIS: + # Dynamically add Redis settings + self.__class__ = type( + "ConditionalSettings", + (self.__class__, RedisCacheSettings), + {} + ) +``` + +## Testing Settings + +### Test Configuration + +Create separate settings for testing: + +```python +class TestSettings(BaseSettings): + # Override database for testing + POSTGRES_DB: str = "test_database" + + # Disable external services + ENABLE_REDIS: bool = False + ENABLE_EMAIL: bool = False + + # Speed up tests + ACCESS_TOKEN_EXPIRE_MINUTES: int = 5 + + # Test-specific settings + TEST_USER_EMAIL: str = "test@example.com" + TEST_USER_PASSWORD: str = "testpassword123" + +# Use in tests +@pytest.fixture +def test_settings(): + return TestSettings() +``` + +### Settings Validation Testing + +Test your custom settings: + +```python +def test_custom_settings_validation(): + # Test valid configuration + settings = CustomSettings( + CUSTOM_API_KEY="test-key", + CUSTOM_TIMEOUT=60, + MAX_UPLOAD_SIZE=5242880 # 5MB + ) + assert settings.CUSTOM_TIMEOUT == 60 + + # Test validation error + with pytest.raises(ValueError, match="MAX_UPLOAD_SIZE cannot exceed 100MB"): + CustomSettings(MAX_UPLOAD_SIZE=209715200) # 200MB + +def test_settings_computed_fields(): + settings = StorageSettings( + STORAGE_TYPE="s3", + AWS_ACCESS_KEY_ID="test-key", + AWS_SECRET_ACCESS_KEY="test-secret", + AWS_BUCKET_NAME="test-bucket" + ) + + assert settings.STORAGE_ENABLED is True + assert settings.STORAGE_CONFIG["bucket"] == "test-bucket" +``` + +## Best Practices + +### Organization +- Group related settings in dedicated classes +- Use descriptive names for settings groups +- Keep validation logic close to the settings +- Document complex validation rules + +### Security +- Validate sensitive settings like secret keys +- Never set default values for secrets in production +- Use computed fields to derive connection strings +- Separate test and production configurations + +### Performance +- Use `@computed_field` for expensive calculations +- Cache settings instances appropriately +- Avoid complex validation in hot paths +- Use model validators for cross-field validation + +### Testing +- Create separate test settings classes +- Test all validation rules +- Mock external service settings in tests +- Use dependency injection for settings in tests + +The settings system provides type safety, validation, and organization for your application configuration. Start with the built-in settings and extend them as your application grows! \ No newline at end of file diff --git a/docs/user-guide/database/crud.md b/docs/user-guide/database/crud.md new file mode 100644 index 0000000..1bf5a98 --- /dev/null +++ b/docs/user-guide/database/crud.md @@ -0,0 +1,491 @@ +# CRUD Operations + +This guide covers all CRUD (Create, Read, Update, Delete) operations available in the FastAPI Boilerplate using FastCRUD, a powerful library that provides consistent and efficient database operations. + +## Overview + +The boilerplate uses [FastCRUD](https://github.com/igorbenav/fastcrud) for all database operations. FastCRUD provides: + +- **Consistent API** across all models +- **Type safety** with generic type parameters +- **Automatic pagination** support +- **Advanced filtering** and joining capabilities +- **Soft delete** support +- **Optimized queries** with selective field loading + +## CRUD Class Structure + +Each model has a corresponding CRUD class that defines the available operations: + +```python +# src/app/crud/crud_users.py +from fastcrud import FastCRUD +from app.models.user import User +from app.schemas.user import ( + UserCreateInternal, UserUpdate, UserUpdateInternal, + UserDelete, UserRead +) + +CRUDUser = FastCRUD[ + User, # Model class + UserCreateInternal, # Create schema + UserUpdate, # Update schema + UserUpdateInternal, # Internal update schema + UserDelete, # Delete schema + UserRead # Read schema +] +crud_users = CRUDUser(User) +``` + +## Read Operations + +### Get Single Record + +Retrieve a single record by any field: + +```python +# Get user by ID +user = await crud_users.get(db=db, id=user_id) + +# Get user by username +user = await crud_users.get(db=db, username="john_doe") + +# Get user by email +user = await crud_users.get(db=db, email="john@example.com") + +# Get with specific fields only +user = await crud_users.get( + db=db, + schema_to_select=UserRead, # Only select fields defined in UserRead + id=user_id, +) +``` + +**Real usage from the codebase:** + +```python +# From src/app/api/v1/users.py +db_user = await crud_users.get( + db=db, + schema_to_select=UserRead, + username=username, + is_deleted=False, +) +``` + +### Get Multiple Records + +Retrieve multiple records with filtering and pagination: + +```python +# Get all users +users = await crud_users.get_multi(db=db) + +# Get with pagination +users = await crud_users.get_multi( + db=db, + offset=0, # Skip first 0 records + limit=10, # Return maximum 10 records +) + +# Get with filtering +active_users = await crud_users.get_multi( + db=db, + is_deleted=False, # Filter condition + offset=compute_offset(page, items_per_page), + limit=items_per_page +) +``` + +**Pagination response structure:** + +```python +{ + "data": [ + {"id": 1, "username": "john", "email": "john@example.com"}, + {"id": 2, "username": "jane", "email": "jane@example.com"} + ], + "total_count": 25, + "has_more": true, + "page": 1, + "items_per_page": 10 +} +``` + +### Check Existence + +Check if a record exists without fetching it: + +```python +# Check if user exists +user_exists = await crud_users.exists(db=db, email="john@example.com") +# Returns True or False + +# Check if username is available +username_taken = await crud_users.exists(db=db, username="john_doe") +``` + +**Real usage example:** + +```python +# From src/app/api/v1/users.py - checking before creating +email_row = await crud_users.exists(db=db, email=user.email) +if email_row: + raise DuplicateValueException("Email is already registered") +``` + +### Count Records + +Get count of records matching criteria: + +```python +# Count all users +total_users = await crud_users.count(db=db) + +# Count active users +active_count = await crud_users.count(db=db, is_deleted=False) + +# Count by specific criteria +admin_count = await crud_users.count(db=db, is_superuser=True) +``` + +## Create Operations + +### Basic Creation + +Create new records using Pydantic schemas: + +```python +# Create user +user_data = UserCreateInternal( + username="john_doe", + email="john@example.com", + hashed_password="hashed_password_here" +) + +created_user = await crud_users.create(db=db, object=user_data) +``` + +**Real creation example:** + +```python +# From src/app/api/v1/users.py +user_internal_dict = user.model_dump() +user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) +del user_internal_dict["password"] + +user_internal = UserCreateInternal(**user_internal_dict) +created_user = await crud_users.create(db=db, object=user_internal) +``` + +### Create with Relationships + +When creating records with foreign keys: + +```python +# Create post for a user +post_data = PostCreateInternal( + title="My First Post", + content="This is the content of my post", + created_by_user_id=user.id # Foreign key reference +) + +created_post = await crud_posts.create(db=db, object=post_data) +``` + +## Update Operations + +### Basic Updates + +Update records by any field: + +```python +# Update user by ID +update_data = UserUpdate(email="newemail@example.com") +await crud_users.update(db=db, object=update_data, id=user_id) + +# Update by username +await crud_users.update(db=db, object=update_data, username="john_doe") + +# Update multiple fields +update_data = UserUpdate( + email="newemail@example.com", + profile_image_url="https://newimage.com/photo.jpg" +) +await crud_users.update(db=db, object=update_data, id=user_id) +``` + +### Conditional Updates + +Update with validation: + +```python +# From real endpoint - check before updating +if values.username != db_user.username: + existing_username = await crud_users.exists(db=db, username=values.username) + if existing_username: + raise DuplicateValueException("Username not available") + +await crud_users.update(db=db, object=values, username=username) +``` + +### Bulk Updates + +Update multiple records at once: + +```python +# Update all users with specific criteria +update_data = {"is_active": False} +await crud_users.update(db=db, object=update_data, is_deleted=True) +``` + +## Delete Operations + +### Soft Delete + +For models with soft delete fields (like User, Post): + +```python +# Soft delete - sets is_deleted=True, deleted_at=now() +await crud_users.delete(db=db, username="john_doe") + +# The record stays in the database but is marked as deleted +user = await crud_users.get(db=db, username="john_doe", is_deleted=True) +``` + +### Hard Delete + +Permanently remove records from the database: + +```python +# Permanently delete from database +await crud_users.db_delete(db=db, username="john_doe") + +# The record is completely removed +``` + +**Real deletion example:** + +```python +# From src/app/api/v1/users.py +# Regular users get soft delete +await crud_users.delete(db=db, username=username) + +# Superusers can hard delete +await crud_users.db_delete(db=db, username=username) +``` + +## Advanced Operations + +### Joined Queries + +Get data from multiple related tables: + +```python +# Get posts with user information +posts_with_users = await crud_posts.get_multi_joined( + db=db, + join_model=User, + join_on=Post.created_by_user_id == User.id, + schema_to_select=PostRead, + join_schema_to_select=UserRead, + join_prefix="user_" +) +``` + +Result structure: +```python +{ + "id": 1, + "title": "My Post", + "content": "Post content", + "user_id": 123, + "user_username": "john_doe", + "user_email": "john@example.com" +} +``` + +### Custom Filtering + +Advanced filtering with SQLAlchemy expressions: + +```python +from sqlalchemy import and_, or_ + +# Complex filters +users = await crud_users.get_multi( + db=db, + filter_criteria=[ + and_( + User.is_deleted == False, + User.created_at > datetime(2024, 1, 1) + ) + ] +) +``` + +### Optimized Field Selection + +Select only needed fields for better performance: + +```python +# Only select id and username +users = await crud_users.get_multi( + db=db, + schema_to_select=UserRead, # Use schema to define fields + limit=100 +) + +# Or specify fields directly +users = await crud_users.get_multi( + db=db, + schema_to_select=["id", "username", "email"], + limit=100 +) +``` + +## Practical Examples + +### Complete CRUD Workflow + +Here's a complete example showing all CRUD operations: + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from app.crud.crud_users import crud_users +from app.schemas.user import UserCreateInternal, UserUpdate, UserRead + +async def user_management_example(db: AsyncSession): + # 1. CREATE + user_data = UserCreateInternal( + username="demo_user", + email="demo@example.com", + hashed_password="hashed_password" + ) + new_user = await crud_users.create(db=db, object=user_data) + print(f"Created user: {new_user.id}") + + # 2. READ + user = await crud_users.get( + db=db, + id=new_user.id, + schema_to_select=UserRead + ) + print(f"Retrieved user: {user.username}") + + # 3. UPDATE + update_data = UserUpdate(email="updated@example.com") + await crud_users.update(db=db, object=update_data, id=new_user.id) + print("User updated") + + # 4. DELETE (soft delete) + await crud_users.delete(db=db, id=new_user.id) + print("User soft deleted") + + # 5. VERIFY DELETION + deleted_user = await crud_users.get(db=db, id=new_user.id, is_deleted=True) + print(f"User deleted at: {deleted_user.deleted_at}") +``` + +### Pagination Helper + +Using FastCRUD's pagination utilities: + +```python +from fastcrud.paginated import compute_offset, paginated_response + +async def get_paginated_users( + db: AsyncSession, + page: int = 1, + items_per_page: int = 10 +): + users_data = await crud_users.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + is_deleted=False, + schema_to_select=UserRead + ) + + return paginated_response( + crud_data=users_data, + page=page, + items_per_page=items_per_page + ) +``` + +### Error Handling + +Proper error handling with CRUD operations: + +```python +from app.core.exceptions.http_exceptions import NotFoundException, DuplicateValueException + +async def safe_user_creation(db: AsyncSession, user_data: UserCreate): + # Check for duplicates + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already registered") + + if await crud_users.exists(db=db, username=user_data.username): + raise DuplicateValueException("Username not available") + + # Create user + try: + user_internal = UserCreateInternal(**user_data.model_dump()) + created_user = await crud_users.create(db=db, object=user_internal) + return created_user + except Exception as e: + # Handle database errors + await db.rollback() + raise e +``` + +## Performance Tips + +### 1. Use Schema Selection + +Always specify `schema_to_select` to avoid loading unnecessary data: + +```python +# Good - only loads needed fields +user = await crud_users.get(db=db, id=user_id, schema_to_select=UserRead) + +# Avoid - loads all fields +user = await crud_users.get(db=db, id=user_id) +``` + +### 2. Batch Operations + +For multiple operations, use transactions: + +```python +async def batch_user_updates(db: AsyncSession, updates: List[dict]): + try: + for update in updates: + await crud_users.update(db=db, object=update["data"], id=update["id"]) + await db.commit() + except Exception: + await db.rollback() + raise +``` + +### 3. Use Exists for Checks + +Use `exists()` instead of `get()` when you only need to check existence: + +```python +# Good - faster, doesn't load data +if await crud_users.exists(db=db, email=email): + raise DuplicateValueException("Email taken") + +# Avoid - slower, loads unnecessary data +user = await crud_users.get(db=db, email=email) +if user: + raise DuplicateValueException("Email taken") +``` + +## Next Steps + +- **[Database Migrations](migrations.md)** - Managing database schema changes +- **[API Development](../api/index.md)** - Using CRUD in API endpoints +- **[Caching](../caching/index.md)** - Optimizing CRUD with caching \ No newline at end of file diff --git a/docs/user-guide/database/index.md b/docs/user-guide/database/index.md new file mode 100644 index 0000000..aa941ba --- /dev/null +++ b/docs/user-guide/database/index.md @@ -0,0 +1,235 @@ +# Database Layer + +Learn how to work with the database layer in the FastAPI Boilerplate. This section covers everything you need to store and retrieve data effectively. + +## What You'll Learn + +- **[Models](models.md)** - Define database tables with SQLAlchemy models +- **[Schemas](schemas.md)** - Validate and serialize data with Pydantic schemas +- **[CRUD Operations](crud.md)** - Perform database operations with FastCRUD +- **[Migrations](migrations.md)** - Manage database schema changes with Alembic + +## Quick Overview + +The boilerplate uses a layered architecture that separates concerns: + +```python +# API Endpoint +@router.post("/", response_model=UserRead) +async def create_user(user_data: UserCreate, db: AsyncSession): + return await crud_users.create(db=db, object=user_data) + +# The layers work together: +# 1. UserCreate schema validates the input +# 2. crud_users handles the database operation +# 3. User model defines the database table +# 4. UserRead schema formats the response +``` + +## Architecture + +The database layer follows a clear separation: + +``` +API Request + ↓ +Pydantic Schema (validation & serialization) + ↓ +CRUD Layer (business logic & database operations) + ↓ +SQLAlchemy Model (database table definition) + ↓ +PostgreSQL Database +``` + +## Key Features + +### 🗄️ **SQLAlchemy 2.0 Models** +Modern async SQLAlchemy with type hints: +```python +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(50), unique=True) + email: Mapped[str] = mapped_column(String(100), unique=True) + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) +``` + +### ✅ **Pydantic Schemas** +Automatic validation and serialization: +```python +class UserCreate(BaseModel): + username: str = Field(min_length=2, max_length=50) + email: EmailStr + password: str = Field(min_length=8) + +class UserRead(BaseModel): + id: int + username: str + email: str + created_at: datetime + # Note: no password field in read schema +``` + +### 🔧 **FastCRUD Operations** +Consistent database operations: +```python +# Create +user = await crud_users.create(db=db, object=user_create) + +# Read +user = await crud_users.get(db=db, id=user_id) +users = await crud_users.get_multi(db=db, offset=0, limit=10) + +# Update +user = await crud_users.update(db=db, object=user_update, id=user_id) + +# Delete (soft delete) +await crud_users.delete(db=db, id=user_id) +``` + +### 🔄 **Database Migrations** +Track schema changes with Alembic: +```bash +# Generate migration +alembic revision --autogenerate -m "Add user table" + +# Apply migrations +alembic upgrade head + +# Rollback if needed +alembic downgrade -1 +``` + +## Database Setup + +The boilerplate is configured for PostgreSQL with async support: + +### Environment Configuration +```bash +# .env file +POSTGRES_USER=your_user +POSTGRES_PASSWORD=your_password +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=your_database +``` + +### Connection Management +```python +# Database session dependency +async def async_get_db() -> AsyncIterator[AsyncSession]: + async with async_session_maker() as session: + yield session + +# Use in endpoints +@router.get("/users/") +async def get_users(db: Annotated[AsyncSession, Depends(async_get_db)]): + return await crud_users.get_multi(db=db) +``` + +## Included Models + +The boilerplate includes four example models: + +### **User Model** - Authentication & user management +- Username, email, password (hashed) +- Soft delete support +- Tier-based access control + +### **Post Model** - Content with user relationships +- Title, content, creation metadata +- Foreign key to user (no SQLAlchemy relationships) +- Soft delete built-in + +### **Tier Model** - User subscription levels +- Name-based tiers (free, premium, etc.) +- Links to rate limiting system + +### **Rate Limit Model** - API access control +- Path-specific rate limits per tier +- Configurable limits and time periods + +## Directory Structure + +```text +src/app/ +├── models/ # SQLAlchemy models (database tables) +│ ├── __init__.py +│ ├── user.py # User table definition +│ ├── post.py # Post table definition +│ └── ... +├── schemas/ # Pydantic schemas (validation) +│ ├── __init__.py +│ ├── user.py # User validation schemas +│ ├── post.py # Post validation schemas +│ └── ... +├── crud/ # Database operations +│ ├── __init__.py +│ ├── crud_users.py # User CRUD operations +│ ├── crud_posts.py # Post CRUD operations +│ └── ... +└── core/db/ # Database configuration + ├── database.py # Connection and session setup + └── models.py # Base classes and mixins +``` + +## Common Patterns + +### Create with Validation +```python +@router.post("/users/", response_model=UserRead) +async def create_user( + user_data: UserCreate, # Validates input automatically + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Check for duplicates + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already exists") + + # Create user (password gets hashed automatically) + return await crud_users.create(db=db, object=user_data) +``` + +### Query with Filters +```python +# Get active users only +users = await crud_users.get_multi( + db=db, + is_active=True, + is_deleted=False, + offset=0, + limit=10 +) + +# Search users +users = await crud_users.get_multi( + db=db, + username__icontains="john", # Contains "john" + schema_to_select=UserRead +) +``` + +### Soft Delete Pattern +```python +# Soft delete (sets is_deleted=True) +await crud_users.delete(db=db, id=user_id) + +# Hard delete (actually removes from database) +await crud_users.db_delete(db=db, id=user_id) + +# Get only non-deleted records +users = await crud_users.get_multi(db=db, is_deleted=False) +``` + +## What's Next + +Each guide builds on the previous one with practical examples: + +1. **[Models](models.md)** - Define your database structure +2. **[Schemas](schemas.md)** - Add validation and serialization +3. **[CRUD Operations](crud.md)** - Implement business logic +4. **[Migrations](migrations.md)** - Deploy changes safely + +The boilerplate provides a solid foundation - just follow these patterns to build your data layer! \ No newline at end of file diff --git a/docs/user-guide/database/migrations.md b/docs/user-guide/database/migrations.md new file mode 100644 index 0000000..ed66b35 --- /dev/null +++ b/docs/user-guide/database/migrations.md @@ -0,0 +1,470 @@ +# Database Migrations + +This guide covers database migrations using Alembic, the migration tool for SQLAlchemy. Learn how to manage database schema changes safely and efficiently in development and production. + +## Overview + +The FastAPI Boilerplate uses [Alembic](https://alembic.sqlalchemy.org/) for database migrations. Alembic provides: + +- **Version-controlled schema changes** - Track every database modification +- **Automatic migration generation** - Generate migrations from model changes +- **Reversible migrations** - Upgrade and downgrade database versions +- **Environment-specific configurations** - Different settings for dev/staging/production +- **Safe schema evolution** - Apply changes incrementally + +## Simple Setup: Automatic Table Creation + +For simple projects or development, the boilerplate includes `create_tables_on_start` parameter that automatically creates all tables on application startup: + +```python +# This is enabled by default in create_application() +app = create_application( + router=router, + settings=settings, + create_tables_on_start=True # Default: True +) +``` + +**When to use:** + +- ✅ **Development** - Quick setup without migration management +- ✅ **Simple projects** - When you don't need migration history +- ✅ **Prototyping** - Fast iteration without migration complexity +- ✅ **Testing** - Clean database state for each test run + +**When NOT to use:** + +- ❌ **Production** - No migration history or rollback capability +- ❌ **Team development** - Can't track schema changes between developers +- ❌ **Data migrations** - Only handles schema, not data transformations +- ❌ **Complex deployments** - No control over when/how schema changes apply + +```python +# Disable for production environments +app = create_application( + router=router, + settings=settings, + create_tables_on_start=False # Use migrations instead +) +``` + +For production deployments and team development, use proper Alembic migrations as described below. + +## Configuration + +### Alembic Setup + +Alembic is configured in `src/alembic.ini`: + +```ini +[alembic] +# Path to migration files +script_location = migrations + +# Database URL with environment variable substitution +sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_SERVER)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s + +# Other configurations +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s +timezone = UTC +``` + +### Environment Configuration + +Migration environment is configured in `src/migrations/env.py`: + +```python +# src/migrations/env.py +from alembic import context +from sqlalchemy import engine_from_config, pool +from app.core.db.database import Base +from app.core.config import settings + +# Import all models to ensure they're registered +from app.models import * # This imports all models + +config = context.config + +# Override database URL from environment +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +target_metadata = Base.metadata +``` + +## Migration Workflow + +### 1. Creating Migrations + +Generate migrations automatically when you change models: + +```bash +# Navigate to src directory +cd src + +# Generate migration from model changes +uv run alembic revision --autogenerate -m "Add user profile fields" +``` + +**What happens:** +- Alembic compares current models with database schema +- Generates a new migration file in `src/migrations/versions/` +- Migration includes upgrade and downgrade functions + +### 2. Review Generated Migration + +Always review auto-generated migrations before applying: + +```python +# Example migration file: src/migrations/versions/20241215_1430_add_user_profile_fields.py +"""Add user profile fields + +Revision ID: abc123def456 +Revises: previous_revision_id +Create Date: 2024-12-15 14:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = 'abc123def456' +down_revision = 'previous_revision_id' +branch_labels = None +depends_on = None + +def upgrade() -> None: + # Add new columns + op.add_column('user', sa.Column('bio', sa.String(500), nullable=True)) + op.add_column('user', sa.Column('website', sa.String(255), nullable=True)) + + # Create index + op.create_index('ix_user_website', 'user', ['website']) + +def downgrade() -> None: + # Remove changes (reverse order) + op.drop_index('ix_user_website', 'user') + op.drop_column('user', 'website') + op.drop_column('user', 'bio') +``` + +### 3. Apply Migration + +Apply migrations to update database schema: + +```bash +# Apply all pending migrations +uv run alembic upgrade head + +# Apply specific number of migrations +uv run alembic upgrade +2 + +# Apply to specific revision +uv run alembic upgrade abc123def456 +``` + +### 4. Verify Migration + +Check migration status and current version: + +```bash +# Show current database version +uv run alembic current + +# Show migration history +uv run alembic history + +# Show pending migrations +uv run alembic show head +``` + +## Common Migration Scenarios + +### Adding New Model + +1. **Create the model** in `src/app/models/`: + +```python +# src/app/models/category.py +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.core.db.database import Base + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[str] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) +``` + +2. **Import in __init__.py**: + +```python +# 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 new import +``` + +3. **Generate migration**: + +```bash +uv run alembic revision --autogenerate -m "Add category model" +``` + +### Adding Foreign Key + +1. **Update model with foreign key**: + +```python +# Add to Post model +category_id: Mapped[Optional[int]] = mapped_column(ForeignKey("category.id"), nullable=True) +``` + +2. **Generate migration**: + +```bash +uv run alembic revision --autogenerate -m "Add category_id to posts" +``` + +3. **Review and apply**: + +```python +# Generated migration will include: +def upgrade() -> None: + op.add_column('post', sa.Column('category_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_post_category_id', 'post', 'category', ['category_id'], ['id']) + op.create_index('ix_post_category_id', 'post', ['category_id']) +``` + +### Data Migrations + +Sometimes you need to migrate data, not just schema: + +```python +# Example: Populate default category for existing posts +def upgrade() -> None: + # Add the column + op.add_column('post', sa.Column('category_id', sa.Integer(), nullable=True)) + + # Data migration + connection = op.get_bind() + + # Create default category + connection.execute( + "INSERT INTO category (name, slug, description) VALUES ('General', 'general', 'Default category')" + ) + + # Get default category ID + result = connection.execute("SELECT id FROM category WHERE slug = 'general'") + default_category_id = result.fetchone()[0] + + # Update existing posts + connection.execute( + f"UPDATE post SET category_id = {default_category_id} WHERE category_id IS NULL" + ) + + # Make column non-nullable after data migration + op.alter_column('post', 'category_id', nullable=False) +``` + +### Renaming Columns + +```python +def upgrade() -> None: + # Rename column + op.alter_column('user', 'full_name', new_column_name='name') + +def downgrade() -> None: + # Reverse the rename + op.alter_column('user', 'name', new_column_name='full_name') +``` + +### Dropping Tables + +```python +def upgrade() -> None: + # Drop table (be careful!) + op.drop_table('old_table') + +def downgrade() -> None: + # Recreate table structure + op.create_table('old_table', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(50), nullable=True), + sa.PrimaryKeyConstraint('id') + ) +``` + +## Production Migration Strategy + +### 1. Development Workflow + +```bash +# 1. Make model changes +# 2. Generate migration +uv run alembic revision --autogenerate -m "Descriptive message" + +# 3. Review migration file +# 4. Test migration +uv run alembic upgrade head + +# 5. Test downgrade (optional) +uv run alembic downgrade -1 +uv run alembic upgrade head +``` + +### 2. Staging Deployment + +```bash +# 1. Deploy code with migrations +# 2. Backup database +pg_dump -h staging-db -U user dbname > backup_$(date +%Y%m%d_%H%M%S).sql + +# 3. Apply migrations +uv run alembic upgrade head + +# 4. Verify application works +# 5. Run tests +``` + +### 3. Production Deployment + +```bash +# 1. Schedule maintenance window +# 2. Create database backup +pg_dump -h prod-db -U user dbname > prod_backup_$(date +%Y%m%d_%H%M%S).sql + +# 3. Apply migrations (with monitoring) +uv run alembic upgrade head + +# 4. Verify health checks pass +# 5. Monitor application metrics +``` + +## Docker Considerations + +### Development with Docker Compose + +For local development, migrations run automatically: + +```yaml +# docker-compose.yml +services: + web: + # ... other config + depends_on: + - db + command: | + sh -c " + uv run alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + " +``` + +### Production Docker + +In production, run migrations separately: + +```dockerfile +# Dockerfile migration stage +FROM python:3.11-slim as migration +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY src/ /app/ +WORKDIR /app +CMD ["alembic", "upgrade", "head"] +``` + +```yaml +# docker-compose.prod.yml +services: + migrate: + build: + context: . + target: migration + env_file: + - .env + depends_on: + - db + command: alembic upgrade head + + web: + # ... web service config + depends_on: + - migrate +``` + +## Migration Best Practices + +### 1. Always Review Generated Migrations + +```python +# Check for issues like: +# - Missing imports +# - Incorrect nullable settings +# - Missing indexes +# - Data loss operations +``` + +### 2. Use Descriptive Messages + +```bash +# Good +uv run alembic revision --autogenerate -m "Add user email verification fields" + +# Bad +uv run alembic revision --autogenerate -m "Update user model" +``` + +### 3. Handle Nullable Columns Carefully + +```python +# When adding non-nullable columns to existing tables: +def upgrade() -> None: + # 1. Add as nullable first + op.add_column('user', sa.Column('phone', sa.String(20), nullable=True)) + + # 2. Populate with default data + op.execute("UPDATE user SET phone = '' WHERE phone IS NULL") + + # 3. Make non-nullable + op.alter_column('user', 'phone', nullable=False) +``` + +### 4. Test Rollbacks + +```bash +# Test that your downgrade works +uv run alembic downgrade -1 +uv run alembic upgrade head +``` + +### 5. Use Transactions for Complex Migrations + +```python +def upgrade() -> None: + # Complex migration with transaction + connection = op.get_bind() + trans = connection.begin() + try: + # Multiple operations + op.create_table(...) + op.add_column(...) + connection.execute("UPDATE ...") + trans.commit() + except: + trans.rollback() + raise +``` + +## Next Steps + +- **[CRUD Operations](crud.md)** - Working with migrated database schema +- **[API Development](../api/index.md)** - Building endpoints for your models +- **[Testing](../testing.md)** - Testing database migrations \ No newline at end of file diff --git a/docs/user-guide/database/models.md b/docs/user-guide/database/models.md new file mode 100644 index 0000000..beea8b2 --- /dev/null +++ b/docs/user-guide/database/models.md @@ -0,0 +1,484 @@ +# Database Models + +This section explains how SQLAlchemy models are implemented in the boilerplate, how to create new models, and the patterns used for relationships, validation, and data integrity. + +## Model Structure + +Models are defined in `src/app/models/` using SQLAlchemy 2.0's declarative syntax with `Mapped` type annotations. + +### Base Model + +All models inherit from `Base` defined in `src/app/core/db/database.py`: + +```python +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass +``` + +**SQLAlchemy 2.0 Change**: Uses `DeclarativeBase` instead of the older `declarative_base()` function. This provides better type checking and IDE support. + +### Model File Structure + +Each model is in its own file: + +```text +src/app/models/ +├── __init__.py # Imports all models for Alembic discovery +├── user.py # User authentication model +├── post.py # Example content model with relationships +├── tier.py # User subscription tiers +└── rate_limit.py # API rate limiting configuration +``` + +**Import Requirement**: Models must be imported in `__init__.py` for Alembic to detect them during migration generation. + +## Design Decision: No SQLAlchemy Relationships + +The boilerplate deliberately avoids using SQLAlchemy's `relationship()` feature. This is an intentional architectural choice with specific benefits. + +### Why No Relationships + +**Performance Concerns**: + +- **N+1 Query Problem**: Relationships can trigger multiple queries when accessing related data +- **Lazy Loading**: Unpredictable when queries execute, making performance optimization difficult +- **Memory Usage**: Loading large object graphs consumes significant memory + +**Code Clarity**: + +- **Explicit Data Fetching**: Developers see exactly what data is being loaded and when +- **Predictable Queries**: No "magic" queries triggered by attribute access +- **Easier Debugging**: SQL queries are explicit in the code, not hidden in relationship configuration + +**Flexibility**: + +- **Query Optimization**: Can optimize each query for its specific use case +- **Selective Loading**: Load only the fields needed for each operation +- **Join Control**: Use FastCRUD's join methods when needed, skip when not + +### What This Means in Practice + +Instead of this (traditional SQLAlchemy): +```python +# Not used in the boilerplate +class User(Base): + posts: Mapped[List["Post"]] = relationship("Post", back_populates="created_by_user") + +class Post(Base): + created_by_user: Mapped["User"] = relationship("User", back_populates="posts") +``` + +The boilerplate uses this approach: +```python +# DO - Explicit and controlled +class User(Base): + # Only foreign key, no relationship + tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), index=True, default=None) + +class Post(Base): + # Only foreign key, no relationship + created_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) + +# Explicit queries - you control exactly what's loaded +user = await crud_users.get(db=db, id=1) +posts = await crud_posts.get_multi(db=db, created_by_user_id=user.id) + +# Or use joins when needed +posts_with_users = await crud_posts.get_multi_joined( + db=db, + join_model=User, + schema_to_select=PostRead, + join_schema_to_select=UserRead +) +``` + +### Benefits of This Approach + +**Predictable Performance**: + +- Every database query is explicit in the code +- No surprise queries from accessing relationships +- Easier to identify and optimize slow operations + +**Better Caching**: + +- Can cache individual models without worrying about related data +- Cache invalidation is simpler and more predictable + +**API Design**: + +- Forces thinking about what data clients actually need +- Prevents over-fetching in API responses +- Encourages lean, focused endpoints + +**Testing**: + +- Easier to mock database operations +- No complex relationship setup in test fixtures +- More predictable test data requirements + +### When You Need Related Data + +Use FastCRUD's join capabilities: + +```python +# Single record with related data +post_with_author = await crud_posts.get_joined( + db=db, + join_model=User, + schema_to_select=PostRead, + join_schema_to_select=UserRead, + id=post_id +) + +# Multiple records with joins +posts_with_authors = await crud_posts.get_multi_joined( + db=db, + join_model=User, + offset=0, + limit=10 +) +``` + +### Alternative Approaches + +If you need relationships in your project, you can add them: + +```python +# Add relationships if needed for your use case +from sqlalchemy.orm import relationship + +class User(Base): + # ... existing fields ... + posts: Mapped[List["Post"]] = relationship("Post", back_populates="created_by_user") + +class Post(Base): + # ... existing fields ... + created_by_user: Mapped["User"] = relationship("User", back_populates="posts") +``` + +But consider the trade-offs and whether explicit queries might be better for your use case. + +## User Model Implementation + +The User model (`src/app/models/user.py`) demonstrates authentication patterns: + +```python +import uuid as uuid_pkg +from datetime import UTC, datetime +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column +from ..core.db.database import Base + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) + + # User data + name: Mapped[str] = mapped_column(String(30)) + username: Mapped[str] = mapped_column(String(20), unique=True, index=True) + email: Mapped[str] = mapped_column(String(50), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String) + + # Profile + profile_image_url: Mapped[str] = mapped_column(String, default="https://profileimageurl.com") + + # UUID for external references + uuid: Mapped[uuid_pkg.UUID] = mapped_column(default_factory=uuid_pkg.uuid4, primary_key=True, unique=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + + # Status flags + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) + is_superuser: Mapped[bool] = mapped_column(default=False) + + # Foreign key to tier system (no relationship defined) + tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), index=True, default=None, init=False) +``` + +### Key Implementation Details + +**Type Annotations**: `Mapped[type]` provides type hints for SQLAlchemy 2.0. IDE and mypy can validate types. + +**String Lengths**: Explicit lengths (`String(50)`) prevent database errors and define constraints clearly. + +**Nullable Fields**: Explicitly set `nullable=False` for required fields, `nullable=True` for optional ones. + +**Default Values**: Use `default=` for database-level defaults, Python functions for computed defaults. + +## Post Model with Relationships + +The Post model (`src/app/models/post.py`) shows relationships and soft deletion: + +```python +import uuid as uuid_pkg +from datetime import UTC, datetime +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column +from ..core.db.database import Base + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) + + # Content + title: Mapped[str] = mapped_column(String(30)) + text: Mapped[str] = mapped_column(String(63206)) # Large text field + media_url: Mapped[str | None] = mapped_column(String, default=None) + + # UUID for external references + uuid: Mapped[uuid_pkg.UUID] = mapped_column(default_factory=uuid_pkg.uuid4, primary_key=True, unique=True) + + # Foreign key (no relationship defined) + created_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) + + # Timestamps (built-in soft delete pattern) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) +``` + +### Soft Deletion Pattern + +Soft deletion is built directly into models: + +```python +# Built into each model that needs soft deletes +class Post(Base): + # ... other fields ... + + # Soft delete fields + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) +``` + +**Usage**: When `crud_posts.delete()` is called, it sets `is_deleted=True` and `deleted_at=datetime.now(UTC)` instead of removing the database row. + +## Tier and Rate Limiting Models + +### Tier Model + +```python +# src/app/models/tier.py +class Tier(Base): + __tablename__ = "tier" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) +``` + +### Rate Limit Model + +```python +# src/app/models/rate_limit.py +class RateLimit(Base): + __tablename__ = "rate_limit" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + tier_id: Mapped[int] = mapped_column(ForeignKey("tier.id"), nullable=False) + path: Mapped[str] = mapped_column(String(255), nullable=False) + limit: Mapped[int] = mapped_column(nullable=False) # requests allowed + period: Mapped[int] = mapped_column(nullable=False) # time period in seconds + name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) +``` + +**Purpose**: Links API endpoints (`path`) to rate limits (`limit` requests per `period` seconds) for specific user tiers. + +## Creating New Models + +### Step-by-Step Process + +1. **Create model file** in `src/app/models/your_model.py` +2. **Define model class** inheriting from `Base` +3. **Add to imports** in `src/app/models/__init__.py` +4. **Generate migration** with `alembic revision --autogenerate` +5. **Apply migration** with `alembic upgrade head` + +### Example: Creating a Category Model + +```python +# src/app/models/category.py +from datetime import datetime +from typing import List +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.db.database import Base + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[str] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) +``` + +If you want to relate Category to Post, just add the id reference in the model: + +```python +class Post(Base): + __tablename__ = "post" + ... + + # Foreign key (no relationship defined) + category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), index=True) +``` + +### Import in __init__.py + +```python +# 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 new model +``` + +**Critical**: Without this import, Alembic won't detect the model for migrations. + +## Model Validation and Constraints + +### Database-Level Constraints + +```python +from sqlalchemy import CheckConstraint, Index + +class Product(Base): + __tablename__ = "product" + + price: Mapped[float] = mapped_column(nullable=False) + quantity: Mapped[int] = mapped_column(nullable=False) + + # Table-level constraints + __table_args__ = ( + CheckConstraint('price > 0', name='positive_price'), + CheckConstraint('quantity >= 0', name='non_negative_quantity'), + Index('idx_product_price', 'price'), + ) +``` + +### Unique Constraints + +```python +# Single column unique +email: Mapped[str] = mapped_column(String(100), unique=True) + +# Multi-column unique constraint +__table_args__ = ( + UniqueConstraint('user_id', 'category_id', name='unique_user_category'), +) +``` + +## Common Model Patterns + +### Timestamp Tracking + +```python +class TimestampedModel: + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) + +# Use as mixin +class Post(Base, TimestampedModel, SoftDeleteMixin): + # Model automatically gets created_at, updated_at, is_deleted, deleted_at + __tablename__ = "post" + id: Mapped[int] = mapped_column(primary_key=True) +``` + +### Enumeration Fields + +```python +from enum import Enum +from sqlalchemy import Enum as SQLEnum + +class UserStatus(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + +class User(Base): + status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.ACTIVE) +``` + +### JSON Fields + +```python +from sqlalchemy.dialects.postgresql import JSONB + +class UserProfile(Base): + preferences: Mapped[dict] = mapped_column(JSONB, nullable=True) + metadata: Mapped[dict] = mapped_column(JSONB, default=lambda: {}) +``` + +**PostgreSQL-specific**: Uses JSONB for efficient JSON storage and querying. + +## Model Testing + +### Basic Model Tests + +```python +# tests/test_models.py +import pytest +from sqlalchemy.exc import IntegrityError +from app.models.user import User + +def test_user_creation(): + user = User( + username="testuser", + email="test@example.com", + hashed_password="hashed123" + ) + assert user.username == "testuser" + assert user.is_active is True # Default value + +def test_user_unique_constraint(): + # Test that duplicate emails raise IntegrityError + with pytest.raises(IntegrityError): + # Create users with same email + pass +``` + +## Migration Considerations + +### Backwards Compatible Changes + +Safe changes that don't break existing code: + +- Adding nullable columns +- Adding new tables +- Adding indexes +- Increasing column lengths + +### Breaking Changes + +Changes requiring careful migration: + +- Making columns non-nullable +- Removing columns +- Changing column types +- Removing tables + +## Next Steps + +Now that you understand model implementation: + +1. **[Schemas](schemas.md)** - Learn Pydantic validation and serialization +2. **[CRUD Operations](crud.md)** - Implement database operations with FastCRUD +3. **[Migrations](migrations.md)** - Manage schema changes with Alembic + +The next section covers how Pydantic schemas provide validation and API contracts separate from database models. \ No newline at end of file diff --git a/docs/user-guide/database/schemas.md b/docs/user-guide/database/schemas.md new file mode 100644 index 0000000..69c7bb2 --- /dev/null +++ b/docs/user-guide/database/schemas.md @@ -0,0 +1,650 @@ +# Database Schemas + +This section explains how Pydantic schemas handle data validation, serialization, and API contracts in the boilerplate. Schemas are separate from SQLAlchemy models and define what data enters and exits your API. + +## Schema Purpose and Structure + +Schemas serve three main purposes: + +1. **Input Validation** - Validate incoming API request data +2. **Output Serialization** - Format database data for API responses +3. **API Contracts** - Define clear interfaces between frontend and backend + +### Schema File Organization + +Schemas are organized in `src/app/schemas/` with one file per model: + +```text +src/app/schemas/ +├── __init__.py # Imports for easy access +├── user.py # User-related schemas +├── post.py # Post-related schemas +├── tier.py # Tier schemas +├── rate_limit.py # Rate limit schemas +└── job.py # Background job schemas +``` + +## User Schema Implementation + +The User schemas (`src/app/schemas/user.py`) demonstrate common validation patterns: + +```python +from datetime import datetime +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from ..core.schemas import PersistentDeletion, TimestampSchema, UUIDSchema + + +# Base schema with common fields +class UserBase(BaseModel): + name: Annotated[ + str, + Field( + min_length=2, + max_length=30, + examples=["User Userson"] + ) + ] + username: Annotated[ + str, + Field( + min_length=2, + max_length=20, + pattern=r"^[a-z0-9]+$", + examples=["userson"] + ) + ] + email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] + + +# Full User data +class User(TimestampSchema, UserBase, UUIDSchema, PersistentDeletion): + profile_image_url: Annotated[ + str, + Field(default="https://www.profileimageurl.com") + ] + hashed_password: str + is_superuser: bool = False + tier_id: int | None = None + + +# Schema for reading user data (API output) +class UserRead(BaseModel): + id: int + + name: Annotated[ + str, + Field( + min_length=2, + max_length=30, + examples=["User Userson"] + ) + ] + username: Annotated[ + str, + Field( + min_length=2, + max_length=20, + pattern=r"^[a-z0-9]+$", + examples=["userson"] + ) + ] + email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] + profile_image_url: str + tier_id: int | None + + +# Schema for creating new users (API input) +class UserCreate(UserBase): # Inherits from UserBase + model_config = ConfigDict(extra="forbid") + + password: Annotated[ + str, + Field( + pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", + examples=["Str1ngst!"] + ) + ] + + +# Schema that FastCRUD will use to store just the hash +class UserCreateInternal(UserBase): + hashed_password: str + + +# Schema for updating users +class UserUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Annotated[ + str | None, + Field( + min_length=2, + max_length=30, + examples=["User Userberg"], + default=None + ) + ] + username: Annotated[ + str | None, + Field( + min_length=2, + max_length=20, + pattern=r"^[a-z0-9]+$", + examples=["userberg"], + default=None + ) + ] + email: Annotated[ + EmailStr | None, + Field( + examples=["user.userberg@example.com"], + default=None + ) + ] + profile_image_url: Annotated[ + str | None, + Field( + pattern=r"^(https?|ftp)://[^\s/$.?#].[^\s]*$", + examples=["https://www.profileimageurl.com"], + default=None + ), + ] + + +# Internal update schema +class UserUpdateInternal(UserUpdate): + updated_at: datetime + + +# Schema to update tier id +class UserTierUpdate(BaseModel): + tier_id: int + + +# Schema for user deletion (soft delete timestamps) +class UserDelete(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_deleted: bool + deleted_at: datetime + + +# User specific schema +class UserRestoreDeleted(BaseModel): + is_deleted: bool +``` + +### Key Implementation Details + +**Field Validation**: Uses `Annotated[type, Field(...)]` for validation rules. `Field` parameters include: + +- `min_length/max_length` - String length constraints +- `gt/ge/lt/le` - Numeric constraints +- `pattern` - Pattern matching (regex) +- `default` - Default values + +**EmailStr**: Validates email format and normalizes the value. + +**ConfigDict**: Replaces the old `Config` class. `from_attributes=True` allows creating schemas from SQLAlchemy model instances. + +**Internal vs External**: Separate schemas for internal operations (like password hashing) vs API exposure. + +## Schema Patterns + +### Base Schema Pattern + +```python +# Common fields shared across operations +class PostBase(BaseModel): + title: Annotated[ + str, + Field( + min_length=1, + max_length=100 + ) + ] + content: Annotated[ + str, + Field( + min_length=1, + max_length=10000 + ) + ] + +# Specific operation schemas inherit from base +class PostCreate(PostBase): + pass # Only title and content needed for creation + +class PostRead(PostBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + created_by_user_id: int + is_deleted: bool = False # From model's soft delete fields +``` + +**Purpose**: Reduces duplication and ensures consistency across related schemas. + +### Optional Fields in Updates + +```python +class PostUpdate(BaseModel): + title: Annotated[ + str | None, + Field( + min_length=1, + max_length=100, + default=None + ) + ] + content: Annotated[ + str | None, + Field( + min_length=1, + max_length=10000, + default=None + ) + ] +``` + +**Pattern**: All fields optional in update schemas. Only provided fields are updated in the database. + +### Nested Schemas + +```python +# Post schema with user information +class PostWithUser(PostRead): + created_by_user: UserRead # Nested user data + +# Alternative: Custom nested schema +class PostAuthor(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + # Only include fields needed for this context + +class PostRead(PostBase): + created_by_user: PostAuthor +``` + +**Usage**: Include related model data in responses without exposing all fields. + +## Validation Patterns + +### Custom Validators + +```python +from pydantic import field_validator, model_validator + +class UserCreateWithConfirm(UserBase): + password: str + confirm_password: str + + @field_validator('username') + @classmethod + def validate_username(cls, v): + if v.lower() in ['admin', 'root', 'system']: + raise ValueError('Username not allowed') + return v.lower() # Normalize to lowercase + + @model_validator(mode='after') + def validate_passwords_match(self): + if self.password != self.confirm_password: + raise ValueError('Passwords do not match') + return self +``` + +**field_validator**: Validates individual fields. Can transform values. + +**model_validator**: Validates across multiple fields. Access to full model data. + +### Computed Fields + +```python +from pydantic import computed_field + +class UserReadWithComputed(UserRead): + created_at: datetime # Would need to be added to actual UserRead + + @computed_field + @property + def age_days(self) -> int: + return (datetime.utcnow() - self.created_at).days + + @computed_field + @property + def display_name(self) -> str: + return f"@{self.username}" +``` + +**Purpose**: Add computed values to API responses without storing them in the database. + +### Conditional Validation + +```python +class PostCreate(BaseModel): + title: str + content: str + category: Optional[str] = None + is_premium: bool = False + + @model_validator(mode='after') + def validate_premium_content(self): + if self.is_premium and not self.category: + raise ValueError('Premium posts must have a category') + return self +``` + +## Schema Configuration + +### Model Config Options + +```python +class UserRead(BaseModel): + model_config = ConfigDict( + from_attributes=True, # Allow creation from SQLAlchemy models + extra="forbid", # Reject extra fields + str_strip_whitespace=True, # Strip whitespace from strings + validate_assignment=True, # Validate on field assignment + populate_by_name=True, # Allow field names and aliases + ) +``` + +### Field Aliases + +```python +class UserResponse(BaseModel): + user_id: Annotated[ + int, + Field(alias="id") + ] + username: str + email_address: Annotated[ + str, + Field(alias="email") + ] + + model_config = ConfigDict(populate_by_name=True) +``` + +**Usage**: API can accept both `id` and `user_id`, `email` and `email_address`. + +## Response Schema Patterns + +### Multi-Record Responses + +[FastCRUD's](https://benavlabs.github.io/fastcrud/) `get_multi` method returns a `GetMultiResponse`: + +```python +# Using get_multi directly +users = await crud_users.get_multi( + db=db, + offset=0, + limit=10, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True +) +# Returns GetMultiResponse structure: +# { +# "data": [UserRead, ...], +# "total_count": 150 +# } +``` + +### Paginated Responses + +For pagination with page numbers, use `PaginatedListResponse`: + +```python +from fastcrud.paginated import PaginatedListResponse + +# In API endpoint - ONLY for paginated list responses +@router.get("/users/", response_model=PaginatedListResponse[UserRead]) +async def get_users(page: int = 1, items_per_page: int = 10): + # Returns paginated structure with additional pagination fields: + # { + # "data": [UserRead, ...], + # "total_count": 150, + # "has_more": true, + # "page": 1, + # "items_per_page": 10 + # } + +# Single user endpoints return UserRead directly +@router.get("/users/{user_id}", response_model=UserRead) +async def get_user(user_id: int): + # Returns single UserRead object: + # { + # "id": 1, + # "name": "User Userson", + # "username": "userson", + # "email": "user.userson@example.com", + # "profile_image_url": "https://...", + # "tier_id": null + # } +``` + +### Error Response Schemas + +```python +class ErrorResponse(BaseModel): + detail: str + error_code: Optional[str] = None + +class ValidationErrorResponse(BaseModel): + detail: str + errors: list[dict] # Pydantic validation errors +``` + +### Success Response Wrapper + +```python +from typing import Generic, TypeVar + +T = TypeVar('T') + +class SuccessResponse(BaseModel, Generic[T]): + success: bool = True + data: T + message: Optional[str] = None + +# Usage in endpoint +@router.post("/users/", response_model=SuccessResponse[UserRead]) +async def create_user(user_data: UserCreate): + user = await crud_users.create(db=db, object=user_data) + return SuccessResponse(data=user, message="User created successfully") +``` + +## Creating New Schemas + +### Step-by-Step Process + +1. **Create schema file** in `src/app/schemas/your_model.py` +2. **Define base schema** with common fields +3. **Create operation-specific schemas** (Create, Read, Update, Delete) +4. **Add validation rules** as needed +5. **Import in __init__.py** for easy access + +### Example: Category Schemas + +```python +# src/app/schemas/category.py +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): + pass + +class CategoryRead(CategoryBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + +class CategoryUpdate(BaseModel): + name: Annotated[ + str | None, + Field( + min_length=1, + max_length=50, + default=None + ) + ] + description: Annotated[ + str | None, + Field( + max_length=255, + default=None + ) + ] + +class CategoryWithPosts(CategoryRead): + posts: list[PostRead] = [] # Include related posts +``` + +### Import in __init__.py + +```python +# src/app/schemas/__init__.py +from .user import UserCreate, UserRead, UserUpdate +from .post import PostCreate, PostRead, PostUpdate +from .category import CategoryCreate, CategoryRead, CategoryUpdate +``` + +## Schema Testing + +### Validation Testing + +```python +# tests/test_schemas.py +import pytest +from pydantic import ValidationError +from app.schemas.user import UserCreate + +def test_user_create_valid(): + user_data = { + "name": "Test User", + "username": "testuser", + "email": "test@example.com", + "password": "Str1ngst!" + } + user = UserCreate(**user_data) + assert user.username == "testuser" + assert user.name == "Test User" + +def test_user_create_invalid_email(): + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="Test User", + username="test", + email="invalid-email", + password="Str1ngst!" + ) + + errors = exc_info.value.errors() + assert any(error['type'] == 'value_error' for error in errors) + +def test_password_validation(): + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="Test User", + username="test", + email="test@example.com", + password="123" # Doesn't match pattern + ) +``` + +### Serialization Testing + +```python +from app.models.user import User +from app.schemas.user import UserRead + +def test_user_read_from_model(): + # Create model instance + user_model = User( + id=1, + name="Test User", + username="testuser", + email="test@example.com", + profile_image_url="https://example.com/image.jpg", + hashed_password="hashed123", + is_superuser=False, + tier_id=None, + created_at=datetime.utcnow() + ) + + # Convert to schema + user_schema = UserRead.model_validate(user_model) + assert user_schema.username == "testuser" + assert user_schema.id == 1 + assert user_schema.name == "Test User" + # hashed_password not included in UserRead +``` + +## Common Pitfalls + +### Model vs Schema Field Names + +```python +# DON'T - Exposing sensitive fields +class UserRead(BaseModel): + hashed_password: str # Never expose password hashes + +# DO - Only expose safe fields +class UserRead(BaseModel): + id: int + name: str + username: str + email: str + profile_image_url: str + tier_id: int | None +``` + +### Validation Performance + +```python +# DON'T - Complex validation in every request +@field_validator('email') +@classmethod +def validate_email_unique(cls, v): + # Database query in validator - slow! + if crud_users.exists(email=v): + raise ValueError('Email already exists') + +# DO - Handle uniqueness in business logic +# Let database unique constraints handle this +``` + +## Next Steps + +Now that you understand schema implementation: + +1. **[CRUD Operations](crud.md)** - Learn how schemas integrate with database operations +2. **[Migrations](migrations.md)** - Manage database schema changes +3. **[API Endpoints](../api/endpoints.md)** - Use schemas in FastAPI endpoints + +The next section covers CRUD operations and how they use these schemas for data validation and transformation. \ No newline at end of file diff --git a/docs/user-guide/development.md b/docs/user-guide/development.md new file mode 100644 index 0000000..b54f245 --- /dev/null +++ b/docs/user-guide/development.md @@ -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. \ No newline at end of file diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md new file mode 100644 index 0000000..2ac23fd --- /dev/null +++ b/docs/user-guide/index.md @@ -0,0 +1,78 @@ +# User Guide + +This user guide provides comprehensive information about using and understanding the FastAPI Boilerplate. Whether you're building your first API or looking to understand advanced features, this guide covers everything you need to know. + +## What You'll Learn + +This guide covers all aspects of working with the FastAPI Boilerplate: + +### Project Understanding +- **[Project Structure](project-structure.md)** - Navigate the codebase organization and understand architectural decisions +- **[Configuration](configuration/index.md)** - Configure your application for different environments + +### Core Components + +### Database Operations +- **[Database Overview](database/index.md)** - Understand the data layer architecture +- **[Models](database/models.md)** - Define and work with SQLAlchemy models +- **[Schemas](database/schemas.md)** - Create Pydantic schemas for data validation +- **[CRUD Operations](database/crud.md)** - Implement create, read, update, and delete operations +- **[Migrations](database/migrations.md)** - Manage database schema changes with Alembic + +### API Development +- **[API Overview](api/index.md)** - Build robust REST APIs with FastAPI +- **[Endpoints](api/endpoints.md)** - Create and organize API endpoints +- **[Pagination](api/pagination.md)** - Implement efficient data pagination +- **[Exception Handling](api/exceptions.md)** - Handle errors gracefully +- **[API Versioning](api/versioning.md)** - Manage API versions and backward compatibility + +### Security & Authentication +- **[Authentication Overview](authentication/index.md)** - Secure your API with JWT authentication +- **[JWT Tokens](authentication/jwt-tokens.md)** - Understand access and refresh token management +- **[User Management](authentication/user-management.md)** - Handle user registration, login, and profiles +- **[Permissions](authentication/permissions.md)** - Implement role-based access control + +### Performance & Caching +- **[Caching Overview](caching/index.md)** - Improve performance with Redis caching +- **[Redis Cache](caching/redis-cache.md)** - Server-side caching with Redis +- **[Client Cache](caching/client-cache.md)** - HTTP caching headers and browser caching +- **[Cache Strategies](caching/cache-strategies.md)** - Advanced caching patterns and invalidation + +### Background Processing +- **[Background Tasks](background-tasks/index.md)** - Handle long-running operations with ARQ + +### Rate Limiting +- **[Rate Limiting](rate-limiting/index.md)** - Protect your API from abuse with Redis-based rate limiting + +## Prerequisites + +Before diving into this guide, ensure you have: + +- Completed the [Getting Started](../getting-started/index.md) section +- A running FastAPI Boilerplate instance +- Basic understanding of Python, FastAPI, and REST APIs +- Familiarity with SQL databases (PostgreSQL knowledge is helpful) + +## Next Steps + +Ready to dive in? Here are recommended learning paths: + +### For New Users +1. Start with [Project Structure](project-structure.md) to understand the codebase +2. Learn [Database Models](database/models.md) and [Schemas](database/schemas.md) +3. Create your first [API Endpoints](api/endpoints.md) +4. Add [Authentication](authentication/index.md) to secure your API + +### For Experienced Developers +1. Review [Database CRUD Operations](database/crud.md) for advanced patterns +2. Implement [Caching Strategies](caching/index.md) for performance +3. Set up [Background Tasks](background-tasks/index.md) for async processing +4. Configure [Rate Limiting](rate-limiting/index.md) for production use + +### For Production Deployment +1. Understand [Cache Strategies](caching/cache-strategies.md) patterns +2. Configure [Rate Limiting](rate-limiting/index.md) with user tiers +3. Set up [Background Task Processing](background-tasks/index.md) +4. Review the [Production Guide](production.md) for deployment considerations + +Choose your path based on your needs and experience level. Each section builds upon previous concepts while remaining self-contained for reference use. \ No newline at end of file diff --git a/docs/user-guide/production.md b/docs/user-guide/production.md new file mode 100644 index 0000000..53fecbf --- /dev/null +++ b/docs/user-guide/production.md @@ -0,0 +1,709 @@ +# Production Deployment + +This guide covers deploying the FastAPI boilerplate to production with proper performance, security, and reliability configurations. + +## Production Architecture + +The recommended production setup uses: + +- **Gunicorn** - WSGI server managing Uvicorn workers +- **Uvicorn Workers** - ASGI server handling FastAPI requests +- **NGINX** - Reverse proxy and load balancer +- **PostgreSQL** - Production database +- **Redis** - Caching and background tasks +- **Docker** - Containerization + +## Environment Configuration + +### Production Environment Variables + +Update your `.env` file for production: + +```bash +# ------------- environment ------------- +ENVIRONMENT="production" + +# ------------- app settings ------------- +APP_NAME="Your Production App" +DEBUG=false + +# ------------- database ------------- +POSTGRES_USER="prod_user" +POSTGRES_PASSWORD="secure_production_password" +POSTGRES_SERVER="db" # or your database host +POSTGRES_PORT=5432 +POSTGRES_DB="prod_database" + +# ------------- redis ------------- +REDIS_CACHE_HOST="redis" +REDIS_CACHE_PORT=6379 +REDIS_QUEUE_HOST="redis" +REDIS_QUEUE_PORT=6379 +REDIS_RATE_LIMIT_HOST="redis" +REDIS_RATE_LIMIT_PORT=6379 + +# ------------- security ------------- +SECRET_KEY="your-super-secure-secret-key-generate-with-openssl" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# ------------- logging ------------- +LOG_LEVEL="INFO" +``` + +### Docker Configuration + +#### Production Dockerfile + +```dockerfile +FROM python:3.11-slim + +WORKDIR /code + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install UV +RUN pip install uv + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies +RUN uv sync --frozen --no-dev + +# Copy application code +COPY src/ ./src/ + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /code +USER app + +# Production command with Gunicorn +CMD ["uv", "run", "gunicorn", "src.app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"] +``` + +#### Production Docker Compose + +```yaml +version: '3.8' + +services: + web: + build: . + ports: + - "8000:8000" + env_file: + - ./src/.env + depends_on: + - db + - redis + restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + + worker: + build: . + command: uv run arq src.app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + restart: unless-stopped + deploy: + replicas: 2 + + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + restart: unless-stopped + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 1G + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + depends_on: + - web + restart: unless-stopped + +volumes: + postgres_data: + redis_data: +``` + +## Gunicorn Configuration + +### Basic Gunicorn Setup + +Create `gunicorn.conf.py`: + +```python +import multiprocessing + +# Server socket +bind = "0.0.0.0:8000" +backlog = 2048 + +# Worker processes +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "uvicorn.workers.UvicornWorker" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 + +# Restart workers after this many requests, with up to 50 jitter +preload_app = True + +# Logging +accesslog = "-" +errorlog = "-" +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# Process naming +proc_name = "fastapi-boilerplate" + +# Server mechanics +daemon = False +pidfile = "/tmp/gunicorn.pid" +user = None +group = None +tmp_upload_dir = None + +# SSL (if terminating SSL at application level) +# keyfile = "/path/to/keyfile" +# certfile = "/path/to/certfile" + +# Worker timeout +timeout = 30 +keepalive = 2 + +# Memory management +max_requests = 1000 +max_requests_jitter = 50 +preload_app = True +``` + +### Running with Gunicorn + +```bash +# Basic command +uv run gunicorn src.app.main:app -w 4 -k uvicorn.workers.UvicornWorker + +# With configuration file +uv run gunicorn src.app.main:app -c gunicorn.conf.py + +# With specific bind address +uv run gunicorn src.app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +## NGINX Configuration + +### Single Server Setup + +Create `nginx/nginx.conf`: + +```nginx +events { + worker_connections 1024; +} + +http { + upstream fastapi_backend { + server web:8000; + } + + server { + listen 80; + server_name your-domain.com; + + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL Configuration + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 10240; + gzip_proxied expired no-cache no-store private must-revalidate auth; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + + location / { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffer settings + proxy_buffering on; + proxy_buffer_size 8k; + proxy_buffers 8 8k; + } + + # Health check endpoint (no rate limiting) + location /health { + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + access_log off; + } + + # Static files (if any) + location /static/ { + alias /code/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} +``` + +### Simple Single Server (default.conf) + +For basic production setup, create `default.conf`: + +```nginx +# ---------------- Running With One Server ---------------- +server { + listen 80; + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Load Balancing Multiple Servers + +For horizontal scaling with multiple FastAPI instances: + +```nginx +# ---------------- To Run with Multiple Servers ---------------- +upstream fastapi_app { + server fastapi1:8000; # Replace with actual server names + server fastapi2:8000; + # Add more servers as needed +} + +server { + listen 80; + + location / { + proxy_pass http://fastapi_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Advanced Load Balancing + +For production with advanced features: + +```nginx +upstream fastapi_backend { + least_conn; + server web1:8000 weight=3; + server web2:8000 weight=2; + server web3:8000 weight=1; + + # Health checks + keepalive 32; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + location / { + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Connection settings for load balancing + proxy_http_version 1.1; + proxy_set_header Connection ""; + } +} +``` + +### SSL Certificate Setup + +#### Using Let's Encrypt (Certbot) + +```bash +# Install certbot +sudo apt-get update +sudo apt-get install certbot python3-certbot-nginx + +# Obtain certificate +sudo certbot --nginx -d your-domain.com + +# Auto-renewal (add to crontab) +0 2 * * 1 /usr/bin/certbot renew --quiet +``` + +#### Manual SSL Setup + +```bash +# Generate self-signed certificate (development only) +mkdir -p nginx/ssl +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout nginx/ssl/key.pem \ + -out nginx/ssl/cert.pem +``` + +## Production Best Practices + +### Database Optimization + +#### PostgreSQL Configuration + +```sql +-- Optimize PostgreSQL for production +ALTER SYSTEM SET shared_buffers = '256MB'; +ALTER SYSTEM SET effective_cache_size = '1GB'; +ALTER SYSTEM SET random_page_cost = 1.1; +ALTER SYSTEM SET effective_io_concurrency = 200; +SELECT pg_reload_conf(); +``` + +#### Connection Pooling + +```python +# src/app/core/db/database.py +from sqlalchemy.ext.asyncio import create_async_engine + +# Production database settings +engine = create_async_engine( + DATABASE_URL, + echo=False, # Disable in production + pool_size=20, + max_overflow=0, + pool_pre_ping=True, + pool_recycle=3600, +) +``` + +### Redis Configuration + +#### Redis Production Settings + +```bash +# redis.conf adjustments +maxmemory 512mb +maxmemory-policy allkeys-lru +save 900 1 +save 300 10 +save 60 10000 +``` + +### Application Optimization + +#### Logging Configuration + +```python +# src/app/core/config.py +import logging +from pythonjsonlogger import jsonlogger + +def setup_production_logging(): + logHandler = logging.StreamHandler() + formatter = jsonlogger.JsonFormatter( + "%(asctime)s %(name)s %(levelname)s %(message)s" + ) + logHandler.setFormatter(formatter) + + logger = logging.getLogger() + logger.addHandler(logHandler) + logger.setLevel(logging.INFO) + + # Reduce noise from third-party libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +``` + +#### Performance Monitoring + +```python +# src/app/middleware/monitoring.py +import time +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware + +class MonitoringMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + + response = await call_next(request) + + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + + # Log slow requests + if process_time > 1.0: + logger.warning(f"Slow request: {request.method} {request.url} - {process_time:.2f}s") + + return response +``` + +### Security Configuration + +#### Environment Security + +```python +# src/app/core/config.py +class ProductionSettings(Settings): + # Hide docs in production + ENVIRONMENT: str = "production" + + # Security settings + SECRET_KEY: str = Field(..., min_length=32) + ALLOWED_HOSTS: list[str] = ["your-domain.com", "api.your-domain.com"] + + # Database security + POSTGRES_PASSWORD: str = Field(..., min_length=16) + + class Config: + case_sensitive = True +``` + +#### Rate Limiting + +```python +# Adjust rate limits for production +DEFAULT_RATE_LIMIT_LIMIT = 100 # requests per period +DEFAULT_RATE_LIMIT_PERIOD = 3600 # 1 hour +``` + +### Health Checks + +#### Application Health Check + +```python +# src/app/api/v1/health.py +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from ...core.db.database import async_get_db +from ...core.utils.cache import redis_client + +router = APIRouter() + +@router.get("/health") +async def health_check(): + return {"status": "healthy", "timestamp": datetime.utcnow()} + +@router.get("/health/detailed") +async def detailed_health_check(db: AsyncSession = Depends(async_get_db)): + health_status = {"status": "healthy", "services": {}} + + # Check database + try: + await db.execute("SELECT 1") + health_status["services"]["database"] = "healthy" + except Exception: + health_status["services"]["database"] = "unhealthy" + health_status["status"] = "unhealthy" + + # Check Redis + try: + await redis_client.ping() + health_status["services"]["redis"] = "healthy" + except Exception: + health_status["services"]["redis"] = "unhealthy" + health_status["status"] = "unhealthy" + + if health_status["status"] == "unhealthy": + raise HTTPException(status_code=503, detail=health_status) + + return health_status +``` + +### Deployment Process + +#### CI/CD Pipeline (GitHub Actions) + +```yaml +# .github/workflows/deploy.yml +name: Deploy to Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build and push Docker image + env: + DOCKER_REGISTRY: your-registry.com + run: | + docker build -t $DOCKER_REGISTRY/fastapi-app:latest . + docker push $DOCKER_REGISTRY/fastapi-app:latest + + - name: Deploy to production + run: | + # Your deployment commands + ssh production-server "docker compose pull && docker compose up -d" +``` + +#### Zero-Downtime Deployment + +```bash +#!/bin/bash +# deploy.sh - Zero-downtime deployment script + +# Pull new images +docker compose pull + +# Start new containers +docker compose up -d --no-deps --scale web=2 web + +# Wait for health check +sleep 30 + +# Stop old containers +docker compose up -d --no-deps --scale web=1 web + +# Clean up +docker system prune -f +``` + +### Monitoring and Alerting + +#### Basic Monitoring Setup + +```python +# Basic metrics collection +import psutil +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/metrics") +async def get_metrics(): + return { + "cpu_percent": psutil.cpu_percent(), + "memory_percent": psutil.virtual_memory().percent, + "disk_usage": psutil.disk_usage('/').percent + } +``` + +### Backup Strategy + +#### Database Backup + +```bash +#!/bin/bash +# backup-db.sh +BACKUP_DIR="/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +pg_dump -h localhost -U $POSTGRES_USER $POSTGRES_DB | gzip > $BACKUP_DIR/backup_$DATE.sql.gz + +# Keep only last 7 days of backups +find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +7 -delete +``` + +## Troubleshooting + +### Common Production Issues + +**High Memory Usage**: Check for memory leaks, optimize database queries, adjust worker counts + +**Slow Response Times**: Enable query logging, check database indexes, optimize N+1 queries + +**Connection Timeouts**: Adjust proxy timeouts, check database connection pool settings + +**SSL Certificate Issues**: Verify certificate paths, check renewal process + +### Performance Tuning + +- Monitor database query performance +- Implement proper caching strategies +- Use connection pooling +- Optimize Docker image layers +- Configure proper resource limits + +This production guide provides a solid foundation for deploying the FastAPI boilerplate to production environments with proper performance, security, and reliability configurations. \ No newline at end of file diff --git a/docs/user-guide/project-structure.md b/docs/user-guide/project-structure.md new file mode 100644 index 0000000..be47b72 --- /dev/null +++ b/docs/user-guide/project-structure.md @@ -0,0 +1,296 @@ +# Project Structure + +Understanding the project structure is essential for navigating the FastAPI Boilerplate effectively. This guide explains the organization of the codebase, the purpose of each directory, and how components interact with each other. + +## Overview + +The FastAPI Boilerplate follows a clean, modular architecture that separates concerns and promotes maintainability. The structure is designed to scale from simple APIs to complex applications while maintaining code organization and clarity. + +## Root Directory Structure + +```text +FastAPI-boilerplate/ +├── Dockerfile # Container configuration +├── docker-compose.yml # Multi-service orchestration +├── pyproject.toml # Project configuration and dependencies +├── uv.lock # Dependency lock file +├── README.md # Project documentation +├── LICENSE.md # License information +├── tests/ # Test suite +├── docs/ # Documentation +└── src/ # Source code +``` + +### Configuration Files + +| File | Purpose | +|------|---------| +| `Dockerfile` | Defines the container image for the application | +| `docker-compose.yml` | Orchestrates multiple services (API, database, Redis, worker) | +| `pyproject.toml` | Modern Python project configuration with dependencies and metadata | +| `uv.lock` | Locks exact dependency versions for reproducible builds | + +## Source Code Structure + +The `src/` directory contains all application code: + +```text +src/ +├── app/ # Main application package +│ ├── main.py # Application entry point +│ ├── api/ # API layer +│ ├── core/ # Core utilities and configurations +│ ├── crud/ # Database operations +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ ├── middleware/ # Custom middleware +│ └── logs/ # Application logs +├── migrations/ # Database migrations +└── scripts/ # Utility scripts +``` + +## Core Application (`src/app/`) + +### Entry Point +- **`main.py`** - FastAPI application instance and configuration + +### API Layer (`api/`) +```text +api/ +├── dependencies.py # Shared dependencies +└── v1/ # API version 1 + ├── login.py # Authentication endpoints + ├── logout.py # Logout functionality + ├── users.py # User management + ├── posts.py # Post operations + ├── tasks.py # Background task endpoints + ├── tiers.py # User tier management + └── rate_limits.py # Rate limiting endpoints +``` + +**Purpose**: Contains all API endpoints organized by functionality and version. + +### Core System (`core/`) +```text +core/ +├── config.py # Application settings +├── logger.py # Logging configuration +├── schemas.py # Core Pydantic schemas +├── security.py # Security utilities +├── setup.py # Application factory +├── db/ # Database core +├── exceptions/ # Custom exceptions +├── utils/ # Utility functions +└── worker/ # Background worker +``` + +**Purpose**: Houses core functionality, configuration, and shared utilities. + +#### Database Core (`core/db/`) +```text +db/ +├── database.py # Database connection and session management +├── models.py # Base models and mixins +├── crud_token_blacklist.py # Token blacklist operations +└── token_blacklist.py # Token blacklist model +``` + +#### Exceptions (`core/exceptions/`) +```text +exceptions/ +├── cache_exceptions.py # Cache-related exceptions +└── http_exceptions.py # HTTP exceptions +``` + +#### Utilities (`core/utils/`) +```text +utils/ +├── cache.py # Caching utilities +├── queue.py # Task queue management +└── rate_limit.py # Rate limiting utilities +``` + +#### Worker (`core/worker/`) +```text +worker/ +├── settings.py # Worker configuration +└── functions.py # Background task definitions +``` + +### Data Layer + +#### Models (`models/`) +```text +models/ +├── user.py # User model +├── post.py # Post model +├── tier.py # User tier model +└── rate_limit.py # Rate limit model +``` + +**Purpose**: SQLAlchemy ORM models defining database schema. + +#### Schemas (`schemas/`) +```text +schemas/ +├── user.py # User validation schemas +├── post.py # Post validation schemas +├── tier.py # Tier validation schemas +├── rate_limit.py # Rate limit schemas +└── job.py # Background job schemas +``` + +**Purpose**: Pydantic schemas for request/response validation and serialization. + +#### CRUD Operations (`crud/`) +```text +crud/ +├── crud_base.py # Base CRUD class +├── crud_users.py # User operations +├── crud_posts.py # Post operations +├── crud_tier.py # Tier operations +├── crud_rate_limit.py # Rate limit operations +└── helper.py # CRUD helper functions +``` + +**Purpose**: Database operations using FastCRUD for consistent data access patterns. + +### Additional Components + +#### Middleware (`middleware/`) +```text +middleware/ +└── client_cache_middleware.py # Client-side caching middleware +``` + +#### Logs (`logs/`) +```text +logs/ +└── app.log # Application log file +``` + +## Database Migrations (`src/migrations/`) + +```text +migrations/ +├── README # Migration instructions +├── env.py # Alembic environment configuration +├── script.py.mako # Migration template +└── versions/ # Individual migration files +``` + +**Purpose**: Alembic database migrations for schema version control. + +## Utility Scripts (`src/scripts/`) + +```text +scripts/ +├── create_first_superuser.py # Create initial admin user +└── create_first_tier.py # Create initial user tier +``` + +**Purpose**: Initialization and maintenance scripts. + +## Testing Structure (`tests/`) + +```text +tests/ +├── conftest.py # Pytest configuration and fixtures +├── test_user_unit.py # User-related unit tests +└── helpers/ # Test utilities + ├── generators.py # Test data generators + └── mocks.py # Mock objects and functions +``` + +## Architectural Patterns + +### Layered Architecture + +The boilerplate implements a clean layered architecture: + +1. **API Layer** (`api/`) - Handles HTTP requests and responses +2. **Business Logic** (`crud/`) - Implements business rules and data operations +3. **Data Access** (`models/`) - Defines data structure and database interaction +4. **Core Services** (`core/`) - Provides shared functionality and configuration + +### Dependency Injection + +FastAPI's dependency injection system is used throughout: + +- **Database Sessions** - Injected into endpoints via `async_get_db` +- **Authentication** - User context provided by `get_current_user` +- **Rate Limiting** - Applied via `rate_limiter_dependency` +- **Caching** - Managed through decorators and middleware + +### Configuration Management + +All configuration is centralized in `core/config.py`: + +- **Environment Variables** - Loaded from `.env` file +- **Settings Classes** - Organized by functionality (database, security, etc.) +- **Type Safety** - Using Pydantic for validation + +### Error Handling + +Centralized exception handling: + +- **Custom Exceptions** - Defined in `core/exceptions/` +- **HTTP Status Codes** - Consistent error responses +- **Logging** - Automatic error logging and tracking + +## Design Principles + +### Single Responsibility + +Each module has a clear, single purpose: + +- Models define data structure +- Schemas handle validation +- CRUD manages data operations +- API endpoints handle requests + +### Separation of Concerns + +- Business logic separated from presentation +- Database operations isolated from API logic +- Configuration centralized and environment-aware + +### Modularity + +- Features can be added/removed independently +- Services can be disabled via configuration +- Clear interfaces between components + +### Scalability + +- Async/await throughout the application +- Connection pooling for database access +- Caching and background task support +- Horizontal scaling ready + +## Navigation Tips + +### Finding Code + +- **Models** → `src/app/models/` +- **API Endpoints** → `src/app/api/v1/` +- **Database Operations** → `src/app/crud/` +- **Configuration** → `src/app/core/config.py` +- **Business Logic** → Distributed across CRUD and API layers + +### Adding New Features + +1. **Model** → Define in `models/` +2. **Schema** → Create in `schemas/` +3. **CRUD** → Implement in `crud/` +4. **API** → Add endpoints in `api/v1/` +5. **Migration** → Generate with Alembic + +### Understanding Data Flow + +```text +Request → API Endpoint → Dependencies → CRUD → Model → Database +Response ← API Response ← Schema ← CRUD ← Query Result ← Database +``` + +This structure provides a solid foundation for building scalable, maintainable APIs while keeping the codebase organized and easy to navigate. \ No newline at end of file diff --git a/docs/user-guide/rate-limiting/index.md b/docs/user-guide/rate-limiting/index.md new file mode 100644 index 0000000..1652147 --- /dev/null +++ b/docs/user-guide/rate-limiting/index.md @@ -0,0 +1,428 @@ +# Rate Limiting + +The boilerplate includes a sophisticated rate limiting system built on Redis that protects your API from abuse while supporting user tiers with different access levels. This system provides flexible, scalable rate limiting for production applications. + +## Overview + +Rate limiting controls how many requests users can make within a specific time period. The boilerplate implements: + +- **Redis-Based Storage**: Fast, distributed rate limiting using Redis +- **User Tier System**: Different limits for different user types +- **Path-Specific Limits**: Granular control per API endpoint +- **Fallback Protection**: Default limits for unauthenticated users + +## Quick Example + +```python +from fastapi import Depends +from app.api.dependencies import rate_limiter_dependency + +@router.post("/api/v1/posts", dependencies=[Depends(rate_limiter_dependency)]) +async def create_post(post_data: PostCreate): + # This endpoint is automatically rate limited based on: + # - User's tier (basic, premium, enterprise) + # - Specific limits for the /posts endpoint + # - Default limits for unauthenticated users + return await crud_posts.create(db=db, object=post_data) +``` + +## Architecture + +### Rate Limiting Components + +**Rate Limiter Class**: Singleton Redis client for checking limits +**User Tiers**: Database-stored user subscription levels +**Rate Limit Rules**: Path-specific limits per tier +**Dependency Injection**: Automatic enforcement via FastAPI dependencies + +### How It Works + +1. **Request Arrives**: User makes API request to protected endpoint +2. **User Identification**: System identifies user and their tier +3. **Limit Lookup**: Finds applicable rate limit for user tier + endpoint +4. **Redis Check**: Increments counter in Redis sliding window +5. **Allow/Deny**: Request proceeds or returns 429 Too Many Requests + +## User Tier System + +### Default Tiers + +The system supports flexible user tiers with different access levels: + +```python +# Example tier configuration +tiers = { + "free": { + "requests_per_minute": 10, + "requests_per_hour": 100, + "special_endpoints": { + "/api/v1/ai/generate": {"limit": 2, "period": 3600}, # 2 per hour + "/api/v1/exports": {"limit": 1, "period": 86400}, # 1 per day + } + }, + "premium": { + "requests_per_minute": 60, + "requests_per_hour": 1000, + "special_endpoints": { + "/api/v1/ai/generate": {"limit": 50, "period": 3600}, + "/api/v1/exports": {"limit": 10, "period": 86400}, + } + }, + "enterprise": { + "requests_per_minute": 300, + "requests_per_hour": 10000, + "special_endpoints": { + "/api/v1/ai/generate": {"limit": 500, "period": 3600}, + "/api/v1/exports": {"limit": 100, "period": 86400}, + } + } +} +``` + +### Rate Limit Database Structure + +```python +# Rate limits are stored per tier and path +class RateLimit: + id: int + tier_id: int # Links to user tier + name: str # Descriptive name + path: str # API path (sanitized) + limit: int # Number of requests allowed + period: int # Time period in seconds +``` + +## Implementation Details + +### Automatic Rate Limiting + +The system automatically applies rate limiting through dependency injection: + +```python +@router.post("/protected-endpoint", dependencies=[Depends(rate_limiter_dependency)]) +async def protected_endpoint(): + """This endpoint is automatically rate limited.""" + pass + +# The dependency: +# 1. Identifies the user and their tier +# 2. Looks up rate limits for this path +# 3. Checks Redis counter +# 4. Allows or blocks the request +``` + +### Redis-Based Counting + +The rate limiter uses Redis for distributed, high-performance counting: + +```python +# Sliding window implementation +async def is_rate_limited(self, user_id: int, path: str, limit: int, period: int) -> bool: + current_timestamp = int(datetime.now(UTC).timestamp()) + window_start = current_timestamp - (current_timestamp % period) + + # Create unique key for this user/path/window + key = f"ratelimit:{user_id}:{sanitized_path}:{window_start}" + + # Increment counter + current_count = await redis_client.incr(key) + + # Set expiration on first increment + if current_count == 1: + await redis_client.expire(key, period) + + # Check if limit exceeded + return current_count > limit +``` + +### Path Sanitization + +API paths are sanitized for consistent Redis key generation: + +```python +def sanitize_path(path: str) -> str: + return path.strip("/").replace("/", "_") + +# Examples: +# "/api/v1/users" → "api_v1_users" +# "/posts/{id}" → "posts_{id}" +``` + +## Configuration + +### Environment Variables + +```bash +# Rate Limiting Settings +DEFAULT_RATE_LIMIT_LIMIT=100 # Default requests per period +DEFAULT_RATE_LIMIT_PERIOD=3600 # Default period (1 hour) + +# Redis Rate Limiter Settings +REDIS_RATE_LIMITER_HOST=localhost +REDIS_RATE_LIMITER_PORT=6379 +REDIS_RATE_LIMITER_DB=2 # Separate from cache/queue +``` + +### Creating User Tiers + +```python +# Create tiers via API (superuser only) +POST /api/v1/tiers +{ + "name": "premium", + "description": "Premium subscription with higher limits" +} + +# Assign tier to user +PUT /api/v1/users/{user_id}/tier +{ + "tier_id": 2 +} +``` + +### Setting Rate Limits + +```python +# Create rate limits per tier and endpoint +POST /api/v1/tier/premium/rate_limit +{ + "name": "premium_posts_limit", + "path": "/api/v1/posts", + "limit": 100, # 100 requests + "period": 3600 # per hour +} + +# Different limits for different endpoints +POST /api/v1/tier/free/rate_limit +{ + "name": "free_ai_limit", + "path": "/api/v1/ai/generate", + "limit": 5, # 5 requests + "period": 86400 # per day +} +``` + +## Usage Patterns + +### Basic Protection + +```python +# Protect all endpoints in a router +router = APIRouter(dependencies=[Depends(rate_limiter_dependency)]) + +@router.get("/users") +async def get_users(): + """Rate limited based on user tier.""" + pass + +@router.post("/posts") +async def create_post(): + """Rate limited based on user tier.""" + pass +``` + +### Selective Protection + +```python +# Protect only specific endpoints +@router.get("/public-data") +async def get_public_data(): + """No rate limiting - public endpoint.""" + pass + +@router.post("/premium-feature", dependencies=[Depends(rate_limiter_dependency)]) +async def premium_feature(): + """Rate limited - premium feature.""" + pass +``` + +### Custom Error Handling + +```python +from app.core.exceptions.http_exceptions import RateLimitException + +@app.exception_handler(RateLimitException) +async def rate_limit_handler(request: Request, exc: RateLimitException): + """Custom rate limit error response.""" + return JSONResponse( + status_code=429, + content={ + "error": "Rate limit exceeded", + "message": "Too many requests. Please try again later.", + "retry_after": 60 # Suggest retry time + }, + headers={"Retry-After": "60"} + ) +``` + +## Monitoring and Analytics + +### Rate Limit Metrics + +```python +@router.get("/admin/rate-limit-stats") +async def get_rate_limit_stats(): + """Monitor rate limiting effectiveness.""" + + # Get Redis statistics + redis_info = await rate_limiter.client.info() + + # Count current rate limit keys + pattern = "ratelimit:*" + keys = await rate_limiter.client.keys(pattern) + + # Analyze by endpoint + endpoint_stats = {} + for key in keys: + parts = key.split(":") + if len(parts) >= 3: + endpoint = parts[2] + endpoint_stats[endpoint] = endpoint_stats.get(endpoint, 0) + 1 + + return { + "total_active_limits": len(keys), + "redis_memory_usage": redis_info.get("used_memory_human"), + "endpoint_stats": endpoint_stats + } +``` + +### User Analytics + +```python +async def analyze_user_usage(user_id: int, days: int = 7): + """Analyze user's API usage patterns.""" + + # This would require additional logging/analytics + # implementation to track request patterns + + return { + "user_id": user_id, + "tier": "premium", + "requests_last_7_days": 2540, + "average_requests_per_day": 363, + "top_endpoints": [ + {"path": "/api/v1/posts", "count": 1200}, + {"path": "/api/v1/users", "count": 800}, + {"path": "/api/v1/ai/generate", "count": 540} + ], + "rate_limit_hits": 12, # Times user hit rate limits + "suggested_tier": "enterprise" # Based on usage patterns + } +``` + +## Best Practices + +### Rate Limit Design + +```python +# Design limits based on resource cost +expensive_endpoints = { + "/api/v1/ai/generate": {"limit": 10, "period": 3600}, # AI is expensive + "/api/v1/reports/export": {"limit": 3, "period": 86400}, # Export is heavy + "/api/v1/bulk/import": {"limit": 1, "period": 3600}, # Import is intensive +} + +# More generous limits for lightweight endpoints +lightweight_endpoints = { + "/api/v1/users/me": {"limit": 1000, "period": 3600}, # Profile access + "/api/v1/posts": {"limit": 300, "period": 3600}, # Content browsing + "/api/v1/search": {"limit": 500, "period": 3600}, # Search queries +} +``` + +### Production Considerations + +```python +# Use separate Redis database for rate limiting +REDIS_RATE_LIMITER_DB=2 # Isolate from cache and queues + +# Set appropriate Redis memory policies +# maxmemory-policy volatile-lru # Remove expired rate limit keys first + +# Monitor Redis memory usage +# Rate limit keys can accumulate quickly under high load + +# Consider rate limit key cleanup +async def cleanup_expired_rate_limits(): + """Clean up expired rate limit keys.""" + pattern = "ratelimit:*" + keys = await redis_client.keys(pattern) + + for key in keys: + ttl = await redis_client.ttl(key) + if ttl == -2: # Key expired but not cleaned up + await redis_client.delete(key) +``` + +### Security Considerations + +```python +# Rate limit by IP for unauthenticated users +if not user: + user_id = request.client.host if request.client else "unknown" + limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD + +# Prevent rate limit enumeration attacks +# Don't expose exact remaining requests in error messages + +# Use progressive delays for repeated violations +# Consider temporary bans for severe abuse + +# Log rate limit violations for security monitoring +if is_limited: + logger.warning( + f"Rate limit exceeded", + extra={ + "user_id": user_id, + "path": path, + "ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent") + } + ) +``` + +## Common Use Cases + +### API Monetization + +```python +# Different tiers for different pricing levels +tiers = { + "free": {"daily_requests": 1000, "cost": 0}, + "starter": {"daily_requests": 10000, "cost": 29}, + "professional": {"daily_requests": 100000, "cost": 99}, + "enterprise": {"daily_requests": 1000000, "cost": 499} +} +``` + +### Resource Protection + +```python +# Protect expensive operations +@router.post("/ai/generate-image", dependencies=[Depends(rate_limiter_dependency)]) +async def generate_image(): + """Expensive AI operation - heavily rate limited.""" + pass + +@router.get("/data/export", dependencies=[Depends(rate_limiter_dependency)]) +async def export_data(): + """Database-intensive operation - rate limited.""" + pass +``` + +### Abuse Prevention + +```python +# Strict limits on user-generated content +@router.post("/posts", dependencies=[Depends(rate_limiter_dependency)]) +async def create_post(): + """Prevent spam posting.""" + pass + +@router.post("/comments", dependencies=[Depends(rate_limiter_dependency)]) +async def create_comment(): + """Prevent comment spam.""" + pass +``` + +This comprehensive rate limiting system provides robust protection against API abuse while supporting flexible business models through user tiers and granular endpoint controls. \ No newline at end of file diff --git a/docs/user-guide/testing.md b/docs/user-guide/testing.md new file mode 100644 index 0000000..8c16480 --- /dev/null +++ b/docs/user-guide/testing.md @@ -0,0 +1,810 @@ +# Testing Guide + +This guide covers comprehensive testing strategies for the FastAPI boilerplate, including unit tests, integration tests, and API testing. + +## Test Setup + +### Testing Dependencies + +The boilerplate uses these testing libraries: + +- **pytest** - Testing framework +- **pytest-asyncio** - Async test support +- **httpx** - Async HTTP client for API tests +- **pytest-cov** - Coverage reporting +- **faker** - Test data generation + +### Test Configuration + +#### pytest.ini + +```ini +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --strict-config + --cov=src + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-fail-under=80 +markers = + unit: Unit tests + integration: Integration tests + api: API tests + slow: Slow tests +asyncio_mode = auto +``` + +#### Test Database Setup + +Create `tests/conftest.py`: + +```python +import asyncio +import pytest +import pytest_asyncio +from typing import AsyncGenerator +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from faker import Faker + +from src.app.core.config import settings +from src.app.core.db.database import Base, async_get_db +from src.app.main import app +from src.app.models.user import User +from src.app.models.post import Post +from src.app.core.security import get_password_hash + +# Test database configuration +TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db" + +# Create test engine and session +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +TestSessionLocal = sessionmaker( + test_engine, class_=AsyncSession, expire_on_commit=False +) + +fake = Faker() + + +@pytest_asyncio.fixture +async def async_session() -> AsyncGenerator[AsyncSession, None]: + """Create a fresh database session for each test.""" + 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: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """Create an async HTTP client for testing.""" + 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() + + +@pytest_asyncio.fixture +async def test_user(async_session: AsyncSession) -> User: + """Create a test user.""" + user = User( + name=fake.name(), + username=fake.user_name(), + email=fake.email(), + hashed_password=get_password_hash("testpassword123"), + is_superuser=False + ) + async_session.add(user) + await async_session.commit() + await async_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_superuser(async_session: AsyncSession) -> User: + """Create a test superuser.""" + user = User( + name="Super Admin", + username="superadmin", + email="admin@test.com", + hashed_password=get_password_hash("superpassword123"), + is_superuser=True + ) + async_session.add(user) + await async_session.commit() + await async_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_post(async_session: AsyncSession, test_user: User) -> Post: + """Create a test post.""" + post = Post( + title=fake.sentence(), + content=fake.text(), + created_by_user_id=test_user.id + ) + async_session.add(post) + await async_session.commit() + await async_session.refresh(post) + return post + + +@pytest_asyncio.fixture +async def auth_headers(async_client: AsyncClient, test_user: User) -> dict: + """Get authentication headers for a test user.""" + login_data = { + "username": test_user.username, + "password": "testpassword123" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + token = response.json()["access_token"] + + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture +async def superuser_headers(async_client: AsyncClient, test_superuser: User) -> dict: + """Get authentication headers for a test superuser.""" + login_data = { + "username": test_superuser.username, + "password": "superpassword123" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + token = response.json()["access_token"] + + return {"Authorization": f"Bearer {token}"} +``` + +## Unit Tests + +### Model Tests + +```python +# tests/test_models.py +import pytest +from datetime import datetime +from src.app.models.user import User +from src.app.models.post import Post + + +@pytest.mark.unit +class TestUserModel: + """Test User model functionality.""" + + async def test_user_creation(self, async_session): + """Test creating a user.""" + 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) + + assert user.id is not None + assert user.name == "Test User" + assert user.username == "testuser" + assert user.email == "test@example.com" + assert user.created_at is not None + assert user.is_superuser is False + assert user.is_deleted is False + + async def test_user_relationships(self, async_session, test_user): + """Test user relationships.""" + post = Post( + title="Test Post", + content="Test content", + created_by_user_id=test_user.id + ) + + async_session.add(post) + await async_session.commit() + + # Test relationship + await async_session.refresh(test_user) + assert len(test_user.posts) == 1 + assert test_user.posts[0].title == "Test Post" + + +@pytest.mark.unit +class TestPostModel: + """Test Post model functionality.""" + + async def test_post_creation(self, async_session, test_user): + """Test creating a post.""" + post = Post( + title="Test Post", + content="This is test content", + created_by_user_id=test_user.id + ) + + async_session.add(post) + await async_session.commit() + await async_session.refresh(post) + + assert post.id is not None + assert post.title == "Test Post" + assert post.content == "This is test content" + assert post.created_by_user_id == test_user.id + assert post.created_at is not None + assert post.is_deleted is False +``` + +### Schema Tests + +```python +# tests/test_schemas.py +import pytest +from pydantic import ValidationError +from src.app.schemas.user import UserCreate, UserRead, UserUpdate +from src.app.schemas.post import PostCreate, PostRead, PostUpdate + + +@pytest.mark.unit +class TestUserSchemas: + """Test User schema validation.""" + + def test_user_create_valid(self): + """Test valid user creation schema.""" + user_data = { + "name": "John Doe", + "username": "johndoe", + "email": "john@example.com", + "password": "SecurePass123!" + } + + user = UserCreate(**user_data) + assert user.name == "John Doe" + assert user.username == "johndoe" + assert user.email == "john@example.com" + assert user.password == "SecurePass123!" + + def test_user_create_invalid_email(self): + """Test invalid email validation.""" + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="John Doe", + username="johndoe", + email="invalid-email", + password="SecurePass123!" + ) + + errors = exc_info.value.errors() + assert any(error['type'] == 'value_error' for error in errors) + + def test_user_create_short_password(self): + """Test password length validation.""" + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="John Doe", + username="johndoe", + email="john@example.com", + password="123" + ) + + errors = exc_info.value.errors() + assert any(error['type'] == 'value_error' for error in errors) + + def test_user_update_partial(self): + """Test partial user update.""" + update_data = {"name": "Jane Doe"} + user_update = UserUpdate(**update_data) + + assert user_update.name == "Jane Doe" + assert user_update.username is None + assert user_update.email is None + + +@pytest.mark.unit +class TestPostSchemas: + """Test Post schema validation.""" + + def test_post_create_valid(self): + """Test valid post creation.""" + post_data = { + "title": "Test Post", + "content": "This is a test post content" + } + + post = PostCreate(**post_data) + assert post.title == "Test Post" + assert post.content == "This is a test post content" + + def test_post_create_empty_title(self): + """Test empty title validation.""" + with pytest.raises(ValidationError): + PostCreate( + title="", + content="This is a test post content" + ) + + def test_post_create_long_title(self): + """Test title length validation.""" + with pytest.raises(ValidationError): + PostCreate( + title="x" * 101, # Exceeds max length + content="This is a test post content" + ) +``` + +### CRUD Tests + +```python +# tests/test_crud.py +import pytest +from src.app.crud.crud_users import crud_users +from src.app.crud.crud_posts import crud_posts +from src.app.schemas.user import UserCreate, UserUpdate +from src.app.schemas.post import PostCreate, PostUpdate + + +@pytest.mark.unit +class TestUserCRUD: + """Test User CRUD operations.""" + + async def test_create_user(self, async_session): + """Test creating a user.""" + 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" + assert user["email"] == "crud@example.com" + assert "id" in user + + async def test_get_user(self, async_session, test_user): + """Test getting a user.""" + retrieved_user = await crud_users.get( + db=async_session, + id=test_user.id + ) + + assert retrieved_user is not None + assert retrieved_user["id"] == test_user.id + assert retrieved_user["name"] == test_user.name + assert retrieved_user["username"] == test_user.username + + async def test_get_user_by_email(self, async_session, test_user): + """Test getting a user by email.""" + retrieved_user = await crud_users.get( + db=async_session, + email=test_user.email + ) + + assert retrieved_user is not None + assert retrieved_user["email"] == test_user.email + + async def test_update_user(self, async_session, test_user): + """Test updating a user.""" + update_data = UserUpdate(name="Updated Name") + + updated_user = await crud_users.update( + db=async_session, + object=update_data, + id=test_user.id + ) + + assert updated_user["name"] == "Updated Name" + assert updated_user["id"] == test_user.id + + async def test_delete_user(self, async_session, test_user): + """Test soft deleting a user.""" + await crud_users.delete(db=async_session, id=test_user.id) + + # User should be soft deleted + deleted_user = await crud_users.get( + db=async_session, + id=test_user.id, + is_deleted=True + ) + + assert deleted_user is not None + assert deleted_user["is_deleted"] is True + + async def test_get_multi_users(self, async_session): + """Test getting multiple users.""" + # Create multiple users + for i in range(5): + user_data = UserCreate( + name=f"User {i}", + username=f"user{i}", + email=f"user{i}@example.com", + password="password123" + ) + await crud_users.create(db=async_session, object=user_data) + + # Get users with pagination + result = await crud_users.get_multi( + db=async_session, + offset=0, + limit=3 + ) + + assert len(result["data"]) == 3 + assert result["total_count"] == 5 + assert result["has_more"] is True + + +@pytest.mark.unit +class TestPostCRUD: + """Test Post CRUD operations.""" + + async def test_create_post(self, async_session, test_user): + """Test creating a post.""" + post_data = PostCreate( + title="Test Post", + content="This is test content" + ) + + post = await crud_posts.create( + db=async_session, + object=post_data, + created_by_user_id=test_user.id + ) + + assert post["title"] == "Test Post" + assert post["content"] == "This is test content" + assert post["created_by_user_id"] == test_user.id + + async def test_get_posts_by_user(self, async_session, test_user): + """Test getting posts by user.""" + # Create multiple posts + for i in range(3): + post_data = PostCreate( + title=f"Post {i}", + content=f"Content {i}" + ) + await crud_posts.create( + db=async_session, + object=post_data, + created_by_user_id=test_user.id + ) + + # Get posts by user + result = await crud_posts.get_multi( + db=async_session, + created_by_user_id=test_user.id + ) + + assert len(result["data"]) == 3 + assert result["total_count"] == 3 +``` + +## Integration Tests + +### API Endpoint Tests + +```python +# tests/test_api_users.py +import pytest +from httpx import AsyncClient + + +@pytest.mark.integration +class TestUserAPI: + """Test User API endpoints.""" + + async def test_create_user(self, async_client: AsyncClient): + """Test user creation endpoint.""" + 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 data["email"] == "new@example.com" + assert "hashed_password" not in data + assert "id" in data + + async def test_create_user_duplicate_email(self, async_client: AsyncClient, test_user): + """Test creating user with duplicate email.""" + user_data = { + "name": "Duplicate User", + "username": "duplicateuser", + "email": test_user.email, # Use existing email + "password": "SecurePass123!" + } + + response = await async_client.post("/api/v1/users", json=user_data) + assert response.status_code == 409 # Conflict + + async def test_get_users(self, async_client: AsyncClient): + """Test getting users list.""" + 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 + assert "has_more" in data + assert isinstance(data["data"], list) + + async def test_get_user_by_id(self, async_client: AsyncClient, test_user): + """Test getting specific user.""" + response = await async_client.get(f"/api/v1/users/{test_user.id}") + assert response.status_code == 200 + + data = response.json() + assert data["id"] == test_user.id + assert data["name"] == test_user.name + assert data["username"] == test_user.username + + async def test_get_user_not_found(self, async_client: AsyncClient): + """Test getting non-existent user.""" + response = await async_client.get("/api/v1/users/99999") + assert response.status_code == 404 + + async def test_update_user_authorized(self, async_client: AsyncClient, test_user, auth_headers): + """Test updating user with proper authorization.""" + update_data = {"name": "Updated Name"} + + response = await async_client.patch( + f"/api/v1/users/{test_user.id}", + json=update_data, + headers=auth_headers + ) + assert response.status_code == 200 + + data = response.json() + assert data["name"] == "Updated Name" + assert data["id"] == test_user.id + + async def test_update_user_unauthorized(self, async_client: AsyncClient, test_user): + """Test updating user without authorization.""" + update_data = {"name": "Updated Name"} + + response = await async_client.patch( + f"/api/v1/users/{test_user.id}", + json=update_data + ) + assert response.status_code == 401 + + async def test_delete_user_superuser(self, async_client: AsyncClient, test_user, superuser_headers): + """Test deleting user as superuser.""" + response = await async_client.delete( + f"/api/v1/users/{test_user.id}", + headers=superuser_headers + ) + assert response.status_code == 200 + + async def test_delete_user_forbidden(self, async_client: AsyncClient, test_user, auth_headers): + """Test deleting user without superuser privileges.""" + response = await async_client.delete( + f"/api/v1/users/{test_user.id}", + headers=auth_headers + ) + assert response.status_code == 403 + + +@pytest.mark.integration +class TestAuthAPI: + """Test Authentication API endpoints.""" + + async def test_login_success(self, async_client: AsyncClient, test_user): + """Test successful login.""" + login_data = { + "username": test_user.username, + "password": "testpassword123" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + assert response.status_code == 200 + + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + async def test_login_invalid_credentials(self, async_client: AsyncClient, test_user): + """Test login with invalid credentials.""" + login_data = { + "username": test_user.username, + "password": "wrongpassword" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + assert response.status_code == 401 + + async def test_get_current_user(self, async_client: AsyncClient, test_user, auth_headers): + """Test getting current user information.""" + response = await async_client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 + + data = response.json() + assert data["id"] == test_user.id + assert data["username"] == test_user.username + + async def test_refresh_token(self, async_client: AsyncClient, test_user): + """Test token refresh.""" + # First login to get refresh token + login_data = { + "username": test_user.username, + "password": "testpassword123" + } + + login_response = await async_client.post("/api/v1/auth/login", data=login_data) + refresh_token = login_response.json()["refresh_token"] + + # Use refresh token to get new access token + refresh_response = await async_client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {refresh_token}"} + ) + + assert refresh_response.status_code == 200 + data = refresh_response.json() + assert "access_token" in data +``` + +## Running Tests + +### Basic Test Commands + +```bash +# Run all tests +uv run pytest + +# Run specific test categories +uv run pytest -m unit +uv run pytest -m integration +uv run pytest -m api + +# Run tests with coverage +uv run pytest --cov=src --cov-report=html + +# Run tests in parallel +uv run pytest -n auto + +# Run specific test file +uv run pytest tests/test_api_users.py + +# Run with verbose output +uv run pytest -v + +# Run tests matching pattern +uv run pytest -k "test_user" + +# Run tests and stop on first failure +uv run pytest -x + +# Run slow tests +uv run pytest -m slow +``` + +### Test Environment Setup + +```bash +# Set up test database +createdb test_db + +# Run tests with specific environment +ENVIRONMENT=testing uv run pytest + +# Run tests with debug output +uv run pytest -s --log-cli-level=DEBUG +``` + +## Testing Best Practices + +### Test Organization + +- **Separate concerns**: Unit tests for business logic, integration tests for API endpoints +- **Use fixtures**: Create reusable test data and setup +- **Test isolation**: Each test should be independent +- **Clear naming**: Test names should describe what they're testing + +### Test Data + +- **Use factories**: Create test data programmatically +- **Avoid hardcoded values**: Use variables and constants +- **Clean up**: Ensure tests don't leave data behind +- **Realistic data**: Use faker or similar libraries for realistic test data + +### Assertions + +- **Specific assertions**: Test specific behaviors, not just "it works" +- **Multiple assertions**: Test all relevant aspects of the response +- **Error cases**: Test error conditions and edge cases +- **Performance**: Include performance tests for critical paths + +### Mocking + +```python +# Example of mocking external dependencies +from unittest.mock import patch, AsyncMock + +@pytest.mark.unit +async def test_external_api_call(): + """Test function that calls external API.""" + with patch('src.app.services.external_api.make_request') as mock_request: + mock_request.return_value = {"status": "success"} + + result = await some_function_that_calls_external_api() + + assert result["status"] == "success" + mock_request.assert_called_once() +``` + +### Continuous Integration + +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_pass + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + pip install uv + uv sync + + - name: Run tests + run: uv run pytest --cov=src --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +This testing guide provides comprehensive coverage of testing strategies for the FastAPI boilerplate, ensuring reliable and maintainable code. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..7f48de3 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,150 @@ +site_name: FastAPI Boilerplate +site_description: A production-ready FastAPI boilerplate with async support, JWT authentication, Redis caching, and more. +site_author: Benav Labs +site_url: https://github.com/benavlabs/fastapi-boilerplate + +theme: + name: material + font: + text: Ubuntu + logo: assets/FastAPI-boilerplate.png + favicon: assets/FastAPI-boilerplate.png + features: + - navigation.instant + - navigation.instant.prefetch + - navigation.tabs + - navigation.indexes + - search.suggest + - content.code.copy + - content.code.annotate + - navigation.top + - navigation.footer + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - mkdocstrings: + handlers: + python: + rendering: + show_source: true + +nav: + - Home: index.md + - Getting Started: + - Overview: getting-started/index.md + - Installation: getting-started/installation.md + - Configuration: getting-started/configuration.md + - First Run: getting-started/first-run.md + - User Guide: + - Overview: user-guide/index.md + - Project Structure: user-guide/project-structure.md + - Configuration: + - Overview: user-guide/configuration/index.md + - Environment Variables: user-guide/configuration/environment-variables.md + - Settings Classes: user-guide/configuration/settings-classes.md + - Docker Setup: user-guide/configuration/docker-setup.md + - Environment-Specific: user-guide/configuration/environment-specific.md + - Database: + - Overview: user-guide/database/index.md + - Models: user-guide/database/models.md + - Schemas: user-guide/database/schemas.md + - CRUD Operations: user-guide/database/crud.md + - Migrations: user-guide/database/migrations.md + - API: + - Overview: user-guide/api/index.md + - Endpoints: user-guide/api/endpoints.md + - Pagination: user-guide/api/pagination.md + - Exceptions: user-guide/api/exceptions.md + - Versioning: user-guide/api/versioning.md + - Authentication: + - Overview: user-guide/authentication/index.md + - JWT Tokens: user-guide/authentication/jwt-tokens.md + - User Management: user-guide/authentication/user-management.md + - Permissions: user-guide/authentication/permissions.md + - Caching: + - Overview: user-guide/caching/index.md + - Redis Cache: user-guide/caching/redis-cache.md + - Client Cache: user-guide/caching/client-cache.md + - Cache Strategies: user-guide/caching/cache-strategies.md + - Background Tasks: user-guide/background-tasks/index.md + - Rate Limiting: user-guide/rate-limiting/index.md + - Development: user-guide/development.md + - Production: user-guide/production.md + - Testing: user-guide/testing.md + # - Examples: + # - Overview: examples/index.md + # - Basic CRUD: examples/basic-crud.md + # - Authentication Flow: examples/authentication-flow.md + # - Background Job Workflow: examples/background-job-workflow.md + # - Caching Patterns: examples/caching-patterns.md + # - Production Setup: examples/production-setup.md + # - Reference: + # - Overview: reference/index.md + # - API Reference: reference/api-reference.md + # - Configuration Reference: reference/configuration-reference.md + # - Database Schema: reference/database-schema.md + # - Middleware Reference: reference/middleware-reference.md + # - Dependencies Reference: reference/dependencies-reference.md + # - Contributing: + # - Overview: contributing/index.md + # - Development Setup: contributing/development-setup.md + # - Coding Standards: contributing/coding-standards.md + # - Pull Request Process: contributing/pull-request-process.md + # - Testing Guidelines: contributing/testing-guidelines.md + # - Migration Guides: + # - Overview: migration-guides/index.md + # - Version Migrations: migration-guides/from-v1-to-v2.md + # - From Other Frameworks: migration-guides/from-other-frameworks.md + # - FAQ: faq.md + +markdown_extensions: + - admonition + - codehilite + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - attr_list + - md_in_html + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/benavlabs/fastapi-boilerplate + - icon: fontawesome/brands/python + link: https://pypi.org/project/fastapi/ + version: + provider: mike + +extra_css: + - stylesheets/extra.css + +repo_name: benavlabs/fastapi-boilerplate +repo_url: https://github.com/benavlabs/fastapi-boilerplate +edit_uri: edit/main/docs/ \ No newline at end of file