- Extract jail status/processing to helper functions - Add error_handling.py service for centralized error handling - Update config.py with validation and defaults - Update .env.example with all config options - Remove obsolete Tasks.md, add Service-Development.md - Minor fixes across routers and services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
115 lines
3.4 KiB
Markdown
115 lines
3.4 KiB
Markdown
# Service Development Guide
|
|
|
|
How to write and maintain services in BanGUI.
|
|
|
|
## Error Handling Contracts
|
|
|
|
Every service method must document which error handling pattern it follows.
|
|
This lets callers know what to expect without reading the implementation.
|
|
|
|
### The Three Patterns
|
|
|
|
```python
|
|
from app.services.error_handling import ABORT_ON_ERROR, RETURN_DEFAULT, PARTIAL_RESULT
|
|
```
|
|
|
|
**ABORT_ON_ERROR** — Raise an exception, let the router convert it to HTTP.
|
|
Used for: auth, writes, state changes, any operation where partial success is meaningless.
|
|
|
|
```python
|
|
async def start_jail(socket_path: str, name: str) -> None:
|
|
"""Start a stopped fail2ban jail.
|
|
|
|
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
|
|
JailOperationError (409), Fail2BanConnectionError (503).
|
|
"""
|
|
...
|
|
```
|
|
|
|
**RETURN_DEFAULT** — Return empty result and log warning. Never raises.
|
|
Used for: informational reads (list, get) where infrastructure unavailability
|
|
should not block the UI.
|
|
|
|
```python
|
|
async def get_settings(socket_path: str) -> DomainServerSettingsResult:
|
|
"""Return current fail2ban server-level settings.
|
|
|
|
Error contract: RETURN_DEFAULT. Returns DomainServerSettingsResult
|
|
with default values if socket is unreachable. Never raises.
|
|
"""
|
|
...
|
|
```
|
|
|
|
**PARTIAL_RESULT** — Return (result, errors) tuple. Errors collected, not raised.
|
|
Used for: batch operations on collections where one item failing does not
|
|
invalidate the rest.
|
|
|
|
```python
|
|
# Not yet used in codebase; define as needed for batch operations.
|
|
```
|
|
|
|
### When to Use Which
|
|
|
|
| Operation type | Pattern |
|
|
|---------------|---------|
|
|
| Auth / session | ABORT_ON_ERROR |
|
|
| Write / state change | ABORT_ON_ERROR |
|
|
| Config updates | ABORT_ON_ERROR |
|
|
| Single-item read (jail, ban) | ABORT_ON_ERROR |
|
|
| Multi-item read (list) | RETURN_DEFAULT |
|
|
| Server settings read | RETURN_DEFAULT |
|
|
| Batch / parallel fetch | PARTIAL_RESULT |
|
|
|
|
### Changing Patterns
|
|
|
|
Switching a method's error contract is a **breaking change**. Update the docstring,
|
|
add a changelog entry, and bump the major version if this is a public API.
|
|
|
|
## Service Structure
|
|
|
|
Services live in `backend/app/services/`. They contain **no** HTTP/FastAPI concerns.
|
|
|
|
```
|
|
app/services/
|
|
ban_service.py # ban/unban, ban history queries
|
|
jail_service.py # jail lifecycle, ignore lists
|
|
server_service.py # server-level settings
|
|
geo_service.py # geolocation
|
|
...
|
|
error_handling.py # contract definitions
|
|
protocols.py # Protocol interfaces for DI
|
|
```
|
|
|
|
## Protocols
|
|
|
|
Each service has a corresponding protocol in `protocols.py` for dependency injection.
|
|
Protocol methods include the error contract in their docstring:
|
|
|
|
```python
|
|
class JailService(Protocol):
|
|
async def list_jails(self, socket_path: str) -> DomainJailList:
|
|
"""Error contract: ABORT_ON_ERROR."""
|
|
...
|
|
```
|
|
|
|
## Router Error Handling
|
|
|
|
Routers must not catch and silently swallow exceptions from services using
|
|
ABORT_ON_ERROR unless they convert to a specific HTTP response.
|
|
Let domain exceptions propagate — the global exception handlers handle them.
|
|
|
|
Exception handler registration (in `main.py`):
|
|
- `DomainError` → JSON error response
|
|
- `Fail2BanConnectionError` → HTTP 503
|
|
- `JailNotFoundError` → HTTP 404
|
|
|
|
## Logging
|
|
|
|
Log at the service layer using structlog:
|
|
|
|
```python
|
|
log.info("jail_started", jail=name)
|
|
log.warning("socket_unreachable_using_default", socket_path=socket_path)
|
|
```
|
|
|
|
Never log sensitive data (tokens, passwords, IPs in full). |