CalejoControl/src/protocols/rest_api.py

624 lines
23 KiB
Python

"""
REST API Server for Calejo Control Adapter.
Provides REST endpoints for emergency stop, status monitoring, and setpoint access.
"""
from typing import Optional, Dict, Any
from datetime import datetime
import structlog
from fastapi import FastAPI, HTTPException, status, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from src.core.setpoint_manager import SetpointManager
from src.core.emergency_stop import EmergencyStopManager
from src.core.security import (
SecurityManager, TokenData, UserRole, get_security_manager
)
from src.core.tls_manager import get_tls_manager
logger = structlog.get_logger()
# Security
security = HTTPBearer()
class LoginRequest(BaseModel):
"""Request model for user login."""
username: str
password: str
class LoginResponse(BaseModel):
"""Response model for successful login."""
access_token: str
token_type: str = "bearer"
expires_in: int
user_id: str
username: str
role: str
permissions: list[str]
class UserInfoResponse(BaseModel):
"""Response model for user information."""
user_id: str
username: str
email: str
role: str
permissions: list[str]
last_login: Optional[str]
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
security_manager: SecurityManager = Depends(get_security_manager)
) -> TokenData:
"""Dependency to get current user from JWT token."""
token = credentials.credentials
token_data = security_manager.verify_access_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return token_data
def require_permission(permission: str):
"""Dependency factory to require specific permission."""
def permission_dependency(
token_data: TokenData = Depends(get_current_user),
security_manager: SecurityManager = Depends(get_security_manager)
):
if not security_manager.check_permission(token_data, permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required: {permission}"
)
return token_data
return permission_dependency
class EmergencyStopRequest(BaseModel):
"""Request model for emergency stop."""
triggered_by: str
reason: str
station_id: Optional[str] = None
pump_id: Optional[str] = None
class EmergencyStopClearRequest(BaseModel):
"""Request model for emergency stop clearance."""
cleared_by: str
notes: str
class SetpointResponse(BaseModel):
"""Response model for setpoint data."""
station_id: str
pump_id: str
setpoint_hz: Optional[float]
control_type: str
safety_status: str
timestamp: str
class SetpointUpdateRequest(BaseModel):
"""Request model for updating setpoint."""
setpoint_hz: float
reason: Optional[str] = None
class RESTAPIServer:
"""REST API Server for Calejo Control Adapter."""
def __init__(
self,
setpoint_manager: SetpointManager,
emergency_stop_manager: EmergencyStopManager,
host: str = "0.0.0.0",
port: int = 8000
):
self.setpoint_manager = setpoint_manager
self.emergency_stop_manager = emergency_stop_manager
self.host = host
self.port = port
# Create FastAPI app
self.app = FastAPI(
title="Calejo Control API",
version="2.0",
description="REST API for Calejo Control Adapter with safety framework",
docs_url="/docs",
redoc_url="/redoc"
)
# Add CORS middleware
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, restrict to specific origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self._setup_routes()
def _setup_routes(self):
"""Setup all API routes."""
@self.app.get("/", summary="API Root", tags=["General"])
async def root():
"""API root endpoint."""
return {
"name": "Calejo Control API",
"version": "2.0",
"status": "operational",
"authentication_required": True
}
@self.app.get("/health", summary="Health Check", tags=["General"])
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"timestamp": datetime.now().isoformat()
}
# Authentication endpoints (no authentication required)
@self.app.post(
"/api/v1/auth/login",
summary="User Login",
tags=["Authentication"],
response_model=LoginResponse
)
async def login(
request: LoginRequest,
security_manager: SecurityManager = Depends(get_security_manager)
):
"""Authenticate user and return JWT token."""
token = security_manager.authenticate(request.username, request.password)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password"
)
# Get user permissions
token_data = security_manager.verify_access_token(token)
permissions = security_manager.get_user_permissions(token_data)
return LoginResponse(
access_token=token,
token_type="bearer",
expires_in=60, # 60 minutes
user_id=token_data.user_id,
username=token_data.username,
role=token_data.role.value,
permissions=permissions
)
@self.app.get(
"/api/v1/auth/me",
summary="Get Current User Info",
tags=["Authentication"],
response_model=UserInfoResponse
)
async def get_current_user_info(
token_data: TokenData = Depends(get_current_user),
security_manager: SecurityManager = Depends(get_security_manager)
):
"""Get information about the currently authenticated user."""
# Get user from auth manager
user = security_manager.auth_manager.users.get(token_data.user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
permissions = security_manager.get_user_permissions(token_data)
return UserInfoResponse(
user_id=user.user_id,
username=user.username,
email=user.email,
role=user.role.value,
permissions=permissions,
last_login=user.last_login.isoformat() if user.last_login else None
)
@self.app.get(
"/api/v1/setpoints",
summary="Get All Setpoints",
tags=["Setpoints"],
response_model=Dict[str, Dict[str, Optional[float]]]
)
async def get_all_setpoints(
token_data: TokenData = Depends(require_permission("read_pump_status"))
):
"""
Get current setpoints for all pumps.
Returns dictionary mapping station_id -> pump_id -> setpoint_hz
Requires permission: read_pump_status
"""
try:
setpoints = self.setpoint_manager.get_all_current_setpoints()
return setpoints
except Exception as e:
logger.error("failed_to_get_setpoints", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve setpoints"
)
@self.app.get(
"/api/v1/setpoints/{station_id}/{pump_id}",
summary="Get Setpoint for Specific Pump",
tags=["Setpoints"],
response_model=SetpointResponse
)
async def get_pump_setpoint(
station_id: str,
pump_id: str,
token_data: TokenData = Depends(require_permission("read_pump_status")),
security_manager: SecurityManager = Depends(get_security_manager)
):
"""
Get current setpoint for a specific pump.
Requires permission: read_pump_status
"""
try:
# Check if user can access this station
if not security_manager.can_access_station(token_data, station_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to station {station_id}"
)
setpoint = self.setpoint_manager.get_current_setpoint(station_id, pump_id)
# Get pump info for control type
pump_info = self.setpoint_manager.discovery.get_pump(station_id, pump_id)
control_type = pump_info['control_type'] if pump_info else "UNKNOWN"
# Determine safety status
safety_status = "normal"
if self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id):
safety_status = "emergency_stop"
elif self.setpoint_manager.watchdog.is_failsafe_active(station_id, pump_id):
safety_status = "failsafe"
return SetpointResponse(
station_id=station_id,
pump_id=pump_id,
setpoint_hz=setpoint,
control_type=control_type,
safety_status=safety_status,
timestamp=datetime.now().isoformat()
)
except HTTPException:
raise
except Exception as e:
logger.error(
"failed_to_get_pump_setpoint",
station_id=station_id,
pump_id=pump_id,
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve setpoint for {station_id}/{pump_id}"
)
@self.app.put(
"/api/v1/setpoints/{station_id}/{pump_id}",
summary="Update Setpoint for Specific Pump",
tags=["Setpoints"]
)
async def update_pump_setpoint(
station_id: str,
pump_id: str,
request: SetpointUpdateRequest,
token_data: TokenData = Depends(require_permission("write_pump_setpoint")),
security_manager: SecurityManager = Depends(get_security_manager)
):
"""
Update setpoint for a specific pump.
Requires permission: write_pump_setpoint
"""
try:
# Check if user can access this station
if not security_manager.can_access_station(token_data, station_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to station {station_id}"
)
# Validate setpoint range
if request.setpoint_hz < 0 or request.setpoint_hz > 50:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Setpoint must be between 0 and 50 Hz"
)
# Check if pump is in emergency stop
if self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id):
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="Cannot update setpoint - pump is in emergency stop"
)
# Update setpoint
success = self.setpoint_manager.update_setpoint(
station_id=station_id,
pump_id=pump_id,
setpoint_hz=request.setpoint_hz,
user_id=token_data.username,
reason=request.reason or "Manual update via REST API"
)
if success:
return {
"status": "setpoint_updated",
"station_id": station_id,
"pump_id": pump_id,
"setpoint_hz": request.setpoint_hz,
"updated_by": token_data.username,
"reason": request.reason,
"timestamp": datetime.now().isoformat()
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update setpoint"
)
except HTTPException:
raise
except Exception as e:
logger.error(
"failed_to_update_pump_setpoint",
station_id=station_id,
pump_id=pump_id,
error=str(e)
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update setpoint for {station_id}/{pump_id}"
)
@self.app.post(
"/api/v1/emergency-stop",
summary="Trigger Emergency Stop",
tags=["Emergency Stop"],
status_code=status.HTTP_201_CREATED
)
async def trigger_emergency_stop(
request: EmergencyStopRequest,
token_data: TokenData = Depends(require_permission("emergency_stop")),
security_manager: SecurityManager = Depends(get_security_manager)
):
"""
Trigger emergency stop ("big red button").
Scope:
- If station_id and pump_id provided: Stop single pump
- If station_id only: Stop all pumps at station
- If neither: Stop ALL pumps system-wide
Requires permission: emergency_stop
"""
try:
# Check station access if station_id is provided
if request.station_id and not security_manager.can_access_station(token_data, request.station_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied to station {request.station_id}"
)
# Use authenticated user as triggered_by
triggered_by = token_data.username
if request.station_id and request.pump_id:
# Single pump stop
result = self.emergency_stop_manager.emergency_stop_pump(
station_id=request.station_id,
pump_id=request.pump_id,
reason=request.reason,
user_id=triggered_by
)
scope = f"pump {request.station_id}/{request.pump_id}"
elif request.station_id:
# Station-wide stop
result = self.emergency_stop_manager.emergency_stop_station(
station_id=request.station_id,
reason=request.reason,
user_id=triggered_by
)
scope = f"station {request.station_id}"
else:
# System-wide stop
result = self.emergency_stop_manager.emergency_stop_system(
reason=request.reason,
user_id=triggered_by
)
scope = "system-wide"
if result:
return {
"status": "emergency_stop_triggered",
"scope": scope,
"reason": request.reason,
"triggered_by": triggered_by,
"timestamp": datetime.now().isoformat()
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to trigger emergency stop"
)
except HTTPException:
raise
except Exception as e:
logger.error("failed_to_trigger_emergency_stop", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to trigger emergency stop"
)
@self.app.post(
"/api/v1/emergency-stop/clear",
summary="Clear Emergency Stop",
tags=["Emergency Stop"]
)
async def clear_emergency_stop(
request: EmergencyStopClearRequest,
token_data: TokenData = Depends(require_permission("clear_emergency_stop"))
):
"""
Clear all active emergency stops.
Requires permission: clear_emergency_stop
"""
try:
# Use authenticated user as cleared_by
cleared_by = token_data.username
# Clear system-wide emergency stop
self.emergency_stop_manager.clear_emergency_stop_system(
reason=request.notes,
user_id=cleared_by
)
return {
"status": "emergency_stop_cleared",
"cleared_by": cleared_by,
"notes": request.notes,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error("failed_to_clear_emergency_stop", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to clear emergency stop"
)
@self.app.get(
"/api/v1/emergency-stop/status",
summary="Get Emergency Stop Status",
tags=["Emergency Stop"]
)
async def get_emergency_stop_status(
token_data: TokenData = Depends(require_permission("read_safety_status"))
):
"""
Check if any emergency stops are active.
Requires permission: read_safety_status
"""
try:
# Check system-wide emergency stop
system_stop = self.emergency_stop_manager.system_emergency_stop
# Count station and pump stops
station_stops = len(self.emergency_stop_manager.emergency_stop_stations)
pump_stops = len(self.emergency_stop_manager.emergency_stop_pumps)
return {
"system_emergency_stop": system_stop,
"station_emergency_stops": station_stops,
"pump_emergency_stops": pump_stops,
"any_active": system_stop or station_stops > 0 or pump_stops > 0,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error("failed_to_get_emergency_stop_status", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve emergency stop status"
)
@self.app.get(
"/api/v1/security/status",
summary="Get Security Status",
tags=["Security"]
)
async def get_security_status(
token_data: TokenData = Depends(require_permission("read_security_status"))
):
"""
Get current security status and configuration.
Requires permission: read_security_status
"""
try:
security_manager = get_security_manager()
tls_manager = get_tls_manager()
# Get security status
auth_status = security_manager.get_security_status()
tls_status = tls_manager.get_tls_status()
return {
"authentication": auth_status,
"tls": tls_status,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error("failed_to_get_security_status", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve security status"
)
async def start(self):
"""Start the REST API server."""
import uvicorn
# Get TLS configuration
tls_manager = get_tls_manager()
ssl_config = tls_manager.get_rest_api_ssl_config()
logger.info(
"rest_api_server_starting",
host=self.host,
port=self.port,
tls_enabled=ssl_config is not None
)
config_kwargs = {
"app": self.app,
"host": self.host,
"port": self.port,
"log_level": "info"
}
# Add SSL configuration if available
if ssl_config:
certfile, keyfile = ssl_config
config_kwargs.update({
"ssl_certfile": certfile,
"ssl_keyfile": keyfile
})
config = uvicorn.Config(**config_kwargs)
server = uvicorn.Server(config)
await server.serve()
async def stop(self):
"""Stop the REST API server."""
logger.info("rest_api_server_stopping")