feat: Add comprehensive configuration persistence system

- Implemented ConfigService with file-based JSON persistence
  - Atomic file writes using temporary files
  - Configuration validation with detailed error reporting
  - Schema versioning with migration support
  - Singleton pattern for global access

- Added backup management functionality
  - Automatic backup creation before updates
  - Manual backup creation with custom names
  - Backup restoration with pre-restore backup
  - Backup listing and deletion
  - Automatic cleanup of old backups (max 10)

- Updated configuration API endpoints
  - GET /api/config - Retrieve configuration
  - PUT /api/config - Update with automatic backup
  - POST /api/config/validate - Validation without applying
  - GET /api/config/backups - List all backups
  - POST /api/config/backups - Create manual backup
  - POST /api/config/backups/{name}/restore - Restore backup
  - DELETE /api/config/backups/{name} - Delete backup

- Comprehensive test coverage
  - 27 unit tests for ConfigService (all passing)
  - Integration tests for API endpoints
  - Tests for validation, persistence, backups, and error handling

- Updated documentation
  - Added ConfigService documentation to infrastructure.md
  - Marked task as completed in instructions.md

Files changed:
- src/server/services/config_service.py (new)
- src/server/api/config.py (refactored)
- tests/unit/test_config_service.py (new)
- tests/api/test_config_endpoints.py (enhanced)
- infrastructure.md (updated)
- instructions.md (updated)
This commit is contained in:
2025-10-17 20:26:40 +02:00
parent a0f32b1a00
commit 0d6cade56c
6 changed files with 1033 additions and 64 deletions

View File

@@ -1,9 +1,14 @@
from typing import Optional
from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from src.config.settings import settings
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
from src.server.services.config_service import (
ConfigBackupError,
ConfigServiceError,
ConfigValidationError,
get_config_service,
)
from src.server.utils.dependencies import require_auth
router = APIRouter(prefix="/api/config", tags=["config"])
@@ -11,58 +16,144 @@ router = APIRouter(prefix="/api/config", tags=["config"])
@router.get("", response_model=AppConfig)
def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
"""Return current application configuration (read-only)."""
# Construct AppConfig from pydantic-settings where possible
cfg_data = {
"name": getattr(settings, "app_name", "Aniworld"),
"data_dir": getattr(settings, "data_dir", "data"),
"scheduler": getattr(settings, "scheduler", {}),
"logging": getattr(settings, "logging", {}),
"backup": getattr(settings, "backup", {}),
"other": getattr(settings, "other", {}),
}
"""Return current application configuration."""
try:
return AppConfig(**cfg_data)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read config: {e}")
config_service = get_config_service()
return config_service.load_config()
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to load config: {e}"
) from e
@router.put("", response_model=AppConfig)
def update_config(update: ConfigUpdate, auth: dict = Depends(require_auth)) -> AppConfig:
"""Apply an update to the configuration and return the new config.
def update_config(
update: ConfigUpdate, auth: dict = Depends(require_auth)
) -> AppConfig:
"""Apply an update to the configuration and persist it.
Note: persistence strategy for settings is out-of-scope for this task.
This endpoint updates the in-memory Settings where possible and returns
the merged result as an AppConfig.
Creates automatic backup before applying changes.
"""
# Build current AppConfig from settings then apply update
current = get_config(auth)
new_cfg = update.apply_to(current)
# Mirror some fields back into pydantic-settings 'settings' where safe.
# Avoid writing secrets or unsupported fields.
try:
if new_cfg.data_dir:
setattr(settings, "data_dir", new_cfg.data_dir)
# scheduler/logging/backup/other kept in memory only for now
setattr(settings, "scheduler", new_cfg.scheduler.model_dump())
setattr(settings, "logging", new_cfg.logging.model_dump())
setattr(settings, "backup", new_cfg.backup.model_dump())
setattr(settings, "other", new_cfg.other)
except Exception:
# Best-effort; do not fail the request if persistence is not available
pass
return new_cfg
config_service = get_config_service()
return config_service.update_config(update)
except ConfigValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid configuration: {e}"
) from e
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update config: {e}"
) from e
@router.post("/validate", response_model=ValidationResult)
def validate_config(cfg: AppConfig, auth: dict = Depends(require_auth)) -> ValidationResult:
def validate_config(
cfg: AppConfig, auth: dict = Depends(require_auth) # noqa: ARG001
) -> ValidationResult:
"""Validate a provided AppConfig without applying it.
Returns ValidationResult with any validation errors.
"""
try:
return cfg.validate()
config_service = get_config_service()
return config_service.validate_config(cfg)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
) from e
@router.get("/backups", response_model=List[Dict[str, object]])
def list_backups(
auth: dict = Depends(require_auth)
) -> List[Dict[str, object]]:
"""List all available configuration backups.
Returns list of backup metadata including name, size, and created time.
"""
try:
config_service = get_config_service()
return config_service.list_backups()
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list backups: {e}"
) from e
@router.post("/backups", response_model=Dict[str, str])
def create_backup(
name: Optional[str] = None, auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Create a backup of the current configuration.
Args:
name: Optional custom backup name (timestamp used if not provided)
Returns:
Dictionary with backup name and message
"""
try:
config_service = get_config_service()
backup_path = config_service.create_backup(name)
return {
"name": backup_path.name,
"message": "Backup created successfully"
}
except ConfigBackupError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to create backup: {e}"
) from e
@router.post("/backups/{backup_name}/restore", response_model=AppConfig)
def restore_backup(
backup_name: str, auth: dict = Depends(require_auth)
) -> AppConfig:
"""Restore configuration from a backup.
Creates backup of current config before restoring.
Args:
backup_name: Name of backup file to restore
Returns:
Restored configuration
"""
try:
config_service = get_config_service()
return config_service.restore_backup(backup_name)
except ConfigBackupError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Failed to restore backup: {e}"
) from e
@router.delete("/backups/{backup_name}")
def delete_backup(
backup_name: str, auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Delete a configuration backup.
Args:
backup_name: Name of backup file to delete
Returns:
Success message
"""
try:
config_service = get_config_service()
config_service.delete_backup(backup_name)
return {"message": f"Backup '{backup_name}' deleted successfully"}
except ConfigBackupError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Failed to delete backup: {e}"
) from e