chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
# FastAPI Endpoints Generator
|
||||
|
||||
Create comprehensive FastAPI endpoints with proper structure, validation, and documentation.
|
||||
|
||||
## Purpose
|
||||
|
||||
This command helps you quickly create FastAPI endpoints with Pydantic models, dependency injection, and automatic API documentation.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/api-endpoints
|
||||
```
|
||||
|
||||
## What this command does
|
||||
|
||||
1. **Creates API endpoints** with proper HTTP methods
|
||||
2. **Adds Pydantic models** for request/response validation
|
||||
3. **Implements dependency injection** for database and auth
|
||||
4. **Includes error handling** and status codes
|
||||
5. **Generates automatic documentation** with OpenAPI
|
||||
|
||||
## Example Output
|
||||
|
||||
```python
|
||||
# main.py
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
import uvicorn
|
||||
|
||||
from app.database import get_db, engine
|
||||
from app.models import models
|
||||
from app.routers import auth, users, posts, comments
|
||||
from app.core.config import settings
|
||||
|
||||
# Create database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Blog API",
|
||||
description="A comprehensive blog API built with FastAPI",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_HOSTS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
app.include_router(posts.router, prefix="/posts", tags=["Posts"])
|
||||
app.include_router(comments.router, prefix="/comments", tags=["Comments"])
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""API root endpoint."""
|
||||
return {
|
||||
"message": "Welcome to Blog API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc"
|
||||
}
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "timestamp": "2024-01-01T00:00:00Z"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# app/routers/posts.py
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import post_schemas
|
||||
from app.services import post_service
|
||||
from app.core.dependencies import get_current_user, get_current_active_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[post_schemas.PostResponse])
|
||||
async def get_posts(
|
||||
skip: int = Query(0, ge=0, description="Number of posts to skip"),
|
||||
limit: int = Query(10, ge=1, le=100, description="Number of posts to return"),
|
||||
search: Optional[str] = Query(None, description="Search in title and content"),
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
published: Optional[bool] = Query(True, description="Filter by published status"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all posts with pagination and filtering.
|
||||
|
||||
- **skip**: Number of posts to skip (for pagination)
|
||||
- **limit**: Maximum number of posts to return (1-100)
|
||||
- **search**: Search term for title and content
|
||||
- **category**: Filter posts by category
|
||||
- **published**: Filter by published status
|
||||
"""
|
||||
posts = post_service.get_posts(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
search=search,
|
||||
category=category,
|
||||
published=published
|
||||
)
|
||||
return posts
|
||||
|
||||
@router.get("/{post_id}", response_model=post_schemas.PostResponse)
|
||||
async def get_post(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific post by ID.
|
||||
|
||||
- **post_id**: Unique identifier for the post
|
||||
"""
|
||||
post = post_service.get_post(db=db, post_id=post_id)
|
||||
if not post:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found"
|
||||
)
|
||||
return post
|
||||
|
||||
@router.post("/", response_model=post_schemas.PostResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_post(
|
||||
post: post_schemas.PostCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Create a new post.
|
||||
|
||||
- **title**: Post title (required)
|
||||
- **content**: Post content (required)
|
||||
- **category**: Post category (optional)
|
||||
- **published**: Publication status (default: false)
|
||||
"""
|
||||
return post_service.create_post(
|
||||
db=db,
|
||||
post=post,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
@router.put("/{post_id}", response_model=post_schemas.PostResponse)
|
||||
async def update_post(
|
||||
post_id: int,
|
||||
post_update: post_schemas.PostUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Update an existing post.
|
||||
|
||||
- **post_id**: Unique identifier for the post
|
||||
- **title**: Updated post title (optional)
|
||||
- **content**: Updated post content (optional)
|
||||
- **category**: Updated post category (optional)
|
||||
- **published**: Updated publication status (optional)
|
||||
"""
|
||||
post = post_service.get_post(db=db, post_id=post_id)
|
||||
if not post:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found"
|
||||
)
|
||||
|
||||
if post.author_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to update this post"
|
||||
)
|
||||
|
||||
return post_service.update_post(
|
||||
db=db,
|
||||
post_id=post_id,
|
||||
post_update=post_update
|
||||
)
|
||||
|
||||
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_post(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Delete a post.
|
||||
|
||||
- **post_id**: Unique identifier for the post to delete
|
||||
"""
|
||||
post = post_service.get_post(db=db, post_id=post_id)
|
||||
if not post:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found"
|
||||
)
|
||||
|
||||
if post.author_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to delete this post"
|
||||
)
|
||||
|
||||
post_service.delete_post(db=db, post_id=post_id)
|
||||
|
||||
@router.post("/{post_id}/like", response_model=post_schemas.PostResponse)
|
||||
async def like_post(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Like/unlike a post.
|
||||
|
||||
- **post_id**: Unique identifier for the post to like
|
||||
"""
|
||||
post = post_service.get_post(db=db, post_id=post_id)
|
||||
if not post:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found"
|
||||
)
|
||||
|
||||
return post_service.toggle_like(
|
||||
db=db,
|
||||
post_id=post_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
@router.get("/{post_id}/comments", response_model=List[post_schemas.CommentResponse])
|
||||
async def get_post_comments(
|
||||
post_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all comments for a specific post.
|
||||
|
||||
- **post_id**: Unique identifier for the post
|
||||
- **skip**: Number of comments to skip
|
||||
- **limit**: Maximum number of comments to return
|
||||
"""
|
||||
post = post_service.get_post(db=db, post_id=post_id)
|
||||
if not post:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found"
|
||||
)
|
||||
|
||||
return post_service.get_post_comments(
|
||||
db=db,
|
||||
post_id=post_id,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# app/schemas/post_schemas.py
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
class PostBase(BaseModel):
|
||||
"""Base post schema."""
|
||||
title: str = Field(..., min_length=1, max_length=200, description="Post title")
|
||||
content: str = Field(..., min_length=1, description="Post content")
|
||||
category: Optional[str] = Field(None, max_length=50, description="Post category")
|
||||
published: bool = Field(False, description="Publication status")
|
||||
|
||||
class PostCreate(PostBase):
|
||||
"""Schema for creating a post."""
|
||||
|
||||
@validator('title')
|
||||
def validate_title(cls, v):
|
||||
if not v.strip():
|
||||
raise ValueError('Title cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
@validator('content')
|
||||
def validate_content(cls, v):
|
||||
if len(v.strip()) < 10:
|
||||
raise ValueError('Content must be at least 10 characters long')
|
||||
return v.strip()
|
||||
|
||||
class PostUpdate(BaseModel):
|
||||
"""Schema for updating a post."""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
content: Optional[str] = Field(None, min_length=1)
|
||||
category: Optional[str] = Field(None, max_length=50)
|
||||
published: Optional[bool] = None
|
||||
|
||||
@validator('title')
|
||||
def validate_title(cls, v):
|
||||
if v is not None and not v.strip():
|
||||
raise ValueError('Title cannot be empty')
|
||||
return v.strip() if v else v
|
||||
|
||||
@validator('content')
|
||||
def validate_content(cls, v):
|
||||
if v is not None and len(v.strip()) < 10:
|
||||
raise ValueError('Content must be at least 10 characters long')
|
||||
return v.strip() if v else v
|
||||
|
||||
class PostResponse(PostBase):
|
||||
"""Schema for post responses."""
|
||||
id: int
|
||||
author_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
like_count: int = 0
|
||||
comment_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CommentBase(BaseModel):
|
||||
"""Base comment schema."""
|
||||
content: str = Field(..., min_length=1, max_length=1000, description="Comment content")
|
||||
|
||||
class CommentCreate(CommentBase):
|
||||
"""Schema for creating a comment."""
|
||||
post_id: int = Field(..., description="ID of the post to comment on")
|
||||
|
||||
class CommentResponse(CommentBase):
|
||||
"""Schema for comment responses."""
|
||||
id: int
|
||||
post_id: int
|
||||
author_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
```
|
||||
|
||||
```python
|
||||
# app/services/post_service.py
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.models.post import Post, Comment, PostLike
|
||||
from app.schemas.post_schemas import PostCreate, PostUpdate
|
||||
|
||||
def get_posts(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
search: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
published: Optional[bool] = True
|
||||
) -> List[Post]:
|
||||
"""Get posts with filtering and pagination."""
|
||||
query = db.query(Post)
|
||||
|
||||
if published is not None:
|
||||
query = query.filter(Post.published == published)
|
||||
|
||||
if category:
|
||||
query = query.filter(Post.category == category)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Post.title.contains(search),
|
||||
Post.content.contains(search)
|
||||
)
|
||||
)
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
def get_post(db: Session, post_id: int) -> Optional[Post]:
|
||||
"""Get a single post by ID."""
|
||||
return db.query(Post).filter(Post.id == post_id).first()
|
||||
|
||||
def create_post(db: Session, post: PostCreate, user_id: int) -> Post:
|
||||
"""Create a new post."""
|
||||
db_post = Post(**post.dict(), author_id=user_id)
|
||||
db.add(db_post)
|
||||
db.commit()
|
||||
db.refresh(db_post)
|
||||
return db_post
|
||||
|
||||
def update_post(
|
||||
db: Session,
|
||||
post_id: int,
|
||||
post_update: PostUpdate
|
||||
) -> Optional[Post]:
|
||||
"""Update an existing post."""
|
||||
db_post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not db_post:
|
||||
return None
|
||||
|
||||
update_data = post_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_post, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_post)
|
||||
return db_post
|
||||
|
||||
def delete_post(db: Session, post_id: int) -> bool:
|
||||
"""Delete a post."""
|
||||
db_post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not db_post:
|
||||
return False
|
||||
|
||||
db.delete(db_post)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def toggle_like(db: Session, post_id: int, user_id: int) -> Post:
|
||||
"""Toggle like status for a post."""
|
||||
existing_like = db.query(PostLike).filter(
|
||||
PostLike.post_id == post_id,
|
||||
PostLike.user_id == user_id
|
||||
).first()
|
||||
|
||||
if existing_like:
|
||||
db.delete(existing_like)
|
||||
else:
|
||||
new_like = PostLike(post_id=post_id, user_id=user_id)
|
||||
db.add(new_like)
|
||||
|
||||
db.commit()
|
||||
return get_post(db, post_id)
|
||||
```
|
||||
|
||||
## Features Included
|
||||
|
||||
### API Documentation
|
||||
- **Automatic OpenAPI** schema generation
|
||||
- **Interactive docs** at `/docs`
|
||||
- **ReDoc documentation** at `/redoc`
|
||||
- **Request/Response examples** in schemas
|
||||
|
||||
### Validation & Serialization
|
||||
- **Pydantic models** for data validation
|
||||
- **Custom validators** for business rules
|
||||
- **Type hints** for better IDE support
|
||||
- **Automatic data conversion** and validation
|
||||
|
||||
### Error Handling
|
||||
- **HTTP status codes** for different scenarios
|
||||
- **Detailed error messages** with context
|
||||
- **Input validation errors** with field-specific messages
|
||||
- **Custom exception handlers** for consistent responses
|
||||
|
||||
### Security
|
||||
- **JWT authentication** with dependencies
|
||||
- **Role-based access control** for endpoints
|
||||
- **CORS middleware** for cross-origin requests
|
||||
- **Input sanitization** through Pydantic
|
||||
|
||||
### Performance
|
||||
- **Database query optimization** with SQLAlchemy
|
||||
- **Pagination support** for large datasets
|
||||
- **Async/await support** for concurrent requests
|
||||
- **Connection pooling** for database efficiency
|
||||
|
||||
## Testing Example
|
||||
|
||||
```python
|
||||
# tests/test_posts.py
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_get_posts():
|
||||
"""Test getting posts."""
|
||||
response = client.get("/posts/")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
def test_create_post():
|
||||
"""Test creating a new post."""
|
||||
post_data = {
|
||||
"title": "Test Post",
|
||||
"content": "This is a test post content.",
|
||||
"published": True
|
||||
}
|
||||
response = client.post("/posts/", json=post_data)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["title"] == "Test Post"
|
||||
```
|
||||
@@ -0,0 +1,775 @@
|
||||
# FastAPI Authentication & Authorization
|
||||
|
||||
Complete authentication system with JWT tokens, OAuth2, and role-based access control.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Install auth dependencies
|
||||
pip install python-jose[cryptography] passlib[bcrypt] python-multipart
|
||||
|
||||
# Generate secret key
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## JWT Configuration
|
||||
|
||||
```python
|
||||
# app/core/security.py
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT settings
|
||||
SECRET_KEY = settings.SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any],
|
||||
expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""Create JWT access token."""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(subject: Union[str, Any]) -> str:
|
||||
"""Create JWT refresh token."""
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password against hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Generate password hash."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and verify JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
```
|
||||
|
||||
## Authentication Dependencies
|
||||
|
||||
```python
|
||||
# app/api/dependencies/auth.py
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.security import decode_token
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
# OAuth2 scheme
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl="/api/v1/auth/login",
|
||||
scheme_name="JWT"
|
||||
)
|
||||
|
||||
# Bearer token scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""Get current authenticated user."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if user_id is None or token_type != "access":
|
||||
raise credentials_exception
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get(int(user_id))
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""Get current active user."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
async def get_current_superuser(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""Get current superuser."""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
def require_permissions(*permissions: str):
|
||||
"""Decorator for permission-based access control."""
|
||||
async def permission_checker(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> User:
|
||||
# Check if user has required permissions
|
||||
user_permissions = set(current_user.permissions or [])
|
||||
required_permissions = set(permissions)
|
||||
|
||||
if not required_permissions.issubset(user_permissions) and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
def require_roles(*roles: str):
|
||||
"""Decorator for role-based access control."""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> User:
|
||||
user_roles = set(role.name for role in current_user.roles or [])
|
||||
required_roles = set(roles)
|
||||
|
||||
if not required_roles.issubset(user_roles) and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient role permissions"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
```
|
||||
|
||||
## Authentication Schemas
|
||||
|
||||
```python
|
||||
# app/schemas/auth.py
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token response schema."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Token payload schema."""
|
||||
sub: Optional[int] = None
|
||||
exp: Optional[int] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""User login schema."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
"""User registration schema."""
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
"""Password reset schema."""
|
||||
email: EmailStr
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
"""Password reset confirmation schema."""
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class ChangePassword(BaseModel):
|
||||
"""Change password schema."""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
class RefreshToken(BaseModel):
|
||||
"""Refresh token schema."""
|
||||
refresh_token: str
|
||||
```
|
||||
|
||||
## Authentication Endpoints
|
||||
|
||||
```python
|
||||
# app/api/v1/auth.py
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.schemas.auth import (
|
||||
Token, UserLogin, UserRegister, PasswordReset,
|
||||
PasswordResetConfirm, ChangePassword, RefreshToken
|
||||
)
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.core.security import (
|
||||
create_access_token, create_refresh_token, verify_password,
|
||||
decode_token, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
from app.api.dependencies.auth import get_current_user, get_current_active_user
|
||||
from app.services.email import send_password_reset_email
|
||||
from app.services.auth import AuthService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserRegister,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Register new user."""
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
# Check if user already exists
|
||||
if await user_repo.get_by_email(user_data.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
if await user_repo.get_by_username(user_data.username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already taken"
|
||||
)
|
||||
|
||||
# Create user
|
||||
user = await auth_service.create_user(user_data.dict())
|
||||
return user
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""OAuth2 compatible token login."""
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
user = await auth_service.authenticate_user(
|
||||
form_data.username,
|
||||
form_data.password
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(subject=user.id)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
}
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshToken,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Refresh access token."""
|
||||
payload = decode_token(refresh_data.refresh_token)
|
||||
|
||||
if payload is None or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get(int(user_id))
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token = create_access_token(subject=user.id)
|
||||
new_refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_users_me(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
) -> Any:
|
||||
"""Get current user."""
|
||||
return current_user
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
||||
async def change_password(
|
||||
password_data: ChangePassword,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Change user password."""
|
||||
if not verify_password(password_data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
await auth_service.change_password(current_user.id, password_data.new_password)
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
@router.post("/password-reset", status_code=status.HTTP_200_OK)
|
||||
async def password_reset(
|
||||
reset_data: PasswordReset,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Request password reset."""
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get_by_email(reset_data.email)
|
||||
|
||||
if user:
|
||||
# Generate reset token
|
||||
reset_token = create_access_token(
|
||||
subject=user.id,
|
||||
expires_delta=timedelta(hours=1) # 1 hour expiry
|
||||
)
|
||||
|
||||
# Send email with reset token
|
||||
background_tasks.add_task(
|
||||
send_password_reset_email,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
token=reset_token
|
||||
)
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
return {"message": "Password reset email sent if account exists"}
|
||||
|
||||
@router.post("/password-reset-confirm", status_code=status.HTTP_200_OK)
|
||||
async def password_reset_confirm(
|
||||
reset_data: PasswordResetConfirm,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Any:
|
||||
"""Confirm password reset."""
|
||||
payload = decode_token(reset_data.token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired reset token"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token"
|
||||
)
|
||||
|
||||
user_repo = UserRepository(User, db)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
user = await user_repo.get(int(user_id))
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token"
|
||||
)
|
||||
|
||||
await auth_service.change_password(user.id, reset_data.new_password)
|
||||
|
||||
return {"message": "Password reset successful"}
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_200_OK)
|
||||
async def logout(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> Any:
|
||||
"""Logout user (invalidate token on client side)."""
|
||||
# In a more sophisticated setup, you might want to blacklist the token
|
||||
return {"message": "Successfully logged out"}
|
||||
```
|
||||
|
||||
## Authentication Service
|
||||
|
||||
```python
|
||||
# app/services/auth.py
|
||||
from typing import Optional
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.security import verify_password, get_password_hash
|
||||
|
||||
class AuthService:
|
||||
"""Authentication service."""
|
||||
|
||||
def __init__(self, user_repository: UserRepository):
|
||||
self.user_repo = user_repository
|
||||
|
||||
async def authenticate_user(
|
||||
self,
|
||||
username_or_email: str,
|
||||
password: str
|
||||
) -> Optional[User]:
|
||||
"""Authenticate user by username/email and password."""
|
||||
# Try to get user by username first, then by email
|
||||
user = await self.user_repo.get_by_username(username_or_email)
|
||||
if not user:
|
||||
user = await self.user_repo.get_by_email(username_or_email)
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
async def create_user(self, user_data: dict) -> User:
|
||||
"""Create new user."""
|
||||
# Hash password
|
||||
password = user_data.pop('password')
|
||||
hashed_password = get_password_hash(password)
|
||||
|
||||
# Create user data
|
||||
user_create_data = {
|
||||
**user_data,
|
||||
'hashed_password': hashed_password,
|
||||
'is_active': True,
|
||||
'is_superuser': False
|
||||
}
|
||||
|
||||
return await self.user_repo.create(user_create_data)
|
||||
|
||||
async def change_password(self, user_id: int, new_password: str) -> bool:
|
||||
"""Change user password."""
|
||||
hashed_password = get_password_hash(new_password)
|
||||
|
||||
result = await self.user_repo.update(user_id, {
|
||||
'hashed_password': hashed_password
|
||||
})
|
||||
|
||||
return result is not None
|
||||
|
||||
async def activate_user(self, user_id: int) -> bool:
|
||||
"""Activate user account."""
|
||||
result = await self.user_repo.update(user_id, {'is_active': True})
|
||||
return result is not None
|
||||
|
||||
async def deactivate_user(self, user_id: int) -> bool:
|
||||
"""Deactivate user account."""
|
||||
result = await self.user_repo.update(user_id, {'is_active': False})
|
||||
return result is not None
|
||||
```
|
||||
|
||||
## Role-Based Access Control
|
||||
|
||||
```python
|
||||
# app/models/rbac.py
|
||||
from sqlalchemy import Column, String, Text, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import BaseModel
|
||||
|
||||
# Association tables for many-to-many relationships
|
||||
user_roles = Table(
|
||||
'user_roles',
|
||||
Base.metadata,
|
||||
Column('user_id', Integer, ForeignKey('users.id')),
|
||||
Column('role_id', Integer, ForeignKey('roles.id'))
|
||||
)
|
||||
|
||||
role_permissions = Table(
|
||||
'role_permissions',
|
||||
Base.metadata,
|
||||
Column('role_id', Integer, ForeignKey('roles.id')),
|
||||
Column('permission_id', Integer, ForeignKey('permissions.id'))
|
||||
)
|
||||
|
||||
class Role(BaseModel):
|
||||
"""Role model for RBAC."""
|
||||
__tablename__ = "roles"
|
||||
|
||||
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||
description = Column(Text)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
|
||||
class Permission(BaseModel):
|
||||
"""Permission model for RBAC."""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
description = Column(Text)
|
||||
resource = Column(String(50), nullable=False) # e.g., 'users', 'posts'
|
||||
action = Column(String(50), nullable=False) # e.g., 'create', 'read', 'update', 'delete'
|
||||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
|
||||
# Update User model to include roles
|
||||
class User(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Relationships
|
||||
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
||||
|
||||
@property
|
||||
def permissions(self) -> list[str]:
|
||||
"""Get all permissions for user."""
|
||||
perms = set()
|
||||
for role in self.roles:
|
||||
for permission in role.permissions:
|
||||
perms.add(f"{permission.resource}:{permission.action}")
|
||||
return list(perms)
|
||||
```
|
||||
|
||||
## OAuth2 Integration
|
||||
|
||||
```python
|
||||
# app/api/v1/oauth.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from starlette.requests import Request
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# OAuth configuration
|
||||
oauth = OAuth()
|
||||
|
||||
# Google OAuth
|
||||
google = oauth.register(
|
||||
name='google',
|
||||
client_id=settings.GOOGLE_CLIENT_ID,
|
||||
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid_configuration',
|
||||
client_kwargs={
|
||||
'scope': 'openid email profile'
|
||||
}
|
||||
)
|
||||
|
||||
# GitHub OAuth
|
||||
github = oauth.register(
|
||||
name='github',
|
||||
client_id=settings.GITHUB_CLIENT_ID,
|
||||
client_secret=settings.GITHUB_CLIENT_SECRET,
|
||||
access_token_url='https://github.com/login/oauth/access_token',
|
||||
access_token_params=None,
|
||||
authorize_url='https://github.com/login/oauth/authorize',
|
||||
authorize_params=None,
|
||||
api_base_url='https://api.github.com/',
|
||||
client_kwargs={'scope': 'user:email'},
|
||||
)
|
||||
|
||||
@router.get('/google')
|
||||
async def google_login(request: Request):
|
||||
"""Initiate Google OAuth login."""
|
||||
redirect_uri = request.url_for('google_callback')
|
||||
return await google.authorize_redirect(request, redirect_uri)
|
||||
|
||||
@router.get('/google/callback')
|
||||
async def google_callback(request: Request):
|
||||
"""Handle Google OAuth callback."""
|
||||
token = await google.authorize_access_token(request)
|
||||
user_info = token.get('userinfo')
|
||||
|
||||
if user_info:
|
||||
# Create or get user
|
||||
# Generate JWT token
|
||||
# Return token
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="OAuth authentication failed"
|
||||
)
|
||||
```
|
||||
|
||||
## API Key Authentication
|
||||
|
||||
```python
|
||||
# app/models/api_key.py
|
||||
from sqlalchemy import Column, String, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import BaseModel
|
||||
import secrets
|
||||
|
||||
class APIKey(BaseModel):
|
||||
"""API Key model."""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
key_hash = Column(String(255), unique=True, nullable=False, index=True)
|
||||
prefix = Column(String(10), nullable=False, index=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
expires_at = Column(DateTime)
|
||||
last_used_at = Column(DateTime)
|
||||
|
||||
# Foreign key
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
@classmethod
|
||||
def generate_key(cls) -> tuple[str, str]:
|
||||
"""Generate API key and return (key, hash)."""
|
||||
key = secrets.token_urlsafe(32)
|
||||
prefix = key[:8]
|
||||
key_hash = get_password_hash(key)
|
||||
return key, prefix, key_hash
|
||||
|
||||
def verify_key(self, key: str) -> bool:
|
||||
"""Verify API key."""
|
||||
return verify_password(key, self.key_hash)
|
||||
|
||||
# Add to User model
|
||||
class User(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Relationships
|
||||
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
```python
|
||||
# tests/test_auth.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.core.security import create_access_token
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_user(client: AsyncClient):
|
||||
"""Test user registration."""
|
||||
user_data = {
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "testpass123",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
data = response.json()
|
||||
assert data["username"] == user_data["username"]
|
||||
assert data["email"] == user_data["email"]
|
||||
assert "hashed_password" not in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_user(client: AsyncClient, test_user):
|
||||
"""Test user login."""
|
||||
login_data = {
|
||||
"username": test_user.username,
|
||||
"password": "testpass123"
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data=login_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user(client: AsyncClient, test_user):
|
||||
"""Test get current user endpoint."""
|
||||
token = create_access_token(subject=test_user.id)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = await client.get("/api/v1/auth/me", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["username"] == test_user.username
|
||||
assert data["email"] == test_user.email
|
||||
```
|
||||
@@ -0,0 +1,657 @@
|
||||
# FastAPI Database Integration
|
||||
|
||||
Complete database setup with SQLAlchemy, Alembic, and async support for FastAPI.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Initialize Alembic
|
||||
alembic init alembic
|
||||
|
||||
# Create migration
|
||||
alembic revision --autogenerate -m "Initial migration"
|
||||
|
||||
# Apply migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Downgrade migration
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## Database Configuration
|
||||
|
||||
```python
|
||||
# app/core/config.py
|
||||
from pydantic import BaseSettings, PostgresDsn, validator
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings."""
|
||||
|
||||
# Database
|
||||
POSTGRES_SERVER: str = "localhost"
|
||||
POSTGRES_USER: str = "postgres"
|
||||
POSTGRES_PASSWORD: str = "password"
|
||||
POSTGRES_DB: str = "fastapi_app"
|
||||
POSTGRES_PORT: str = "5432"
|
||||
DATABASE_URL: Optional[PostgresDsn] = None
|
||||
|
||||
@validator("DATABASE_URL", pre=True)
|
||||
def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
return PostgresDsn.build(
|
||||
scheme="postgresql+asyncpg",
|
||||
user=values.get("POSTGRES_USER"),
|
||||
password=values.get("POSTGRES_PASSWORD"),
|
||||
host=values.get("POSTGRES_SERVER"),
|
||||
port=values.get("POSTGRES_PORT"),
|
||||
path=f"/{values.get('POSTGRES_DB') or ''}",
|
||||
)
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# Database settings
|
||||
DATABASE_POOL_SIZE: int = 10
|
||||
DATABASE_MAX_OVERFLOW: int = 20
|
||||
DATABASE_POOL_RECYCLE: int = 3600
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
## Database Setup
|
||||
|
||||
```python
|
||||
# app/db/database.py
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
str(settings.DATABASE_URL),
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_recycle=settings.DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
echo=False # Set to True for SQL debugging
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency to get database session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def create_tables():
|
||||
"""Create database tables."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def drop_tables():
|
||||
"""Drop database tables."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
```
|
||||
|
||||
## Base Model
|
||||
|
||||
```python
|
||||
# app/models/base.py
|
||||
from sqlalchemy import Column, Integer, DateTime, func
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
from app.db.database import Base
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for timestamp fields."""
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
class BaseModel(Base, TimestampMixin):
|
||||
"""Base model with common functionality."""
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(cls) -> str:
|
||||
return cls.__name__.lower()
|
||||
|
||||
def dict(self, exclude: set = None) -> dict[str, Any]:
|
||||
"""Convert model to dictionary."""
|
||||
exclude = exclude or set()
|
||||
return {
|
||||
column.name: getattr(self, column.name)
|
||||
for column in self.__table__.columns
|
||||
if column.name not in exclude
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}(id={self.id})>"
|
||||
```
|
||||
|
||||
## Example Models
|
||||
|
||||
```python
|
||||
# app/models/user.py
|
||||
from sqlalchemy import Column, String, Boolean, Text, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import BaseModel
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
class User(BaseModel):
|
||||
"""User model."""
|
||||
__tablename__ = "users"
|
||||
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(100), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
first_name = Column(String(50), nullable=False)
|
||||
last_name = Column(String(50), nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_superuser = Column(Boolean, default=False, nullable=False)
|
||||
bio = Column(Text)
|
||||
|
||||
# Relationships
|
||||
posts = relationship("Post", back_populates="author", cascade="all, delete-orphan")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_user_email_active', email, is_active),
|
||||
Index('idx_user_username_active', username, is_active),
|
||||
)
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""Verify password against hash."""
|
||||
return pwd_context.verify(password, self.hashed_password)
|
||||
|
||||
@staticmethod
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Generate password hash."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Set user password."""
|
||||
self.hashed_password = self.get_password_hash(password)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
"""Get user's full name."""
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
def dict(self, exclude: set = None) -> dict:
|
||||
"""Convert to dict excluding sensitive data."""
|
||||
exclude = exclude or set()
|
||||
exclude.add('hashed_password')
|
||||
return super().dict(exclude=exclude)
|
||||
|
||||
# app/models/post.py
|
||||
from sqlalchemy import Column, String, Text, ForeignKey, Boolean, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import BaseModel
|
||||
|
||||
class Post(BaseModel):
|
||||
"""Blog post model."""
|
||||
__tablename__ = "posts"
|
||||
|
||||
title = Column(String(200), nullable=False, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
slug = Column(String(200), unique=True, nullable=False, index=True)
|
||||
is_published = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Foreign keys
|
||||
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# Relationships
|
||||
author = relationship("User", back_populates="posts")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('idx_post_published_created', is_published, 'created_at'),
|
||||
Index('idx_post_author_published', author_id, is_published),
|
||||
)
|
||||
```
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
```python
|
||||
# app/repositories/base.py
|
||||
from typing import Generic, TypeVar, Type, Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.base import BaseModel
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=BaseModel)
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
"""Base repository with common CRUD operations."""
|
||||
|
||||
def __init__(self, model: Type[ModelType], db: AsyncSession):
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
async def get(self, id: int) -> Optional[ModelType]:
|
||||
"""Get model by ID."""
|
||||
result = await self.db.execute(
|
||||
select(self.model).where(self.model.id == id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_multi(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
filters: Dict[str, Any] = None
|
||||
) -> List[ModelType]:
|
||||
"""Get multiple models with pagination."""
|
||||
query = select(self.model)
|
||||
|
||||
if filters:
|
||||
for field, value in filters.items():
|
||||
if hasattr(self.model, field):
|
||||
query = query.where(getattr(self.model, field) == value)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def create(self, obj_in: Dict[str, Any]) -> ModelType:
|
||||
"""Create new model."""
|
||||
db_obj = self.model(**obj_in)
|
||||
self.db.add(db_obj)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
async def update(
|
||||
self,
|
||||
id: int,
|
||||
obj_in: Dict[str, Any]
|
||||
) -> Optional[ModelType]:
|
||||
"""Update model by ID."""
|
||||
await self.db.execute(
|
||||
update(self.model)
|
||||
.where(self.model.id == id)
|
||||
.values(**obj_in)
|
||||
)
|
||||
await self.db.commit()
|
||||
return await self.get(id)
|
||||
|
||||
async def delete(self, id: int) -> bool:
|
||||
"""Delete model by ID."""
|
||||
result = await self.db.execute(
|
||||
delete(self.model).where(self.model.id == id)
|
||||
)
|
||||
await self.db.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def count(self, filters: Dict[str, Any] = None) -> int:
|
||||
"""Count models with optional filters."""
|
||||
query = select(func.count(self.model.id))
|
||||
|
||||
if filters:
|
||||
for field, value in filters.items():
|
||||
if hasattr(self.model, field):
|
||||
query = query.where(getattr(self.model, field) == value)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
# app/repositories/user.py
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
"""User repository with custom methods."""
|
||||
|
||||
async def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Get user by email."""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_username(self, username: str) -> Optional[User]:
|
||||
"""Get user by username."""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_users(self, skip: int = 0, limit: int = 100):
|
||||
"""Get active users."""
|
||||
return await self.get_multi(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
filters={'is_active': True}
|
||||
)
|
||||
```
|
||||
|
||||
## Alembic Configuration
|
||||
|
||||
```python
|
||||
# alembic/env.py
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from alembic import context
|
||||
import asyncio
|
||||
|
||||
# Import your models
|
||||
from app.models.base import Base
|
||||
from app.models.user import User
|
||||
from app.models.post import Post
|
||||
from app.core.config import settings
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Override database URL
|
||||
config.set_main_option("sqlalchemy.url", str(settings.DATABASE_URL))
|
||||
|
||||
# Interpret the config file for logging
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Add your model's MetaData object here
|
||||
target_metadata = Base.metadata
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
async def run_migrations_online():
|
||||
"""Run migrations in 'online' mode."""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration["sqlalchemy.url"] = str(settings.DATABASE_URL)
|
||||
|
||||
connectable = AsyncEngine(
|
||||
engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
```
|
||||
|
||||
## Database Utilities
|
||||
|
||||
```python
|
||||
# app/db/utils.py
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.db.database import engine, AsyncSessionLocal
|
||||
from app.core.config import settings
|
||||
import asyncio
|
||||
|
||||
async def check_database_connection() -> bool:
|
||||
"""Check if database is accessible."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def create_database_if_not_exists():
|
||||
"""Create database if it doesn't exist."""
|
||||
# This is PostgreSQL specific
|
||||
import asyncpg
|
||||
from urllib.parse import urlparse
|
||||
|
||||
url = urlparse(str(settings.DATABASE_URL))
|
||||
|
||||
try:
|
||||
# Connect to postgres database to create our database
|
||||
conn = await asyncpg.connect(
|
||||
host=url.hostname,
|
||||
port=url.port,
|
||||
user=url.username,
|
||||
password=url.password,
|
||||
database='postgres'
|
||||
)
|
||||
|
||||
# Check if database exists
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM pg_database WHERE datname = $1",
|
||||
url.path[1:] # Remove leading slash
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await conn.execute(f'CREATE DATABASE "{url.path[1:]}"')
|
||||
print(f"Database {url.path[1:]} created.")
|
||||
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating database: {e}")
|
||||
|
||||
async def execute_raw_sql(sql: str, params: dict = None) -> list:
|
||||
"""Execute raw SQL query."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(text(sql), params or {})
|
||||
return result.fetchall()
|
||||
|
||||
async def get_table_info(table_name: str) -> dict:
|
||||
"""Get information about a table."""
|
||||
sql = """
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = :table_name
|
||||
ORDER BY ordinal_position;
|
||||
"""
|
||||
|
||||
result = await execute_raw_sql(sql, {'table_name': table_name})
|
||||
return [
|
||||
{
|
||||
'column_name': row[0],
|
||||
'data_type': row[1],
|
||||
'is_nullable': row[2],
|
||||
'column_default': row[3]
|
||||
}
|
||||
for row in result
|
||||
]
|
||||
```
|
||||
|
||||
## Database Initialization
|
||||
|
||||
```python
|
||||
# app/db/init_db.py
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.database import get_db, create_tables
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.config import settings
|
||||
import asyncio
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database with tables and default data."""
|
||||
# Create tables
|
||||
await create_tables()
|
||||
print("Database tables created.")
|
||||
|
||||
# Create default superuser
|
||||
async with AsyncSessionLocal() as session:
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# Check if superuser exists
|
||||
existing_user = await user_repo.get_by_email("admin@example.com")
|
||||
|
||||
if not existing_user:
|
||||
superuser_data = {
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"first_name": "Admin",
|
||||
"last_name": "User",
|
||||
"is_superuser": True,
|
||||
"is_active": True
|
||||
}
|
||||
|
||||
superuser = User(**superuser_data)
|
||||
superuser.set_password("admin123")
|
||||
|
||||
session.add(superuser)
|
||||
await session.commit()
|
||||
print("Superuser created.")
|
||||
else:
|
||||
print("Superuser already exists.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(init_db())
|
||||
```
|
||||
|
||||
## Testing Database
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.db.database import Base, get_db
|
||||
from app.main import app
|
||||
import pytest_asyncio
|
||||
|
||||
# Test database URL
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create event loop for async tests."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def test_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# Drop tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_session(test_engine):
|
||||
"""Create test database session."""
|
||||
TestSessionLocal = sessionmaker(
|
||||
test_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with TestSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
def override_get_db(test_session):
|
||||
"""Override database dependency."""
|
||||
async def _override_get_db():
|
||||
yield test_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
yield
|
||||
app.dependency_overrides = {}
|
||||
```
|
||||
|
||||
## Database Health Check
|
||||
|
||||
```python
|
||||
# app/api/health.py
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from app.db.database import get_db
|
||||
import time
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check(db: AsyncSession = Depends(get_db)):
|
||||
"""Health check endpoint."""
|
||||
checks = {
|
||||
"status": "healthy",
|
||||
"timestamp": time.time(),
|
||||
"database": await check_database_health(db),
|
||||
}
|
||||
|
||||
# Determine overall status
|
||||
if checks["database"]["status"] != "ok":
|
||||
checks["status"] = "unhealthy"
|
||||
raise HTTPException(status_code=503, detail=checks)
|
||||
|
||||
return checks
|
||||
|
||||
async def check_database_health(db: AsyncSession) -> dict:
|
||||
"""Check database connection."""
|
||||
try:
|
||||
start_time = time.time()
|
||||
await db.execute(text("SELECT 1"))
|
||||
response_time = (time.time() - start_time) * 1000 # milliseconds
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"response_time_ms": round(response_time, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,160 @@
|
||||
# FastAPI Deployment
|
||||
|
||||
Basic production deployment setup for FastAPI applications.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run with Uvicorn
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
# Docker deployment
|
||||
docker build -t fastapi-app .
|
||||
docker run -p 8000:8000 fastapi-app
|
||||
```
|
||||
|
||||
## Production Configuration
|
||||
|
||||
```python
|
||||
# app/core/config.py
|
||||
from pydantic import BaseSettings
|
||||
import os
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Production settings."""
|
||||
|
||||
# App
|
||||
PROJECT_NAME: str = "FastAPI App"
|
||||
VERSION: str = "1.0.0"
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-key")
|
||||
|
||||
# Server
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
WORKERS: int = 4
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./app.db")
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy app
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@db:5432/fastapi_db
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: fastapi_db
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SECRET_KEY=your-secret-key
|
||||
DATABASE_URL=postgresql://user:pass@localhost/fastapi_db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```python
|
||||
# app/api/health.py
|
||||
from fastapi import APIRouter
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.VERSION
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# deploy.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "Deploying FastAPI app..."
|
||||
|
||||
# Build and deploy
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
|
||||
# Health check
|
||||
echo "Checking health..."
|
||||
if curl -f http://localhost:8000/health; then
|
||||
echo "✅ Deployment successful!"
|
||||
else
|
||||
echo "❌ Deployment failed!"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
@@ -0,0 +1,927 @@
|
||||
# FastAPI Testing Framework
|
||||
|
||||
Comprehensive testing setup for FastAPI applications with pytest and async support.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=app --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_api.py
|
||||
|
||||
# Run with verbose output
|
||||
pytest -v -s
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
|
||||
```python
|
||||
# pytest.ini
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
--cov=app
|
||||
--cov-report=term-missing
|
||||
--cov-report=html:htmlcov
|
||||
--asyncio-mode=auto
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
e2e: End-to-end tests
|
||||
slow: Slow running tests
|
||||
auth: Authentication tests
|
||||
api: API tests
|
||||
asyncio_mode = auto
|
||||
```
|
||||
|
||||
## Test Dependencies
|
||||
|
||||
```python
|
||||
# requirements/test.txt
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-cov>=4.0.0
|
||||
httpx>=0.24.0
|
||||
factory-boy>=3.2.0
|
||||
faker>=18.0.0
|
||||
respx>=0.20.0
|
||||
pytest-mock>=3.10.0
|
||||
```
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Generator
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.main import app
|
||||
from app.db.database import get_db, Base
|
||||
from app.models.user import User
|
||||
from app.core.security import get_password_hash
|
||||
from tests.factories import UserFactory
|
||||
|
||||
# Test database URL
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
||||
"""Create event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def test_engine():
|
||||
"""Create test database engine."""
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
future=True
|
||||
)
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# Drop tables and dispose engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create database session for testing."""
|
||||
TestSessionLocal = sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
async with TestSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
def override_get_db(db_session: AsyncSession) -> Generator:
|
||||
"""Override database dependency."""
|
||||
async def _override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
yield
|
||||
app.dependency_overrides = {}
|
||||
|
||||
@pytest.fixture
|
||||
def client(override_get_db) -> Generator[TestClient, None, None]:
|
||||
"""Create test client."""
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
@pytest.fixture
|
||||
async def async_client(override_get_db) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create async test client."""
|
||||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db_session: AsyncSession) -> User:
|
||||
"""Create test user."""
|
||||
user_data = {
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"hashed_password": get_password_hash("testpass123"),
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"is_active": True,
|
||||
"is_superuser": False
|
||||
}
|
||||
|
||||
user = User(**user_data)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
async def superuser(db_session: AsyncSession) -> User:
|
||||
"""Create superuser."""
|
||||
user_data = {
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"hashed_password": get_password_hash("adminpass123"),
|
||||
"first_name": "Admin",
|
||||
"last_name": "User",
|
||||
"is_active": True,
|
||||
"is_superuser": True
|
||||
}
|
||||
|
||||
user = User(**user_data)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def user_token(test_user: User) -> str:
|
||||
"""Create authentication token for test user."""
|
||||
from app.core.security import create_access_token
|
||||
return create_access_token(subject=test_user.id)
|
||||
|
||||
@pytest.fixture
|
||||
def superuser_token(superuser: User) -> str:
|
||||
"""Create authentication token for superuser."""
|
||||
from app.core.security import create_access_token
|
||||
return create_access_token(subject=superuser.id)
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(user_token: str) -> dict[str, str]:
|
||||
"""Create authorization headers."""
|
||||
return {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
@pytest.fixture
|
||||
def superuser_headers(superuser_token: str) -> dict[str, str]:
|
||||
"""Create superuser authorization headers."""
|
||||
return {"Authorization": f"Bearer {superuser_token}"}
|
||||
```
|
||||
|
||||
## Test Factories
|
||||
|
||||
```python
|
||||
# tests/factories.py
|
||||
import factory
|
||||
from factory import Faker, SubFactory
|
||||
from app.models.user import User
|
||||
from app.models.post import Post
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
class UserFactory(factory.Factory):
|
||||
"""Factory for User model."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
username = Faker('user_name')
|
||||
email = Faker('email')
|
||||
first_name = Faker('first_name')
|
||||
last_name = Faker('last_name')
|
||||
hashed_password = factory.LazyAttribute(lambda obj: get_password_hash('testpass123'))
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
bio = Faker('text', max_nb_chars=200)
|
||||
|
||||
class SuperUserFactory(UserFactory):
|
||||
"""Factory for superuser."""
|
||||
username = 'admin'
|
||||
email = 'admin@example.com'
|
||||
first_name = 'Admin'
|
||||
last_name = 'User'
|
||||
is_superuser = True
|
||||
|
||||
class PostFactory(factory.Factory):
|
||||
"""Factory for Post model."""
|
||||
|
||||
class Meta:
|
||||
model = Post
|
||||
|
||||
title = Faker('sentence', nb_words=4)
|
||||
content = Faker('text', max_nb_chars=1000)
|
||||
slug = Faker('slug')
|
||||
is_published = True
|
||||
author = SubFactory(UserFactory)
|
||||
|
||||
class InactiveUserFactory(UserFactory):
|
||||
"""Factory for inactive user."""
|
||||
is_active = False
|
||||
```
|
||||
|
||||
## API Testing
|
||||
|
||||
```python
|
||||
# tests/test_api/test_users.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.models.user import User
|
||||
from tests.factories import UserFactory
|
||||
|
||||
class TestUserAPI:
|
||||
"""Test User API endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
superuser_headers: dict
|
||||
):
|
||||
"""Test POST /api/v1/users/."""
|
||||
user_data = {
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "newpass123",
|
||||
"first_name": "New",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
json=user_data,
|
||||
headers=superuser_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["username"] == user_data["username"]
|
||||
assert data["email"] == user_data["email"]
|
||||
assert "password" not in data
|
||||
assert "hashed_password" not in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_users(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test GET /api/v1/users/."""
|
||||
response = await async_client.get(
|
||||
"/api/v1/users/",
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "size" in data
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test GET /api/v1/users/{id}."""
|
||||
response = await async_client.get(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == test_user.id
|
||||
assert data["username"] == test_user.username
|
||||
assert data["email"] == test_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test PUT /api/v1/users/{id}."""
|
||||
update_data = {
|
||||
"first_name": "Updated",
|
||||
"last_name": "Name",
|
||||
"bio": "Updated bio"
|
||||
}
|
||||
|
||||
response = await async_client.put(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["first_name"] == update_data["first_name"]
|
||||
assert data["last_name"] == update_data["last_name"]
|
||||
assert data["bio"] == update_data["bio"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User,
|
||||
superuser_headers: dict
|
||||
):
|
||||
"""Test DELETE /api/v1/users/{id}."""
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
headers=superuser_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify user is deleted
|
||||
get_response = await async_client.get(
|
||||
f"/api/v1/users/{test_user.id}",
|
||||
headers=superuser_headers
|
||||
)
|
||||
assert get_response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_access(self, async_client: AsyncClient):
|
||||
"""Test unauthorized access to protected endpoints."""
|
||||
response = await async_client.get("/api/v1/users/")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forbidden_access(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test forbidden access to admin endpoints."""
|
||||
user_data = {
|
||||
"username": "unauthorized",
|
||||
"email": "unauthorized@example.com",
|
||||
"password": "pass123"
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
json=user_data,
|
||||
headers=auth_headers # Regular user, not superuser
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
```
|
||||
|
||||
## Authentication Testing
|
||||
|
||||
```python
|
||||
# tests/test_api/test_auth.py
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.models.user import User
|
||||
from app.core.security import create_access_token, decode_token
|
||||
|
||||
class TestAuthAPI:
|
||||
"""Test authentication API endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register(
|
||||
self,
|
||||
async_client: AsyncClient
|
||||
):
|
||||
"""Test user registration."""
|
||||
user_data = {
|
||||
"username": "newuser",
|
||||
"email": "new@example.com",
|
||||
"password": "newpass123",
|
||||
"first_name": "New",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["username"] == user_data["username"]
|
||||
assert data["email"] == user_data["email"]
|
||||
assert "password" not in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_email(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User
|
||||
):
|
||||
"""Test registration with duplicate email."""
|
||||
user_data = {
|
||||
"username": "different",
|
||||
"email": test_user.email, # Duplicate email
|
||||
"password": "pass123",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json=user_data
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Email already registered" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User
|
||||
):
|
||||
"""Test user login."""
|
||||
login_data = {
|
||||
"username": test_user.username,
|
||||
"password": "testpass123"
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data=login_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert "expires_in" in data
|
||||
|
||||
# Verify token is valid
|
||||
payload = decode_token(data["access_token"])
|
||||
assert payload is not None
|
||||
assert payload["sub"] == str(test_user.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_invalid_credentials(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: 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,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Incorrect username or password" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test get current user endpoint."""
|
||||
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
|
||||
assert data["email"] == test_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User
|
||||
):
|
||||
"""Test token refresh."""
|
||||
from app.core.security import create_refresh_token
|
||||
|
||||
refresh_token = create_refresh_token(subject=test_user.id)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh_token}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
test_user: User,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test password change."""
|
||||
password_data = {
|
||||
"current_password": "testpass123",
|
||||
"new_password": "newpass123"
|
||||
}
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json=password_data,
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Password changed successfully" in response.json()["message"]
|
||||
```
|
||||
|
||||
## Model Testing
|
||||
|
||||
```python
|
||||
# tests/test_models/test_user.py
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import User
|
||||
from app.core.security import verify_password, get_password_hash
|
||||
|
||||
class TestUserModel:
|
||||
"""Test User model."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(self, db_session: AsyncSession):
|
||||
"""Test user creation."""
|
||||
user_data = {
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"hashed_password": get_password_hash("password123"),
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
user = User(**user_data)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
|
||||
assert user.id is not None
|
||||
assert user.username == "testuser"
|
||||
assert user.email == "test@example.com"
|
||||
assert user.full_name == "Test User"
|
||||
assert user.is_active is True
|
||||
assert user.is_superuser is False
|
||||
assert user.created_at is not None
|
||||
assert user.updated_at is not None
|
||||
|
||||
def test_password_verification(self):
|
||||
"""Test password verification."""
|
||||
password = "testpassword123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
user = User(
|
||||
username="test",
|
||||
email="test@example.com",
|
||||
hashed_password=hashed,
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
)
|
||||
|
||||
assert user.verify_password(password)
|
||||
assert not user.verify_password("wrongpassword")
|
||||
|
||||
def test_user_dict_excludes_password(self):
|
||||
"""Test that dict() method excludes password."""
|
||||
user = User(
|
||||
username="test",
|
||||
email="test@example.com",
|
||||
hashed_password="hashed_password",
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
)
|
||||
|
||||
user_dict = user.dict()
|
||||
assert "hashed_password" not in user_dict
|
||||
assert "username" in user_dict
|
||||
assert "email" in user_dict
|
||||
|
||||
def test_user_repr(self):
|
||||
"""Test user string representation."""
|
||||
user = User(
|
||||
id=1,
|
||||
username="test",
|
||||
email="test@example.com",
|
||||
hashed_password="hash",
|
||||
first_name="Test",
|
||||
last_name="User"
|
||||
)
|
||||
|
||||
assert repr(user) == "<User(id=1)>"
|
||||
```
|
||||
|
||||
## Repository Testing
|
||||
|
||||
```python
|
||||
# tests/test_repositories/test_user.py
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
class TestUserRepository:
|
||||
"""Test UserRepository."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(self, db_session: AsyncSession):
|
||||
"""Test user creation through repository."""
|
||||
repo = UserRepository(User, db_session)
|
||||
|
||||
user_data = {
|
||||
"username": "repouser",
|
||||
"email": "repo@example.com",
|
||||
"hashed_password": get_password_hash("password123"),
|
||||
"first_name": "Repo",
|
||||
"last_name": "User"
|
||||
}
|
||||
|
||||
user = await repo.create(user_data)
|
||||
|
||||
assert user.id is not None
|
||||
assert user.username == "repouser"
|
||||
assert user.email == "repo@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_email(self, db_session: AsyncSession, test_user: User):
|
||||
"""Test get user by email."""
|
||||
repo = UserRepository(User, db_session)
|
||||
|
||||
found_user = await repo.get_by_email(test_user.email)
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user.id == test_user.id
|
||||
assert found_user.email == test_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_username(self, db_session: AsyncSession, test_user: User):
|
||||
"""Test get user by username."""
|
||||
repo = UserRepository(User, db_session)
|
||||
|
||||
found_user = await repo.get_by_username(test_user.username)
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user.id == test_user.id
|
||||
assert found_user.username == test_user.username
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_pagination(
|
||||
self,
|
||||
db_session: AsyncSession,
|
||||
test_user: User
|
||||
):
|
||||
"""Test get multiple users with pagination."""
|
||||
repo = UserRepository(User, db_session)
|
||||
|
||||
# Create additional users
|
||||
for i in range(5):
|
||||
user_data = {
|
||||
"username": f"user{i}",
|
||||
"email": f"user{i}@example.com",
|
||||
"hashed_password": get_password_hash("password123"),
|
||||
"first_name": f"User{i}",
|
||||
"last_name": "Test"
|
||||
}
|
||||
await repo.create(user_data)
|
||||
|
||||
# Test pagination
|
||||
users = await repo.get_multi(skip=0, limit=3)
|
||||
assert len(users) == 3
|
||||
|
||||
users_page_2 = await repo.get_multi(skip=3, limit=3)
|
||||
assert len(users_page_2) >= 1 # At least test_user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(self, db_session: AsyncSession, test_user: User):
|
||||
"""Test user update."""
|
||||
repo = UserRepository(User, db_session)
|
||||
|
||||
updated_user = await repo.update(test_user.id, {
|
||||
"first_name": "Updated",
|
||||
"bio": "Updated bio"
|
||||
})
|
||||
|
||||
assert updated_user is not None
|
||||
assert updated_user.first_name == "Updated"
|
||||
assert updated_user.bio == "Updated bio"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(self, db_session: AsyncSession, test_user: User):
|
||||
"""Test user deletion."""
|
||||
repo = UserRepository(User, db_session)
|
||||
|
||||
result = await repo.delete(test_user.id)
|
||||
assert result is True
|
||||
|
||||
# Verify user is deleted
|
||||
deleted_user = await repo.get(test_user.id)
|
||||
assert deleted_user is None
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
```python
|
||||
# tests/test_performance.py
|
||||
import pytest
|
||||
import time
|
||||
import asyncio
|
||||
from httpx import AsyncClient
|
||||
from app.models.user import User
|
||||
from tests.factories import UserFactory
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestPerformance:
|
||||
"""Test application performance."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test concurrent API requests."""
|
||||
|
||||
async def make_request():
|
||||
response = await async_client.get(
|
||||
"/api/v1/users/",
|
||||
headers=auth_headers
|
||||
)
|
||||
return response.status_code
|
||||
|
||||
# Make 10 concurrent requests
|
||||
start_time = time.time()
|
||||
tasks = [make_request() for _ in range(10)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
end_time = time.time()
|
||||
|
||||
# All requests should succeed
|
||||
assert all(status == 200 for status in results)
|
||||
|
||||
# Should complete within reasonable time
|
||||
assert (end_time - start_time) < 5.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_dataset_pagination(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
db_session,
|
||||
auth_headers: dict
|
||||
):
|
||||
"""Test pagination with large dataset."""
|
||||
# Create 100 users
|
||||
users = []
|
||||
for i in range(100):
|
||||
user = UserFactory.build()
|
||||
users.append(user)
|
||||
|
||||
db_session.add_all(users)
|
||||
await db_session.commit()
|
||||
|
||||
# Test pagination performance
|
||||
start_time = time.time()
|
||||
response = await async_client.get(
|
||||
"/api/v1/users/?skip=0&limit=50",
|
||||
headers=auth_headers
|
||||
)
|
||||
end_time = time.time()
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 50
|
||||
|
||||
# Should complete quickly
|
||||
assert (end_time - start_time) < 1.0
|
||||
```
|
||||
|
||||
## Mocking External Services
|
||||
|
||||
```python
|
||||
# tests/test_external.py
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from app.services.email import EmailService
|
||||
|
||||
class TestExternalServices:
|
||||
"""Test external service integrations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_email_service(
|
||||
self,
|
||||
async_client: AsyncClient
|
||||
):
|
||||
"""Test email service with mocked external API."""
|
||||
# Mock email service API
|
||||
respx.post("https://api.emailservice.com/send").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"message": "Email sent successfully"}
|
||||
)
|
||||
)
|
||||
|
||||
email_service = EmailService()
|
||||
result = await email_service.send_email(
|
||||
to="test@example.com",
|
||||
subject="Test",
|
||||
body="Test email"
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
```
|
||||
|
||||
## Test Utilities
|
||||
|
||||
```python
|
||||
# tests/utils.py
|
||||
from typing import Dict, Any
|
||||
from httpx import Response
|
||||
import json
|
||||
|
||||
def assert_response_status(response: Response, expected_status: int = 200):
|
||||
"""Assert response status code."""
|
||||
assert response.status_code == expected_status, f"Expected {expected_status}, got {response.status_code}. Response: {response.text}"
|
||||
|
||||
def assert_response_json(response: Response, expected_keys: list[str] = None):
|
||||
"""Assert response is valid JSON with expected keys."""
|
||||
assert response.headers.get("content-type") == "application/json"
|
||||
data = response.json()
|
||||
|
||||
if expected_keys:
|
||||
for key in expected_keys:
|
||||
assert key in data, f"Missing key '{key}' in response"
|
||||
|
||||
return data
|
||||
|
||||
def create_auth_headers(token: str) -> Dict[str, str]:
|
||||
"""Create authorization headers with token."""
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def create_test_users(db_session, count: int = 5) -> list:
|
||||
"""Create multiple test users."""
|
||||
from tests.factories import UserFactory
|
||||
|
||||
users = []
|
||||
for i in range(count):
|
||||
user = UserFactory.build()
|
||||
users.append(user)
|
||||
|
||||
db_session.add_all(users)
|
||||
await db_session.commit()
|
||||
|
||||
return users
|
||||
```
|
||||
@@ -0,0 +1,229 @@
|
||||
# FastAPI Project Configuration
|
||||
|
||||
This file provides specific guidance for FastAPI web application development using Claude Code.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a FastAPI application project optimized for modern API development with automatic documentation, type hints, and async support.
|
||||
|
||||
## FastAPI-Specific Development Commands
|
||||
|
||||
### Project Management
|
||||
- `uvicorn app.main:app --reload` - Start development server with auto-reload
|
||||
- `uvicorn app.main:app --host 0.0.0.0 --port 8000` - Start server on all interfaces
|
||||
- `uvicorn app.main:app --workers 4` - Start with multiple workers
|
||||
|
||||
### Database Management
|
||||
- `alembic init alembic` - Initialize Alembic migrations
|
||||
- `alembic revision --autogenerate -m "message"` - Create migration
|
||||
- `alembic upgrade head` - Apply migrations
|
||||
- `alembic downgrade -1` - Rollback one migration
|
||||
|
||||
### Development Tools
|
||||
- `python -m pytest` - Run tests
|
||||
- `python -m pytest --cov=app` - Run tests with coverage
|
||||
- `mypy app/` - Type checking
|
||||
- `black app/` - Code formatting
|
||||
|
||||
## FastAPI Project Structure
|
||||
|
||||
```
|
||||
myproject/
|
||||
├── app/ # Application package
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI application
|
||||
│ ├── core/ # Core configuration
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py # Settings
|
||||
│ │ └── security.py # Authentication
|
||||
│ ├── api/ # API routes
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── deps.py # Dependencies
|
||||
│ │ └── v1/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── api.py # API router
|
||||
│ │ └── endpoints/
|
||||
│ ├── models/ # Database models
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py
|
||||
│ │ └── user.py
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── user.py
|
||||
│ ├── repositories/ # Data access layer
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── user.py
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── auth.py
|
||||
│ └── db/ # Database configuration
|
||||
│ ├── __init__.py
|
||||
│ └── database.py
|
||||
├── alembic/ # Database migrations
|
||||
├── tests/ # Test files
|
||||
├── requirements.txt # Dependencies
|
||||
└── docker-compose.yml # Docker configuration
|
||||
```
|
||||
|
||||
## FastAPI Application Setup
|
||||
|
||||
```python
|
||||
# app/main.py
|
||||
from fastapi import FastAPI
|
||||
from app.core.config import settings
|
||||
from app.api.v1.api import api_router
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
openapi_url=f"/api/v1/openapi.json"
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Welcome to FastAPI"}
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
|
||||
```python
|
||||
# app/core/config.py
|
||||
from pydantic import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "FastAPI App"
|
||||
VERSION: str = "1.0.0"
|
||||
SECRET_KEY: str
|
||||
DATABASE_URL: str
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
## FastAPI Best Practices
|
||||
|
||||
### API Design
|
||||
- Use Pydantic models for request/response validation
|
||||
- Implement proper HTTP status codes
|
||||
- Add comprehensive API documentation
|
||||
- Use dependency injection for common functionality
|
||||
- Implement proper error handling
|
||||
|
||||
### Database Integration
|
||||
- Use SQLAlchemy with async support
|
||||
- Implement repository pattern for data access
|
||||
- Use Alembic for database migrations
|
||||
- Add proper database connection pooling
|
||||
- Implement database health checks
|
||||
|
||||
### Authentication & Security
|
||||
- Use JWT tokens for authentication
|
||||
- Implement OAuth2 with scopes
|
||||
- Add rate limiting for API endpoints
|
||||
- Use HTTPS in production
|
||||
- Implement proper CORS configuration
|
||||
|
||||
### Performance Optimization
|
||||
- Use async/await for I/O operations
|
||||
- Implement response caching
|
||||
- Add database query optimization
|
||||
- Use connection pooling
|
||||
- Monitor application performance
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Organization
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
```
|
||||
|
||||
### Test Types
|
||||
- **Unit tests** for business logic
|
||||
- **Integration tests** for API endpoints
|
||||
- **Database tests** with test fixtures
|
||||
- **Authentication tests** for security
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Production Setup
|
||||
- Use Uvicorn with multiple workers
|
||||
- Implement proper logging and monitoring
|
||||
- Set up reverse proxy (Nginx)
|
||||
- Use environment variables for configuration
|
||||
- Implement health checks
|
||||
|
||||
### Docker Configuration
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
SECRET_KEY=your-secret-key
|
||||
DATABASE_URL=postgresql://user:pass@host/db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
## Common FastAPI Patterns
|
||||
|
||||
### Dependency Injection
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from app.db.database import get_db
|
||||
|
||||
@app.get("/users/")
|
||||
async def get_users(db: Session = Depends(get_db)):
|
||||
return users
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
```python
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
@app.post("/send-email/")
|
||||
async def send_email(background_tasks: BackgroundTasks):
|
||||
background_tasks.add_task(send_email_task)
|
||||
return {"message": "Email sent"}
|
||||
```
|
||||
|
||||
### Middleware
|
||||
```python
|
||||
@app.middleware("http")
|
||||
async def add_process_time_header(request, call_next):
|
||||
response = await call_next(request)
|
||||
return response
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Getting Started
|
||||
1. Clone repository
|
||||
2. Create virtual environment: `python -m venv venv`
|
||||
3. Install dependencies: `pip install -r requirements.txt`
|
||||
4. Set environment variables
|
||||
5. Run migrations: `alembic upgrade head`
|
||||
6. Start server: `uvicorn app.main:app --reload`
|
||||
|
||||
### Code Quality
|
||||
- **Black** - Code formatting
|
||||
- **isort** - Import sorting
|
||||
- **mypy** - Type checking
|
||||
- **pytest** - Testing framework
|
||||
- **flake8** - Linting
|
||||
Reference in New Issue
Block a user