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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user