- 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
452 lines
14 KiB
Python
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
|