|
|
|
|
@@ -544,6 +544,171 @@ The FastAPI app factory. Responsibilities:
|
|
|
|
|
- Registers global exception handlers that map domain exceptions to HTTP status codes
|
|
|
|
|
- Applies the setup-redirect middleware (returns `423 Locked` for all API requests when no configuration exists, except for `/api/setup` and `/api/health`)
|
|
|
|
|
|
|
|
|
|
### 2.3 Dependency Wiring and Service Composition
|
|
|
|
|
|
|
|
|
|
BanGUI uses a **lightweight dependency injection (DI) pattern** based on FastAPI's `Depends()` framework. There is no heavy container library — the composition root is implicit and managed through simple provider functions in `app/dependencies.py`.
|
|
|
|
|
|
|
|
|
|
#### The DI Pattern
|
|
|
|
|
|
|
|
|
|
Every injectable dependency follows this structure:
|
|
|
|
|
|
|
|
|
|
1. **Provider Function** — An async function in `app/dependencies.py` that creates and returns a dependency:
|
|
|
|
|
```python
|
|
|
|
|
async def get_settings(app_context: ...) -> Settings:
|
|
|
|
|
"""Provide application settings."""
|
|
|
|
|
return app_context.runtime_settings or app_context.settings
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
2. **Type Alias** — An `Annotated` alias that decorates the provider for use in route signatures:
|
|
|
|
|
```python
|
|
|
|
|
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
3. **Injection Point** — Routers declare their dependencies using the type alias:
|
|
|
|
|
```python
|
|
|
|
|
async def my_route(settings: SettingsDep) -> Response:
|
|
|
|
|
# FastAPI automatically calls get_settings() and injects the result
|
|
|
|
|
...
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Service Composition Root
|
|
|
|
|
|
|
|
|
|
Services are **not instantiated by a container**. Instead, they are **composed by routers and tasks through explicit parameter passing**. This keeps dependencies visible and avoids implicit side effects.
|
|
|
|
|
|
|
|
|
|
**Example: How `ban_service.get_active_bans()` is wired:**
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# Step 1: Router declares what it needs (dependencies.py)
|
|
|
|
|
async def get_ban_service_context(
|
|
|
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
|
|
|
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
|
|
|
|
|
) -> BanServiceContext:
|
|
|
|
|
"""Combine database connection and repository."""
|
|
|
|
|
return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo)
|
|
|
|
|
|
|
|
|
|
BanServiceContextDep = Annotated[BanServiceContext, Depends(get_ban_service_context)]
|
|
|
|
|
|
|
|
|
|
# Step 2: Router uses the context and calls the service
|
|
|
|
|
@router.get("/active")
|
|
|
|
|
async def get_active_bans(
|
|
|
|
|
ban_ctx: BanServiceContextDep,
|
|
|
|
|
socket_path: Fail2BanSocketDep,
|
|
|
|
|
geo_cache: GeoCacheDep,
|
|
|
|
|
) -> ActiveBanListResponse:
|
|
|
|
|
# Router explicitly passes everything the service needs
|
|
|
|
|
domain_result = await ban_service.get_active_bans(
|
|
|
|
|
socket_path,
|
|
|
|
|
geo_cache=geo_cache,
|
|
|
|
|
app_db=ban_ctx.db, # ← Explicit, no magic
|
|
|
|
|
)
|
|
|
|
|
return map_domain_active_ban_list_to_response(domain_result)
|
|
|
|
|
|
|
|
|
|
# Step 3: Service function accepts dependencies as parameters
|
|
|
|
|
async def get_active_bans(
|
|
|
|
|
socket_path: str,
|
|
|
|
|
geo_cache: GeoCache,
|
|
|
|
|
app_db: aiosqlite.Connection,
|
|
|
|
|
) -> DomainActiveBanList:
|
|
|
|
|
"""Retrieve active bans. All dependencies are explicit parameters."""
|
|
|
|
|
# Service logic here
|
|
|
|
|
...
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Why this pattern?**
|
|
|
|
|
- **Explicit**: No hidden coupling. Every dependency is visible in function signatures.
|
|
|
|
|
- **Testable**: Easy to mock dependencies by passing test doubles.
|
|
|
|
|
- **Lightweight**: No heavyweight DI container library needed. FastAPI's `Depends()` is sufficient.
|
|
|
|
|
- **Debuggable**: Stack traces and type checkers understand the full dependency chain.
|
|
|
|
|
|
|
|
|
|
#### Service Context Dependencies
|
|
|
|
|
|
|
|
|
|
For convenience, related repositories and the database connection are bundled into **context objects**. These prevent routers from depending on the raw database connection (which violates the repository boundary).
|
|
|
|
|
|
|
|
|
|
**Available Service Contexts:**
|
|
|
|
|
|
|
|
|
|
| Context | Includes | Used By |
|
|
|
|
|
|---------|----------|---------|
|
|
|
|
|
| `SessionServiceContext` | `db`, `session_repo` | auth router |
|
|
|
|
|
| `BlocklistServiceContext` | `db`, `blocklist_repo`, `import_log_repo`, `settings_repo` | blocklist router |
|
|
|
|
|
| `SettingsServiceContext` | `db`, `settings_repo` | server settings router |
|
|
|
|
|
| `BanServiceContext` | `db`, `fail2ban_db_repo` | ban router |
|
|
|
|
|
| `HistoryServiceContext` | `db`, `fail2ban_db_repo`, `history_archive_repo` | history router |
|
|
|
|
|
|
|
|
|
|
Each context is created by a provider function:
|
|
|
|
|
```python
|
|
|
|
|
async def get_ban_service_context(
|
|
|
|
|
db: Annotated[aiosqlite.Connection, Depends(get_db)],
|
|
|
|
|
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
|
|
|
|
|
) -> BanServiceContext:
|
|
|
|
|
return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Adding a New Service
|
|
|
|
|
|
|
|
|
|
Follow this checklist when creating a new service:
|
|
|
|
|
|
|
|
|
|
1. **Create the service module** — `app/services/my_service.py`
|
|
|
|
|
2. **Define the service functions** — Each function takes its dependencies as explicit parameters (no imports of other services at the same layer)
|
|
|
|
|
3. **Export key functions** — Only the public API functions are called by routers
|
|
|
|
|
4. **If database access is needed:**
|
|
|
|
|
- Routers depend on the appropriate `ServiceContextDep` (e.g., `BanServiceContextDep`)
|
|
|
|
|
- Pass `context.db` and `context.repository` to the service function
|
|
|
|
|
5. **If a new context is needed:**
|
|
|
|
|
- Create a `@dataclass` in `app/dependencies.py` to hold the related resources
|
|
|
|
|
- Create a provider function `get_<service>_context()` that combines them
|
|
|
|
|
- Create a type alias `<Service>ContextDep` for router injection
|
|
|
|
|
6. **Register the service** — No registration step; FastAPI discovers it via `Depends()`
|
|
|
|
|
|
|
|
|
|
**Example: Adding a new service that needs blocklist and settings repos:**
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# app/services/my_new_service.py
|
|
|
|
|
async def do_something(
|
|
|
|
|
db: aiosqlite.Connection,
|
|
|
|
|
blocklist_repo: BlocklistRepository,
|
|
|
|
|
settings_repo: SettingsRepository,
|
|
|
|
|
) -> MyResult:
|
|
|
|
|
"""Do something with blocklist and settings data."""
|
|
|
|
|
sources = await blocklist_repo.list_sources(db)
|
|
|
|
|
settings = await settings_repo.load(db)
|
|
|
|
|
# Business logic
|
|
|
|
|
return ...
|
|
|
|
|
|
|
|
|
|
# app/routers/my_router.py
|
|
|
|
|
from app.dependencies import BlocklistServiceContextDep
|
|
|
|
|
from app.services import my_new_service
|
|
|
|
|
|
|
|
|
|
@router.get("/something")
|
|
|
|
|
async def my_endpoint(
|
|
|
|
|
ctx: BlocklistServiceContextDep, # ← Already has db, blocklist_repo, settings_repo
|
|
|
|
|
) -> MyResponse:
|
|
|
|
|
result = await my_new_service.do_something(
|
|
|
|
|
db=ctx.db,
|
|
|
|
|
blocklist_repo=ctx.blocklist_repo,
|
|
|
|
|
settings_repo=ctx.settings_repo,
|
|
|
|
|
)
|
|
|
|
|
return MyResponse(...)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### The Repository Boundary
|
|
|
|
|
|
|
|
|
|
Services **must not** depend on raw database connections. The repository boundary is enforced by **not exporting `DbDep` to routers**. Instead:
|
|
|
|
|
|
|
|
|
|
- Routers declare a `ServiceContextDep` which includes both the `db` and the needed repositories
|
|
|
|
|
- Services receive the `db` connection and repositories as parameters
|
|
|
|
|
- Repositories are the **only modules** that execute SQL; services never call SQL directly
|
|
|
|
|
|
|
|
|
|
This ensures:
|
|
|
|
|
- Queries are centralized and testable
|
|
|
|
|
- Changes to the database layer don't leak into business logic
|
|
|
|
|
- Repositories can be mocked independently for testing
|
|
|
|
|
|
|
|
|
|
#### Lifecycle and Scope
|
|
|
|
|
|
|
|
|
|
- **Request-scoped**: Database connections are created fresh for each request and closed after the response is sent. This prevents contention and locking issues with SQLite.
|
|
|
|
|
- **Application-scoped**: Shared resources like `aiohttp.ClientSession`, the scheduler, and the `GeoCache` are created at startup and reused across all requests.
|
|
|
|
|
- **Singleton**: Some services (e.g., `Fail2BanMetadataService`) are instantiated once and cached in `app.state` or imported as module-level instances.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 3. Frontend Architecture
|
|
|
|
|
|