Spaces:
Paused
Paused
File size: 10,630 Bytes
5e0b58b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | """HTTP glue for the dashboard control service.
This module provides HTTP integration for the control service, keeping the
control plane separate from the main server implementation. This separation
allows the control logic to be tested without opening sockets and keeps
JSON validation centralized.
The module provides:
1. Safe JSON body reading with size limits
2. Control request execution with proper HTTP status codes
3. Error handling and logging
4. Content-Type validation for JSON requests
"""
from __future__ import annotations
import json
import logging
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler
from src.dashboard.models import JSONValue
from .control_service import DashboardControlService
logger = logging.getLogger(__name__)
# Maximum request body size for control endpoints (16KB)
_MAX_CONTROL_BODY_BYTES = 16 * 1024
# Allowed Content-Types for control requests
_ALLOWED_CONTENT_TYPES = {"application/json", "application/json; charset=utf-8"}
# HTTP 413 Payload Too Large (not available in all Python versions)
_HTTP_413_PAYLOAD_TOO_LARGE = 413
# ============================================================================
# Custom Exceptions
# ============================================================================
class ControlHTTPError(Exception):
"""Base exception for HTTP control layer errors."""
def __init__(self, message: str, status: int = HTTPStatus.BAD_REQUEST) -> None:
self.message = message
self.status = status
super().__init__(message)
class ContentTypeError(ControlHTTPError):
"""Raised when the Content-Type header is invalid."""
def __init__(self, message: str = "Content-Type must be application/json") -> None:
super().__init__(message, HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
class BodyTooLargeError(ControlHTTPError):
"""Raised when the request body exceeds the size limit."""
def __init__(self, max_size: int = _MAX_CONTROL_BODY_BYTES) -> None:
super().__init__(
f"Control request body too large (max {max_size} bytes)",
_HTTP_413_PAYLOAD_TOO_LARGE,
)
# ============================================================================
# JSON Body Reader
# ============================================================================
def read_json_body(handler: BaseHTTPRequestHandler) -> object:
"""Read one bounded JSON request body.
This function handles all the HTTP-specific details of reading and parsing
a JSON request body, including size limits and content-type validation.
Args:
handler: The HTTP request handler instance.
Returns:
The parsed JSON object (typically a dict or list).
Raises:
ContentTypeError: If the Content-Type header is invalid.
BodyTooLargeError: If the request body exceeds the size limit.
ValueError: If the Content-Length header is missing or invalid,
or if the body is not valid JSON.
"""
# Validate Content-Type
content_type = handler.headers.get("Content-Type", "")
if not content_type or content_type not in _ALLOWED_CONTENT_TYPES:
if content_type and content_type.startswith("application/json"):
# Allow slight variations (e.g., with charset)
pass
else:
raise ContentTypeError(f"Invalid Content-Type: {content_type}")
# Get and validate Content-Length
raw_length = handler.headers.get("Content-Length")
if raw_length is None:
raise ValueError("Content-Length header is required.")
try:
length = int(raw_length)
except ValueError as exc:
raise ValueError("Invalid Content-Length header.") from exc
if length < 0:
raise ValueError("Content-Length cannot be negative.")
if length > _MAX_CONTROL_BODY_BYTES:
raise BodyTooLargeError()
# Read and parse the body
raw = handler.rfile.read(length)
if not raw:
return {}
try:
return json.loads(raw.decode("utf-8"))
except UnicodeDecodeError as exc:
raise ValueError("Request body is not valid UTF-8.") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON request body: {exc.msg}") from exc
def safe_read_json_body(
handler: BaseHTTPRequestHandler,
) -> tuple[object | None, str | None]:
"""Read a JSON body with safe error handling.
This is a convenience wrapper that catches all exceptions and returns
a tuple of (body, error_message) instead of raising.
Args:
handler: The HTTP request handler instance.
Returns:
A tuple of (parsed_body, error_message). If successful, error_message is None.
If an error occurs, parsed_body is None and error_message contains the error.
"""
try:
return read_json_body(handler), None
except ContentTypeError as e:
return None, e.message
except BodyTooLargeError as e:
return None, e.message
except ValueError as e:
return None, str(e)
except Exception as e:
logger.warning(f"Unexpected error reading JSON body: {e}")
return None, f"Internal error reading request body: {e}"
# ============================================================================
# Control Request Executor
# ============================================================================
def execute_control_request(
handler: BaseHTTPRequestHandler,
service: DashboardControlService,
) -> tuple[int, dict[str, JSONValue]]:
"""Read, validate and execute a control request.
This function handles the complete request lifecycle:
1. Read the JSON body
2. Execute the control command via the service
3. Return the appropriate HTTP status and response body
Args:
handler: The HTTP request handler instance.
service: The control service instance.
Returns:
A tuple of (HTTP_status_code, response_body_dict).
Example:
>>> status, body = execute_control_request(handler, service)
>>> handler.send_response(status)
>>> handler.send_header("Content-Type", "application/json")
>>> handler.end_headers()
>>> handler.wfile.write(json.dumps(body).encode())
"""
try:
body = read_json_body(handler)
except ContentTypeError as e:
return e.status, {"ok": False, "error": e.message}
except BodyTooLargeError as e:
return e.status, {"ok": False, "error": e.message}
except ValueError as e:
return HTTPStatus.BAD_REQUEST, {"ok": False, "error": str(e)}
except Exception as e:
logger.warning(f"Unexpected error reading control request: {e}")
return HTTPStatus.INTERNAL_SERVER_ERROR, {
"ok": False,
"error": "Internal server error",
}
# Execute the command
try:
response = service.execute(body)
return response.status, response.payload
except Exception as e:
logger.error(f"Control execution failed: {e}")
return HTTPStatus.INTERNAL_SERVER_ERROR, {
"ok": False,
"error": "Internal server error",
}
def execute_control_request_with_logging(
handler: BaseHTTPRequestHandler,
service: DashboardControlService,
log_prefix: str = "Control request",
) -> tuple[int, dict[str, JSONValue]]:
"""Execute a control request with logging.
This is a convenience wrapper that logs the request and response details.
Args:
handler: The HTTP request handler instance.
service: The control service instance.
log_prefix: Prefix for log messages.
Returns:
A tuple of (HTTP_status_code, response_body_dict).
"""
# Log the request
method = handler.command
path = handler.path
logger.info(f"{log_prefix}: {method} {path}")
status, body = execute_control_request(handler, service)
# Log the response
ok = body.get("ok", False)
logger.debug(f"{log_prefix} response: status={status}, ok={ok}")
return status, body
# ============================================================================
# Integration with server.py
# ============================================================================
def handle_control_post(
handler: BaseHTTPRequestHandler,
service: DashboardControlService,
) -> None:
"""Handle a POST request to the control endpoint.
This function reads the request body, executes the command, and sends
the response directly to the client.
Args:
handler: The HTTP request handler instance.
service: The control service instance.
"""
status, body = execute_control_request(handler, service)
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
encoded = json.dumps(body, separators=(",", ":")).encode("utf-8")
handler.wfile.write(encoded)
def handle_control_get(
handler: BaseHTTPRequestHandler,
service: DashboardControlService,
) -> None:
"""Handle a GET request to the control endpoint (status).
Args:
handler: The HTTP request handler instance.
service: The control service instance.
"""
try:
state = service.state()
body: dict[str, JSONValue] = {"ok": True, "state": state}
status = HTTPStatus.OK
except AttributeError as e:
logger.error(f"Control status failed (AttributeError): {e}")
body = {"ok": False, "error": f"Control status failed: {e}"}
status = HTTPStatus.INTERNAL_SERVER_ERROR
except Exception as e:
logger.error(f"Control status failed: {e}")
body = {"ok": False, "error": f"Failed to get control status: {e}"}
status = HTTPStatus.INTERNAL_SERVER_ERROR
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
encoded = json.dumps(body, separators=(",", ":")).encode("utf-8")
handler.wfile.write(encoded)
# ============================================================================
# Module Exports
# ============================================================================
__all__ = [
"_MAX_CONTROL_BODY_BYTES",
"BodyTooLargeError",
"ContentTypeError",
"ControlHTTPError",
"execute_control_request",
"execute_control_request_with_logging",
"handle_control_get",
"handle_control_post",
"read_json_body",
"safe_read_json_body",
]
|