pdf-tei-editor / docs /development /class-dependencies.md
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
|
Raw
History Blame Contribute Delete
17.3 kB
# Backend Class Dependencies
Guide to FastAPI's dependency injection system and available dependencies in the PDF-TEI Editor.
## Overview
The application uses FastAPI's dependency injection system to provide reusable components to route handlers. This approach:
- Enables proper mocking in tests via `app.dependency_overrides`
- Prevents database access failures in CI environments
- Improves code testability and maintainability
- Provides automatic resource lifecycle management
## How Depends() Works
FastAPI's `Depends()` creates injectable dependencies that are resolved automatically when a route is called:
```python
from fastapi import APIRouter, Depends
from fastapi_app.lib.core.dependencies import get_db, get_file_storage
router = APIRouter()
@router.get("/files")
async def list_files(
db=Depends(get_db), # Injected automatically
file_storage=Depends(get_file_storage) # Injected automatically
):
"""db and file_storage are provided by FastAPI's DI system."""
file_repo = FileRepository(db)
# ... use db and file_storage ...
```
**Key Points:**
- Dependencies are declared as function parameters with `Depends(dependency_function)`
- FastAPI calls the dependency function and injects the result
- Dependencies can depend on other dependencies (nested dependencies)
- Each dependency is cached per request by default
- Tests can override dependencies using `app.dependency_overrides`
## Available Dependencies
All dependency functions are defined in [fastapi_app/lib/core/dependencies.py](../../fastapi_app/lib/core/dependencies.py).
### Database Dependencies
#### `get_db() -> DatabaseManager`
Returns a configured DatabaseManager instance for metadata.db.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_db
from fastapi_app.lib.core.database import DatabaseManager
@router.get("/example")
async def example(db: DatabaseManager = Depends(get_db)):
# Use db to execute SQL queries
result = db.execute("SELECT * FROM files")
```
**Used for:**
- Direct database queries
- Creating FileRepository instances
- Transaction management
#### `get_file_repository(db: DatabaseManager = Depends(get_db)) -> FileRepository`
Returns a FileRepository instance with injected database.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_file_repository
from fastapi_app.lib.repository.file_repository import FileRepository
@router.get("/files")
async def list_files(file_repo: FileRepository = Depends(get_file_repository)):
# Use file_repo for high-level file operations
files = file_repo.get_all_files()
```
**Used for:**
- File metadata queries
- Collection management
- File versioning operations
**Note:** Depends on `get_db()` internally.
#### `get_file_storage() -> FileStorage`
Returns a FileStorage instance for physical file access.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_file_storage
from fastapi_app.lib.storage.file_storage import FileStorage
@router.get("/file/{file_id}")
async def read_file(
file_id: str,
file_storage: FileStorage = Depends(get_file_storage)
):
# Read physical file from content-addressable storage
content = file_storage.read_file(file_id, "tei")
```
**Used for:**
- Reading file content from disk
- Writing files to content-addressable storage
- File reference counting and cleanup
### Authentication Dependencies
#### `get_session_manager() -> SessionManager`
Returns a SessionManager instance for session validation.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_session_manager
from fastapi_app.lib.core.sessions import SessionManager
@router.get("/protected")
async def protected_route(
session_manager: SessionManager = Depends(get_session_manager)
):
# Validate session manually if needed
is_valid = session_manager.is_session_valid(session_id, timeout)
```
**Used for:**
- Session validation in custom auth logic
- Session lifecycle management
- Testing authentication flows
#### `get_auth_manager() -> AuthManager`
Returns an AuthManager instance for user authentication.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_auth_manager
from fastapi_app.lib.utils.auth import AuthManager
@router.post("/login")
async def login(
auth_manager: AuthManager = Depends(get_auth_manager)
):
# Authenticate user
user = auth_manager.authenticate(username, password_hash)
```
**Used for:**
- User authentication
- Password validation
- User lookup by session ID
#### `get_session_id(request: Request) -> Optional[str]`
Extracts session ID from request headers or query parameters (returns None if not present).
```python
from fastapi import Request, Depends
from fastapi_app.lib.core.dependencies import get_session_id
@router.get("/optional-auth")
async def optional_auth(
request: Request,
session_id: str | None = Depends(get_session_id)
):
if session_id:
# User is authenticated
pass
else:
# Anonymous access
pass
```
**Session ID Sources (in order of precedence):**
1. `X-Session-ID` header
2. `X-Session-Id` header (case variation)
3. `session_id` query parameter
#### `require_session_id(request: Request) -> str`
Extracts session ID from request (raises 401 if not present).
```python
from fastapi import Request, Depends
from fastapi_app.lib.core.dependencies import require_session_id
@router.get("/requires-session")
async def requires_session(
request: Request,
session_id: str = Depends(require_session_id)
):
# session_id is guaranteed to be present (or 401 raised)
```
**Raises:** `HTTPException(401)` if session ID not found.
#### `get_current_user(request: Request, ...) -> Optional[Dict]`
Returns authenticated user or None (does not raise errors).
```python
from fastapi import Request, Depends
from typing import Optional, Dict
from fastapi_app.lib.core.dependencies import get_current_user
@router.get("/optional-user")
async def optional_user(
request: Request,
user: Optional[Dict] = Depends(get_current_user)
):
if user:
# User is authenticated
username = user['username']
roles = user['roles']
else:
# Anonymous access allowed
pass
```
**Returns:** User dict with keys: `username`, `email`, `roles`, `groups`, etc.
**Use when:** Endpoints support both authenticated and anonymous access.
#### `require_authenticated_user(request: Request, ...) -> Dict`
Returns authenticated user (raises 401 if not authenticated).
```python
from fastapi import Request, Depends
from typing import Dict
from fastapi_app.lib.core.dependencies import require_authenticated_user
@router.get("/protected")
async def protected(
request: Request,
user: Dict = Depends(require_authenticated_user)
):
# user is guaranteed to be authenticated (or 401 raised)
username = user['username']
roles = user['roles']
```
**Returns:** User dict with keys: `username`, `email`, `roles`, `groups`, etc.
**Raises:** `HTTPException(401)` if not authenticated or session expired.
**Use when:** Endpoint requires authentication.
**Development Mode:** Set `FASTAPI_ALLOW_ANONYMOUS_ACCESS=true` to bypass authentication (returns mock user with admin roles).
#### `require_admin_user(user: Dict = Depends(require_authenticated_user)) -> Dict`
Returns authenticated user with admin role (raises 403 if not admin).
```python
from fastapi import Depends
from typing import Dict
from fastapi_app.lib.core.dependencies import require_admin_user
@router.delete("/users/{username}")
async def delete_user(
username: str,
user: Dict = Depends(require_admin_user)
):
# user is guaranteed to have admin role (or 403 raised)
```
**Returns:** User dict (same as `require_authenticated_user`).
**Raises:**
- `HTTPException(401)` if not authenticated
- `HTTPException(403)` if authenticated but not admin
**Admin Check:** User must have `'admin'` role or wildcard `'*'` in their roles array.
**Use when:** Endpoint requires admin privileges (user management, system config, etc.).
### SSE and Sync Dependencies
#### `get_sse_service() -> SSEService`
Returns singleton SSEService instance for server-sent events.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_sse_service
from fastapi_app.lib.sse.sse_service import SSEService
@router.post("/broadcast")
async def broadcast(
sse_service: SSEService = Depends(get_sse_service)
):
# Broadcast event to all connected clients
await sse_service.broadcast("event_type", {"data": "value"})
```
**Used for:**
- Broadcasting events to all clients
- Session-specific event delivery
- Real-time notifications
#### `get_sync_service(...) -> Optional[SyncService]`
Returns SyncService instance with dependencies (returns None if WebDAV not configured).
```python
from fastapi import Depends
from typing import Optional
from fastapi_app.lib.core.dependencies import get_sync_service
from fastapi_app.lib.services.sync_service import SyncService
@router.post("/sync")
async def sync_files(
sync_service: Optional[SyncService] = Depends(get_sync_service)
):
if not sync_service:
raise HTTPException(503, "WebDAV not configured")
# Perform sync operation
result = await sync_service.sync_collection(collection_id)
```
**Returns:** `SyncService` instance or `None` if WebDAV not configured.
**Dependencies:** Automatically injects `FileRepository`, `FileStorage`, and `SSEService`.
**Use when:** Implementing WebDAV synchronization features.
#### `get_event_bus() -> EventBus`
Returns singleton EventBus instance for decoupled component communication.
```python
from fastapi import Depends
from fastapi_app.lib.core.dependencies import get_event_bus
from fastapi_app.lib.sse.event_bus import EventBus
@router.post("/files/{file_id}")
async def update_file(
file_id: str,
event_bus: EventBus = Depends(get_event_bus)
):
# Update file...
# Notify other components about the change
await event_bus.emit("file.updated", file_id=file_id, variant="tei")
```
**Used for:**
- Loose coupling between plugins and application components
- Event-driven architecture patterns
- Broadcasting state changes without direct dependencies
**Key Features:**
- Async-native (handlers are async functions)
- Error isolation (exceptions in one handler don't affect others)
- Concurrent handler execution
- Singleton instance shared across application
**Common Events:**
- `file.updated` - File content or metadata changed
- `file.deleted` - File removed
- `collection.modified` - Collection membership changed
- `plugin.initialized` - Plugin finished initialization
**Handler Registration:**
Plugins typically register handlers during initialization:
```python
class MyPlugin(PluginBase):
async def initialize(self):
bus = get_event_bus()
bus.on("file.updated", self._handle_file_update)
async def _handle_file_update(self, file_id: str, variant: str, **kwargs):
# Handle the event
pass
```
## Dependency Nesting
Dependencies can depend on other dependencies. FastAPI resolves the entire dependency tree:
```python
# get_file_repository depends on get_db
def get_file_repository(db: DatabaseManager = Depends(get_db)) -> FileRepository:
return FileRepository(db)
# get_sync_service depends on get_file_repository (which depends on get_db)
def get_sync_service(
file_repo: FileRepository = Depends(get_file_repository),
file_storage: FileStorage = Depends(get_file_storage),
sse_service: SSEService = Depends(get_sse_service)
) -> SyncService:
return SyncService(file_repo, file_storage, sse_service)
# Your route only needs to declare the top-level dependency
@router.post("/sync")
async def sync_files(sync_service: SyncService = Depends(get_sync_service)):
# All nested dependencies (db, file_repo, etc.) are resolved automatically
```
FastAPI caches dependency results per request, so `get_db()` is called once even if multiple dependencies need it.
## Testing with Dependency Overrides
Dependencies can be overridden in tests using `app.dependency_overrides`:
```python
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fastapi_app.lib.core.dependencies import get_db, get_auth_manager
# Create test app
app = FastAPI()
app.include_router(router)
# Create mocks
mock_db = MagicMock()
mock_auth = MagicMock()
# Override dependencies
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_auth_manager] = lambda: mock_auth
# Test client will use mocks
client = TestClient(app)
response = client.get("/files")
```
**Key Points:**
- Override in `setUp()` to apply to all tests
- Mock return values: `mock_db.execute.return_value = [...]`
- Individual tests can modify mock behavior
- Always use dependency injection (not direct calls) to enable mocking
See [testing-guide.md](../code-assistant/testing-guide.md#python-unit-tests-with-fastapi-routes) for complete testing patterns.
## Common Patterns
### Basic Authenticated Route
```python
from fastapi import APIRouter, Depends, Request
from typing import Dict
from fastapi_app.lib.core.dependencies import require_authenticated_user
router = APIRouter()
@router.get("/profile")
async def get_profile(
request: Request,
user: Dict = Depends(require_authenticated_user)
):
return {
"username": user['username'],
"roles": user['roles']
}
```
### Admin-Only Route
```python
from fastapi import APIRouter, Depends
from typing import Dict
from fastapi_app.lib.core.dependencies import require_admin_user
router = APIRouter()
@router.delete("/users/{username}")
async def delete_user(
username: str,
user: Dict = Depends(require_admin_user)
):
# Only admin users can reach here
# ... delete user logic ...
```
### File Operation Route
```python
from fastapi import APIRouter, Depends
from fastapi_app.lib.core.dependencies import get_file_repository, get_file_storage
from fastapi_app.lib.repository.file_repository import FileRepository
from fastapi_app.lib.storage.file_storage import FileStorage
router = APIRouter()
@router.get("/files/{file_id}")
async def get_file(
file_id: str,
file_repo: FileRepository = Depends(get_file_repository),
file_storage: FileStorage = Depends(get_file_storage)
):
# Get metadata
file_metadata = file_repo.get_file_by_stable_id(file_id)
# Read content
content = file_storage.read_file(file_metadata.file_id, file_metadata.file_type)
return {"content": content.decode('utf-8')}
```
### Custom Authentication Route
When implementing custom authentication logic (e.g., plugin routes), use the individual auth dependencies:
```python
from fastapi import APIRouter, Depends, Header, Query, HTTPException
from fastapi_app.lib.core.dependencies import get_auth_manager, get_session_manager
from fastapi_app.lib.utils.auth import AuthManager
from fastapi_app.lib.core.sessions import SessionManager
router = APIRouter(prefix="/api/plugins/my-plugin")
@router.get("/custom")
async def custom_route(
session_id: str | None = Query(None),
x_session_id: str | None = Header(None, alias="X-Session-ID"),
session_manager: SessionManager = Depends(get_session_manager),
auth_manager: AuthManager = Depends(get_auth_manager)
):
from fastapi_app.config import get_settings
# Extract session ID (header takes precedence)
session_id_value = x_session_id or session_id
if not session_id_value:
raise HTTPException(status_code=401, detail="Authentication required")
# Validate session
settings = get_settings()
if not session_manager.is_session_valid(session_id_value, settings.session_timeout):
raise HTTPException(status_code=401, detail="Invalid or expired session")
# Get user
user = auth_manager.get_user_by_session_id(session_id_value, session_manager)
if not user:
raise HTTPException(status_code=401, detail="User not found")
# ... use user ...
```
**Note:** For standard routes, prefer `require_authenticated_user` instead of manual authentication.
## Anti-Patterns
### ❌ Direct Function Calls (Wrong)
```python
# BAD - Cannot be mocked in tests, fails if database doesn't exist
@router.get("/files")
async def list_files():
db = get_db() # Direct call - bad!
file_storage = get_file_storage() # Direct call - bad!
# ...
```
**Problems:**
- Tests cannot override these dependencies
- Fails in CI if database doesn't exist
- Not following FastAPI patterns
### ✅ Dependency Injection (Correct)
```python
# GOOD - Can be mocked in tests, follows FastAPI patterns
@router.get("/files")
async def list_files(
db=Depends(get_db),
file_storage=Depends(get_file_storage)
):
# ...
```
## Related Documentation
- [Testing Guide](../code-assistant/testing-guide.md) - Dependency mocking patterns
- [Backend Plugins](../code-assistant/backend-plugins.md) - Using dependencies in plugins
- [Architecture](./architecture.md) - System architecture overview