Files
unitforge/backend/app/main.py
William Valentin c4fd9427ed feat: integrate configuration system into FastAPI application
- Use configurable app title, description, and API settings
- Add template context injection for all HTML routes
- Implement file upload validation with size/extension limits
- Add enhanced health check and info endpoints with feature flags
- Support conditional API documentation based on settings
2025-09-14 15:57:08 -07:00

452 lines
14 KiB
Python

"""
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