""" Main FastAPI application for UnitForge. This module provides the web API for creating, validating, and managing systemd unit files through HTTP endpoints. """ import tempfile from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import FastAPI, File, HTTPException, Request, UploadFile # type: ignore from fastapi.middleware.cors import CORSMiddleware # type: ignore from fastapi.responses import FileResponse, HTMLResponse # type: ignore from fastapi.staticfiles import StaticFiles # type: ignore from fastapi.templating import Jinja2Templates # type: ignore from pydantic import BaseModel from .core.config import settings from .core.templates import UnitTemplate, template_registry from .core.unit_file import SystemdUnitFile, UnitType, ValidationError, create_unit_file # Create FastAPI app app = FastAPI( title=settings.api_title, description=settings.api_description, version=settings.api_version, docs_url=settings.api_docs_url if settings.api_docs_enabled else None, redoc_url=settings.api_redoc_url if settings.redoc_enabled else None, ) # Add CORS middleware app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Setup templates and static files BASE_DIR = Path(__file__).resolve().parent.parent.parent TEMPLATES_DIR = BASE_DIR / settings.templates_dir STATIC_DIR = BASE_DIR / settings.static_dir templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) # Mount static files if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") # Pydantic models for API class ValidationResult(BaseModel): """Result of unit file validation.""" valid: bool errors: List[Dict[str, Any]] warnings: List[Dict[str, Any]] class UnitFileContent(BaseModel): """Unit file content for validation/generation.""" content: str filename: Optional[str] = None class GenerateRequest(BaseModel): """Request to generate a unit file from parameters.""" template_name: str parameters: Dict[str, Any] filename: Optional[str] = None class TemplateInfo(BaseModel): """Template information.""" name: str description: str unit_type: str category: str parameters: List[Dict[str, Any]] tags: List[str] class CreateUnitRequest(BaseModel): """Request to create a basic unit file.""" unit_type: str name: str description: Optional[str] = None parameters: Dict[str, Any] = {} # Helper functions def validation_error_to_dict(error: ValidationError) -> Dict[str, Any]: """Convert ValidationError to dictionary.""" return { "section": error.section, "key": error.key, "message": error.message, "severity": error.severity, "line_number": error.line_number, } def template_to_dict(template: UnitTemplate) -> Dict[str, Any]: """Convert UnitTemplate to dictionary.""" parameters = [] for param in template.parameters: param_dict = { "name": param.name, "description": param.description, "type": param.parameter_type, "required": param.required, "default": param.default, "choices": param.choices, "example": param.example, } parameters.append(param_dict) return { "name": template.name, "description": template.description, "unit_type": template.unit_type.value, "category": template.category, "parameters": parameters, "tags": template.tags or [], } # Web UI Routes @app.get("/", response_class=HTMLResponse) async def index(request: Request): """Serve the main web interface.""" context = {"request": request} context.update(settings.get_template_context()) return templates.TemplateResponse("index.html", context) @app.get("/editor", response_class=HTMLResponse) async def editor(request: Request): """Serve the unit file editor interface.""" context = {"request": request} context.update(settings.get_template_context()) return templates.TemplateResponse("editor.html", context) @app.get("/templates", response_class=HTMLResponse) async def templates_page(request: Request): """Serve the templates browser interface.""" context = {"request": request} context.update(settings.get_template_context()) return templates.TemplateResponse("templates.html", context) # API Routes @app.post("/api/validate", response_model=ValidationResult) async def validate_unit_file(unit_file: UnitFileContent): """Validate a systemd unit file.""" try: systemd_unit = SystemdUnitFile(content=unit_file.content) validation_errors = systemd_unit.validate() errors = [] warnings = [] for error in validation_errors: error_dict = validation_error_to_dict(error) if error.severity == "error": errors.append(error_dict) else: warnings.append(error_dict) return ValidationResult( valid=len(errors) == 0, errors=errors, warnings=warnings ) except Exception as e: raise HTTPException(status_code=400, detail=f"Validation failed: {str(e)}") @app.post("/api/generate") async def generate_unit_file(request: GenerateRequest): """Generate a unit file from a template.""" try: template = template_registry.get_template(request.template_name) if not template: raise HTTPException( status_code=404, detail=f"Template '{request.template_name}' not found" ) # Validate required parameters for param in template.parameters: if param.required and param.name not in request.parameters: raise HTTPException( status_code=400, detail=f"Required parameter '{param.name}' is missing", ) # Generate the unit file unit_file = template.generate(request.parameters) content = unit_file.to_string() # Validate the generated content validation_errors = unit_file.validate() errors = [ validation_error_to_dict(e) for e in validation_errors if e.severity == "error" ] warnings = [ validation_error_to_dict(e) for e in validation_errors if e.severity != "error" ] return { "content": content, "filename": ( request.filename or f"{request.parameters.get('name', 'service')}" f".{template.unit_type.value}" ), "validation": { "valid": len(errors) == 0, "errors": errors, "warnings": warnings, }, } except Exception as e: raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}") @app.post("/api/create") async def create_unit_file_endpoint(request: CreateUnitRequest): """Create a basic unit file.""" try: # Parse unit type try: unit_type = UnitType(request.unit_type.lower()) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid unit type: {request.unit_type}" ) # Create basic unit file kwargs = { "description": request.description or f"{request.name} {unit_type.value}", **request.parameters, } unit_file = create_unit_file(unit_type, **kwargs) content = unit_file.to_string() # Validate the created content validation_errors = unit_file.validate() errors = [ validation_error_to_dict(e) for e in validation_errors if e.severity == "error" ] warnings = [ validation_error_to_dict(e) for e in validation_errors if e.severity != "error" ] return { "content": content, "filename": f"{request.name}.{unit_type.value}", "validation": { "valid": len(errors) == 0, "errors": errors, "warnings": warnings, }, } except Exception as e: raise HTTPException(status_code=500, detail=f"Creation failed: {str(e)}") @app.get("/api/templates", response_model=List[TemplateInfo]) async def list_templates(): """List all available templates.""" templates = template_registry.list_templates() return [TemplateInfo(**template_to_dict(template)) for template in templates] @app.get("/api/templates/{template_name}", response_model=TemplateInfo) async def get_template(template_name: str): """Get details of a specific template.""" template = template_registry.get_template(template_name) if not template: raise HTTPException( status_code=404, detail=f"Template '{template_name}' not found" ) return TemplateInfo(**template_to_dict(template)) @app.get("/api/templates/category/{category}", response_model=List[TemplateInfo]) async def get_templates_by_category(category: str): """Get templates by category.""" templates = template_registry.get_by_category(category) return [TemplateInfo(**template_to_dict(template)) for template in templates] @app.get("/api/templates/type/{unit_type}", response_model=List[TemplateInfo]) async def get_templates_by_type(unit_type: str): """Get templates by unit type.""" try: unit_type_enum = UnitType(unit_type.lower()) except ValueError: raise HTTPException(status_code=400, detail=f"Invalid unit type: {unit_type}") templates = template_registry.get_by_type(unit_type_enum) return [TemplateInfo(**template_to_dict(template)) for template in templates] @app.get("/api/search/{query}", response_model=List[TemplateInfo]) async def search_templates(query: str): """Search templates by name, description, or tags.""" templates = template_registry.search(query) return [TemplateInfo(**template_to_dict(template)) for template in templates] @app.post("/api/download") async def download_unit_file(unit_file: UnitFileContent): """Download a unit file.""" try: # Create a temporary file filename = unit_file.filename or "unit.service" temp_file = tempfile.NamedTemporaryFile( mode="w", delete=False, suffix=f"_{filename}" ) temp_file.write(unit_file.content) temp_file.close() return FileResponse( path=temp_file.name, filename=filename, media_type="application/octet-stream", ) except Exception as e: raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}") @app.post("/api/upload") async def upload_unit_file(file: UploadFile = File(...)): """Upload and validate a unit file.""" try: # Check file size if file.size and file.size > settings.max_upload_size: raise HTTPException( status_code=413, detail=f"File too large. Maximum size is {settings.max_upload_size} bytes" ) # Check file extension if file.filename: file_ext = Path(file.filename).suffix.lower() if file_ext not in settings.allowed_extensions: raise HTTPException( status_code=400, detail=f"File type not allowed. Allowed types: {', '.join(settings.allowed_extensions)}" ) content = await file.read() content_str = content.decode("utf-8") # Parse and validate the uploaded file systemd_unit = SystemdUnitFile(content=content_str) validation_errors = systemd_unit.validate() errors = [] warnings = [] for error in validation_errors: error_dict = validation_error_to_dict(error) if error.severity == "error": errors.append(error_dict) else: warnings.append(error_dict) # Get unit info unit_info = systemd_unit.get_info() return { "filename": file.filename, "content": content_str, "unit_type": unit_info.unit_type.value if unit_info.unit_type else None, "description": unit_info.description, "validation": { "valid": len(errors) == 0, "errors": errors, "warnings": warnings, }, } except UnicodeDecodeError: raise HTTPException(status_code=400, detail="File must be valid UTF-8 text") except Exception as e: raise HTTPException( status_code=500, detail=f"Upload processing failed: {str(e)}" ) @app.get("/api/info") async def get_info(): """Get application information.""" return { "name": settings.app_name, "version": settings.app_version, "description": settings.app_description, "environment": settings.environment, "debug": settings.debug, "supported_types": [t.value for t in UnitType], "template_count": len(template_registry.list_templates()), "max_upload_size": settings.max_upload_size, "allowed_extensions": settings.allowed_extensions, "features": { "api_metrics": settings.enable_api_metrics, "template_caching": settings.enable_template_caching, "validation_caching": settings.enable_validation_caching, "api_docs": settings.api_docs_enabled, "swagger_ui": settings.swagger_ui_enabled, "redoc": settings.redoc_enabled, } } # Health check @app.get("/health") async def health_check(): """Health check endpoint.""" if not settings.health_check_enabled: raise HTTPException(status_code=404, detail="Health check disabled") return { "status": "healthy", "service": settings.app_name.lower(), "version": settings.app_version, "environment": settings.environment, "timestamp": __import__("datetime").datetime.utcnow().isoformat() } if __name__ == "__main__": import uvicorn # type: ignore uvicorn.run(app, host=settings.host, port=settings.port) # nosec B104