clan-app: Better http architecture
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import sys
|
||||
|
||||
from . import main
|
||||
from clan_app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -4,6 +4,8 @@ from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from clan_lib.api import ApiResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .middleware import Middleware
|
||||
|
||||
@@ -15,12 +17,12 @@ class BackendRequest:
|
||||
method_name: str
|
||||
args: dict[str, Any]
|
||||
header: dict[str, Any]
|
||||
op_key: str
|
||||
op_key: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendResponse:
|
||||
body: Any
|
||||
body: ApiResponse
|
||||
header: dict[str, Any]
|
||||
_op_key: str
|
||||
|
||||
@@ -32,7 +34,7 @@ class ApiBridge(ABC):
|
||||
middleware_chain: tuple["Middleware", ...]
|
||||
|
||||
@abstractmethod
|
||||
def send_response(self, response: BackendResponse) -> None:
|
||||
def send_api_response(self, response: BackendResponse) -> None:
|
||||
"""Send response back to the client."""
|
||||
|
||||
def process_request(self, request: BackendRequest) -> None:
|
||||
@@ -55,12 +57,12 @@ class ApiBridge(ABC):
|
||||
middleware.process(context)
|
||||
except Exception as e:
|
||||
# If middleware fails, handle error
|
||||
self.send_error_response(
|
||||
request.op_key, str(e), ["middleware_error"]
|
||||
self.send_api_error_response(
|
||||
request.op_key or "unknown", str(e), ["middleware_error"]
|
||||
)
|
||||
return
|
||||
|
||||
def send_error_response(
|
||||
def send_api_error_response(
|
||||
self, op_key: str, error_message: str, location: list[str]
|
||||
) -> None:
|
||||
"""Send an error response."""
|
||||
@@ -84,4 +86,4 @@ class ApiBridge(ABC):
|
||||
_op_key=op_key,
|
||||
)
|
||||
|
||||
self.send_response(response)
|
||||
self.send_api_response(response)
|
||||
|
||||
@@ -47,8 +47,8 @@ class ArgumentParsingMiddleware(Middleware):
|
||||
log.exception(
|
||||
f"Error while parsing arguments for {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
context.bridge.send_api_error_response(
|
||||
context.request.op_key or "unknown",
|
||||
str(e),
|
||||
["argument_parsing", context.request.method_name],
|
||||
)
|
||||
|
||||
@@ -36,15 +36,15 @@ class LoggingMiddleware(Middleware):
|
||||
)
|
||||
# Create log file
|
||||
log_file = self.log_manager.create_log_file(
|
||||
method, op_key=context.request.op_key, group_path=log_group
|
||||
method, op_key=context.request.op_key or "unknown", group_path=log_group
|
||||
).get_file_path()
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while handling request header of {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
context.bridge.send_api_error_response(
|
||||
context.request.op_key or "unknown",
|
||||
str(e),
|
||||
["header_middleware", context.request.method_name],
|
||||
)
|
||||
|
||||
@@ -26,16 +26,16 @@ class MethodExecutionMiddleware(Middleware):
|
||||
response = BackendResponse(
|
||||
body=result,
|
||||
header={},
|
||||
_op_key=context.request.op_key,
|
||||
_op_key=context.request.op_key or "unknown",
|
||||
)
|
||||
context.bridge.send_response(response)
|
||||
context.bridge.send_api_response(response)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error while handling result of {context.request.method_name}"
|
||||
)
|
||||
context.bridge.send_error_response(
|
||||
context.request.op_key,
|
||||
context.bridge.send_api_error_response(
|
||||
context.request.op_key or "unknown",
|
||||
str(e),
|
||||
["method_execution", context.request.method_name],
|
||||
)
|
||||
|
||||
@@ -65,12 +65,38 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
|
||||
http_server = HttpApiServer(
|
||||
api=API,
|
||||
log_manager=log_manager,
|
||||
host=app_opts.http_host,
|
||||
port=app_opts.http_port,
|
||||
)
|
||||
|
||||
# Add middleware to HTTP server
|
||||
http_server.add_middleware(ArgumentParsingMiddleware(api=API))
|
||||
http_server.add_middleware(LoggingMiddleware(log_manager=log_manager))
|
||||
http_server.add_middleware(MethodExecutionMiddleware(api=API))
|
||||
|
||||
# Start the server (bridge will be created automatically)
|
||||
http_server.start()
|
||||
|
||||
# HTTP-only mode - keep the server running
|
||||
log.info("HTTP API server running...")
|
||||
log.info(
|
||||
f"Available API methods at: http://{app_opts.http_host}:{app_opts.http_port}/api/methods"
|
||||
)
|
||||
log.info(
|
||||
f"Example request: curl -X POST http://{app_opts.http_host}:{app_opts.http_port}/api/v1/list_log_days"
|
||||
)
|
||||
log.info("Press Ctrl+C to stop the server")
|
||||
try:
|
||||
# Keep the main thread alive
|
||||
import time
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Shutting down HTTP API server...")
|
||||
if http_server:
|
||||
http_server.stop()
|
||||
|
||||
# Create webview if not running in HTTP-only mode
|
||||
if not app_opts.http_api:
|
||||
webview = Webview(
|
||||
@@ -91,25 +117,5 @@ def app_run(app_opts: ClanAppOptions) -> int:
|
||||
webview.bind_jsonschema_api(API, log_manager=log_manager)
|
||||
webview.navigate(content_uri)
|
||||
webview.run()
|
||||
else:
|
||||
# HTTP-only mode - keep the server running
|
||||
log.info("HTTP API server running...")
|
||||
log.info(
|
||||
f"Available API methods at: http://{app_opts.http_host}:{app_opts.http_port}/api/methods"
|
||||
)
|
||||
log.info(
|
||||
f"Example request: curl -X POST http://{app_opts.http_host}:{app_opts.http_port}/api/call/list_log_days"
|
||||
)
|
||||
log.info("Press Ctrl+C to stop the server")
|
||||
try:
|
||||
# Keep the main thread alive
|
||||
import time
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Shutting down HTTP API server...")
|
||||
if http_server:
|
||||
http_server.stop()
|
||||
|
||||
return 0
|
||||
|
||||
@@ -1,46 +1,176 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from clan_lib.api import dataclass_to_dict
|
||||
from clan_lib.api import MethodRegistry, SuccessDataClass, dataclass_to_dict
|
||||
from clan_lib.api.tasks import WebThread
|
||||
from clan_lib.async_run import set_should_cancel
|
||||
|
||||
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from clan_app.api.middleware import Middleware
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpBridge(ApiBridge):
|
||||
"""HTTP-specific implementation of the API bridge."""
|
||||
class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
|
||||
"""HTTP-specific implementation of the API bridge that handles HTTP requests directly.
|
||||
|
||||
threads: dict[str, WebThread] = field(default_factory=dict)
|
||||
response_handler: "Callable[[BackendResponse], None] | None" = None
|
||||
This bridge combines the API bridge functionality with HTTP request handling.
|
||||
"""
|
||||
|
||||
def send_response(self, response: BackendResponse) -> None:
|
||||
"""Send response back to the HTTP client."""
|
||||
if self.response_handler:
|
||||
self.response_handler(response)
|
||||
else:
|
||||
# Default behavior - just log the response
|
||||
serialized = json.dumps(
|
||||
dataclass_to_dict(response), indent=4, ensure_ascii=False
|
||||
def __init__(
|
||||
self,
|
||||
api: MethodRegistry,
|
||||
middleware_chain: tuple["Middleware", ...],
|
||||
request: Any,
|
||||
client_address: Any,
|
||||
server: Any,
|
||||
) -> None:
|
||||
# Initialize the API bridge fields
|
||||
self.api = api
|
||||
self.middleware_chain = middleware_chain
|
||||
self.threads: dict[str, WebThread] = {}
|
||||
self._current_response: BackendResponse | None = None
|
||||
|
||||
# Initialize the HTTP handler
|
||||
super(BaseHTTPRequestHandler, self).__init__(request, client_address, server)
|
||||
|
||||
def send_api_response(self, response: BackendResponse) -> None:
|
||||
"""Send HTTP response directly to the client."""
|
||||
self._current_response = response
|
||||
|
||||
# Send HTTP response
|
||||
self.send_response_only(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
# Write response data
|
||||
response_data = json.dumps(
|
||||
dataclass_to_dict(response), indent=2, ensure_ascii=False
|
||||
)
|
||||
self.wfile.write(response_data.encode("utf-8"))
|
||||
|
||||
# Log the response for debugging
|
||||
log.debug(f"HTTP response for {response._op_key}: {response_data}") # noqa: SLF001
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
"""Handle CORS preflight requests."""
|
||||
self.send_response_only(200)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
"""Handle GET requests."""
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
|
||||
if path == "/":
|
||||
response = BackendResponse(
|
||||
body=SuccessDataClass(
|
||||
op_key="info",
|
||||
status="success",
|
||||
data={"message": "Clan API Server", "version": "1.0.0"},
|
||||
),
|
||||
header={},
|
||||
_op_key="info",
|
||||
)
|
||||
log.debug(f"HTTP response: {serialized}")
|
||||
self.send_api_response(response)
|
||||
elif path == "/api/methods":
|
||||
response = BackendResponse(
|
||||
body=SuccessDataClass(
|
||||
op_key="methods",
|
||||
status="success",
|
||||
data={"methods": list(self.api.functions.keys())},
|
||||
),
|
||||
header={},
|
||||
_op_key="methods",
|
||||
)
|
||||
self.send_api_response(response)
|
||||
else:
|
||||
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
|
||||
|
||||
def handle_http_request(
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
"""Handle POST requests."""
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
|
||||
# Check if this is an API call
|
||||
if not path.startswith("/api/v1/"):
|
||||
self.send_api_error_response("post", "Not Found", ["http_bridge", "POST"])
|
||||
return
|
||||
|
||||
# Extract method name from path
|
||||
method_name = path[len("/api/v1/") :]
|
||||
|
||||
if not method_name:
|
||||
self.send_api_error_response(
|
||||
"post", "Method name required", ["http_bridge", "POST"]
|
||||
)
|
||||
return
|
||||
|
||||
if method_name not in self.api.functions:
|
||||
self.send_api_error_response(
|
||||
"post",
|
||||
f"Method '{method_name}' not found",
|
||||
["http_bridge", "POST", method_name],
|
||||
)
|
||||
return
|
||||
|
||||
# Read request body
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length > 0:
|
||||
body = self.rfile.read(content_length)
|
||||
request_data = json.loads(body.decode("utf-8"))
|
||||
else:
|
||||
request_data = {}
|
||||
except json.JSONDecodeError:
|
||||
self.send_api_error_response(
|
||||
"post",
|
||||
"Invalid JSON in request body",
|
||||
["http_bridge", "POST", method_name],
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
self.send_api_error_response(
|
||||
"post",
|
||||
f"Error reading request: {e!s}",
|
||||
["http_bridge", "POST", method_name],
|
||||
)
|
||||
return
|
||||
|
||||
# Generate a unique operation key
|
||||
op_key = str(uuid.uuid4())
|
||||
|
||||
# Handle the API request
|
||||
try:
|
||||
self._handle_api_request(method_name, request_data, op_key)
|
||||
except Exception as e:
|
||||
log.exception(f"Error processing API request {method_name}")
|
||||
self.send_api_error_response(
|
||||
op_key,
|
||||
f"Internal server error: {e!s}",
|
||||
["http_bridge", "POST", method_name],
|
||||
)
|
||||
|
||||
def _handle_api_request(
|
||||
self,
|
||||
method_name: str,
|
||||
request_data: dict[str, Any],
|
||||
op_key: str,
|
||||
) -> None:
|
||||
"""Handle an HTTP API request."""
|
||||
"""Handle an API request by processing it through middleware."""
|
||||
try:
|
||||
# Parse the HTTP request format
|
||||
header = request_data.get("header", {})
|
||||
@@ -59,7 +189,8 @@ class HttpBridge(ApiBridge):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.send_error_response(op_key, str(e), ["http_bridge", method_name])
|
||||
# Create error response directly
|
||||
self.send_api_error_response(op_key, str(e), ["http_bridge", method_name])
|
||||
return
|
||||
|
||||
# Process in a separate thread
|
||||
@@ -78,8 +209,16 @@ class HttpBridge(ApiBridge):
|
||||
thread.start()
|
||||
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
|
||||
|
||||
def set_response_handler(
|
||||
self, handler: "Callable[[BackendResponse], None]"
|
||||
) -> None:
|
||||
"""Set a custom response handler for HTTP responses."""
|
||||
self.response_handler = handler
|
||||
# Wait for the thread to complete (this blocks until response is sent)
|
||||
thread.join(timeout=60.0)
|
||||
|
||||
# If thread is still alive, it timed out
|
||||
if thread.is_alive():
|
||||
stop_event.set() # Cancel the thread
|
||||
self.send_api_error_response(
|
||||
op_key, "Request timeout", ["http_bridge", method_name]
|
||||
)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
"""Override default logging to use our logger."""
|
||||
log.info(f"{self.address_string()} - {format % args}")
|
||||
|
||||
@@ -1,229 +1,100 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from http.server import HTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from clan_lib.api import MethodRegistry, dataclass_to_dict
|
||||
from clan_lib.log_manager import LogManager
|
||||
from clan_lib.api import MethodRegistry
|
||||
|
||||
from clan_app.api.api_bridge import BackendResponse
|
||||
from clan_app.api.middleware import (
|
||||
ArgumentParsingMiddleware,
|
||||
LoggingMiddleware,
|
||||
MethodExecutionMiddleware,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from clan_app.api.middleware import Middleware
|
||||
|
||||
from .http_bridge import HttpBridge
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClanAPIRequestHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler for Clan API."""
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, api: MethodRegistry, bridge: HttpBridge, **kwargs: Any
|
||||
) -> None:
|
||||
self.api = api
|
||||
self.bridge = bridge
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _send_json_response(self, data: dict[str, Any], status_code: int = 200) -> None:
|
||||
"""Send a JSON response."""
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
response_data = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
self.wfile.write(response_data.encode("utf-8"))
|
||||
|
||||
def _send_error_response(self, message: str, status_code: int = 400) -> None:
|
||||
"""Send an error response."""
|
||||
self._send_json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": message,
|
||||
"status_code": status_code,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
"""Handle CORS preflight requests."""
|
||||
self.send_response(200)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
"""Handle GET requests."""
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
|
||||
if path == "/":
|
||||
self._send_json_response(
|
||||
{
|
||||
"message": "Clan API Server",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
)
|
||||
elif path == "/api/methods":
|
||||
self._send_json_response({"methods": list(self.api.functions.keys())})
|
||||
else:
|
||||
self._send_error_response("Not Found", 404)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
"""Handle POST requests."""
|
||||
parsed_url = urlparse(self.path)
|
||||
path = parsed_url.path
|
||||
|
||||
# Check if this is an API call
|
||||
if not path.startswith("/api/call/"):
|
||||
self._send_error_response("Not Found", 404)
|
||||
return
|
||||
|
||||
# Extract method name from path
|
||||
method_name = path[len("/api/call/") :]
|
||||
|
||||
if not method_name:
|
||||
self._send_error_response("Method name required", 400)
|
||||
return
|
||||
|
||||
if method_name not in self.api.functions:
|
||||
self._send_error_response(f"Method '{method_name}' not found", 404)
|
||||
return
|
||||
|
||||
# Read request body
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length > 0:
|
||||
body = self.rfile.read(content_length)
|
||||
request_data = json.loads(body.decode("utf-8"))
|
||||
else:
|
||||
request_data = {}
|
||||
except json.JSONDecodeError:
|
||||
self._send_error_response("Invalid JSON in request body", 400)
|
||||
return
|
||||
except Exception as e:
|
||||
self._send_error_response(f"Error reading request: {e!s}", 400)
|
||||
return
|
||||
|
||||
# Generate a unique operation key
|
||||
op_key = str(uuid.uuid4())
|
||||
|
||||
# Store the response for this request
|
||||
response_data: dict[str, Any] = {}
|
||||
response_event = threading.Event()
|
||||
|
||||
def response_handler(response: BackendResponse) -> None:
|
||||
response_data["response"] = response
|
||||
response_event.set()
|
||||
|
||||
# Set the response handler
|
||||
self.bridge.set_response_handler(response_handler)
|
||||
|
||||
# Process the request
|
||||
self.bridge.handle_http_request(method_name, request_data, op_key)
|
||||
|
||||
# Wait for the response (with timeout)
|
||||
if not response_event.wait(timeout=60): # 60 second timeout
|
||||
self._send_error_response("Request timeout", 408)
|
||||
return
|
||||
|
||||
# Get the response
|
||||
response = response_data["response"]
|
||||
|
||||
# Convert to JSON-serializable format
|
||||
if response.body is not None:
|
||||
response_dict = dataclass_to_dict(response)
|
||||
else:
|
||||
response_dict = None
|
||||
|
||||
self._send_json_response(
|
||||
response_dict if response_dict is not None else {},
|
||||
)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
"""Override default logging to use our logger."""
|
||||
log.info(f"{self.address_string()} - {format % args}")
|
||||
|
||||
|
||||
class HttpApiServer:
|
||||
"""HTTP server for the Clan API using Python's built-in HTTP server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api: MethodRegistry,
|
||||
log_manager: LogManager,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8080,
|
||||
) -> None:
|
||||
self.api = api
|
||||
self.log_manager = log_manager
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.server: HTTPServer | None = None
|
||||
self.server_thread: threading.Thread | None = None
|
||||
self.bridge = self._create_bridge()
|
||||
self._server: HTTPServer | None = None
|
||||
self._server_thread: threading.Thread | None = None
|
||||
# Bridge is now the request handler itself, no separate instance needed
|
||||
self._middleware: list[Middleware] = []
|
||||
|
||||
def _create_bridge(self) -> HttpBridge:
|
||||
"""Create HTTP bridge with middleware."""
|
||||
return HttpBridge(
|
||||
middleware_chain=(
|
||||
ArgumentParsingMiddleware(api=self.api),
|
||||
LoggingMiddleware(log_manager=self.log_manager),
|
||||
MethodExecutionMiddleware(api=self.api),
|
||||
)
|
||||
)
|
||||
def add_middleware(self, middleware: "Middleware") -> None:
|
||||
"""Add middleware to the middleware chain."""
|
||||
if self._server is not None:
|
||||
msg = "Cannot add middleware after server is started"
|
||||
raise RuntimeError(msg)
|
||||
self._middleware.append(middleware)
|
||||
|
||||
def _create_request_handler(self) -> type[ClanAPIRequestHandler]:
|
||||
@property
|
||||
def server(self) -> HTTPServer | None:
|
||||
"""Get the HTTP server instance."""
|
||||
return self._server
|
||||
|
||||
@property
|
||||
def server_thread(self) -> threading.Thread | None:
|
||||
"""Get the server thread."""
|
||||
return self._server_thread
|
||||
|
||||
def _create_request_handler(self) -> type[HttpBridge]:
|
||||
"""Create a request handler class with injected dependencies."""
|
||||
api = self.api
|
||||
bridge = self.bridge
|
||||
middleware_chain = tuple(self._middleware)
|
||||
|
||||
class RequestHandler(ClanAPIRequestHandler):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, api=api, bridge=bridge, **kwargs)
|
||||
class RequestHandler(HttpBridge):
|
||||
def __init__(self, request: Any, client_address: Any, server: Any) -> None:
|
||||
super().__init__(
|
||||
api=api,
|
||||
middleware_chain=middleware_chain,
|
||||
request=request,
|
||||
client_address=client_address,
|
||||
server=server,
|
||||
)
|
||||
|
||||
return RequestHandler
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the HTTP server in a separate thread."""
|
||||
if self.server_thread is not None:
|
||||
if self._server_thread is not None:
|
||||
log.warning("HTTP server is already running")
|
||||
return
|
||||
|
||||
# Create the server
|
||||
handler_class = self._create_request_handler()
|
||||
self.server = HTTPServer((self.host, self.port), handler_class)
|
||||
self._server = HTTPServer((self.host, self.port), handler_class)
|
||||
|
||||
def run_server() -> None:
|
||||
if self.server:
|
||||
self.server.serve_forever()
|
||||
if self._server:
|
||||
log.info(f"HTTP API server started on http://{self.host}:{self.port}")
|
||||
self._server.serve_forever()
|
||||
|
||||
self.server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
self.server_thread.start()
|
||||
self._server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
self._server_thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the HTTP server."""
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.server = None
|
||||
if self._server:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._server = None
|
||||
|
||||
if self.server_thread:
|
||||
self.server_thread.join(timeout=5)
|
||||
self.server_thread = None
|
||||
if self._server_thread:
|
||||
self._server_thread.join(timeout=5)
|
||||
self._server_thread = None
|
||||
|
||||
log.info("HTTP API server stopped")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the server is running."""
|
||||
return self.server_thread is not None and self.server_thread.is_alive()
|
||||
return self._server_thread is not None and self._server_thread.is_alive()
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
"""Tests for HTTP API components."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import Mock
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import pytest
|
||||
from clan_lib.api import MethodRegistry
|
||||
from clan_lib.log_manager import LogManager
|
||||
|
||||
from clan_app.api.api_bridge import BackendResponse
|
||||
from clan_app.api.middleware import (
|
||||
ArgumentParsingMiddleware,
|
||||
LoggingMiddleware,
|
||||
MethodExecutionMiddleware,
|
||||
)
|
||||
from clan_app.deps.http.http_bridge import HttpBridge
|
||||
from clan_app.deps.http.http_server import HttpApiServer
|
||||
|
||||
|
||||
@@ -48,93 +44,58 @@ def mock_log_manager() -> Mock:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_bridge(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpBridge:
|
||||
"""Create HTTP bridge with mock dependencies."""
|
||||
return HttpBridge(
|
||||
middleware_chain=(
|
||||
ArgumentParsingMiddleware(api=mock_api),
|
||||
LoggingMiddleware(log_manager=mock_log_manager),
|
||||
MethodExecutionMiddleware(api=mock_api),
|
||||
)
|
||||
def http_bridge(
|
||||
mock_api: MethodRegistry, mock_log_manager: Mock
|
||||
) -> tuple[MethodRegistry, tuple]:
|
||||
"""Create HTTP bridge dependencies for testing."""
|
||||
middleware_chain = (
|
||||
ArgumentParsingMiddleware(api=mock_api),
|
||||
LoggingMiddleware(log_manager=mock_log_manager),
|
||||
MethodExecutionMiddleware(api=mock_api),
|
||||
)
|
||||
return mock_api, middleware_chain
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServer:
|
||||
"""Create HTTP server with mock dependencies."""
|
||||
return HttpApiServer(
|
||||
server = HttpApiServer(
|
||||
api=mock_api,
|
||||
log_manager=mock_log_manager,
|
||||
host="127.0.0.1",
|
||||
port=8081, # Use different port for tests
|
||||
)
|
||||
|
||||
# Add middleware
|
||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||
server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||
|
||||
# Bridge will be created automatically when accessed
|
||||
|
||||
return server
|
||||
|
||||
|
||||
class TestHttpBridge:
|
||||
"""Tests for HttpBridge class."""
|
||||
|
||||
def test_http_bridge_initialization(self, http_bridge: HttpBridge) -> None:
|
||||
def test_http_bridge_initialization(self, http_bridge: tuple) -> None:
|
||||
"""Test HTTP bridge initialization."""
|
||||
assert http_bridge.threads == {}
|
||||
assert http_bridge.response_handler is None
|
||||
# Since HttpBridge is now a request handler, we can't instantiate it directly
|
||||
# We'll test initialization through the server
|
||||
api, middleware_chain = http_bridge
|
||||
assert api is not None
|
||||
assert len(middleware_chain) == 3
|
||||
|
||||
def test_set_response_handler(self, http_bridge: HttpBridge) -> None:
|
||||
"""Test setting response handler."""
|
||||
handler: Mock = Mock()
|
||||
http_bridge.set_response_handler(handler)
|
||||
assert http_bridge.response_handler == handler
|
||||
def test_http_bridge_middleware_setup(self, http_bridge: tuple) -> None:
|
||||
"""Test that middleware is properly set up."""
|
||||
api, middleware_chain = http_bridge
|
||||
|
||||
def test_handle_http_request_success(self, http_bridge: HttpBridge) -> None:
|
||||
"""Test successful HTTP request handling."""
|
||||
# Set up response handler
|
||||
response_received: threading.Event = threading.Event()
|
||||
received_response: dict = {}
|
||||
|
||||
def response_handler(response: BackendResponse) -> None:
|
||||
received_response["response"] = response
|
||||
response_received.set()
|
||||
|
||||
http_bridge.set_response_handler(response_handler)
|
||||
|
||||
# Make request
|
||||
request_data: dict = {"header": {}, "body": {"message": "World"}}
|
||||
|
||||
http_bridge.handle_http_request("test_method", request_data, "test-op-key")
|
||||
|
||||
# Wait for response
|
||||
assert response_received.wait(timeout=5)
|
||||
response = received_response["response"]
|
||||
|
||||
assert response._op_key == "test-op-key" # noqa: SLF001
|
||||
assert response.body.data == {"response": "Hello World!"}
|
||||
|
||||
def test_handle_http_request_with_invalid_header(
|
||||
self, http_bridge: HttpBridge
|
||||
) -> None:
|
||||
"""Test HTTP request with invalid header."""
|
||||
response_received: threading.Event = threading.Event()
|
||||
received_response: dict = {}
|
||||
|
||||
def response_handler(response: BackendResponse) -> None:
|
||||
received_response["response"] = response
|
||||
response_received.set()
|
||||
|
||||
http_bridge.set_response_handler(response_handler)
|
||||
|
||||
# Make request with invalid header
|
||||
request_data: dict = {
|
||||
"header": "invalid_header", # Should be dict
|
||||
"body": {"message": "World"},
|
||||
}
|
||||
|
||||
http_bridge.handle_http_request("test_method", request_data, "test-op-key")
|
||||
|
||||
# Wait for response
|
||||
assert response_received.wait(timeout=5)
|
||||
response = received_response["response"]
|
||||
|
||||
assert response._op_key == "test-op-key" # noqa: SLF001
|
||||
assert response.body.status == "error"
|
||||
# Test that we can create the bridge with middleware
|
||||
# The actual HTTP handling will be tested through the server integration tests
|
||||
assert len(middleware_chain) == 3
|
||||
assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
|
||||
assert isinstance(middleware_chain[1], LoggingMiddleware)
|
||||
assert isinstance(middleware_chain[2], MethodExecutionMiddleware)
|
||||
|
||||
|
||||
class TestHttpApiServer:
|
||||
@@ -172,14 +133,16 @@ class TestHttpApiServer:
|
||||
# Test root endpoint
|
||||
response = urlopen("http://127.0.0.1:8081/")
|
||||
data: dict = json.loads(response.read().decode())
|
||||
assert data["message"] == "Clan API Server"
|
||||
assert data["version"] == "1.0.0"
|
||||
assert data["body"]["status"] == "success"
|
||||
assert data["body"]["data"]["message"] == "Clan API Server"
|
||||
assert data["body"]["data"]["version"] == "1.0.0"
|
||||
|
||||
# Test methods endpoint
|
||||
response = urlopen("http://127.0.0.1:8081/api/methods")
|
||||
data = json.loads(response.read().decode())
|
||||
assert "test_method" in data["methods"]
|
||||
assert "test_method_with_error" in data["methods"]
|
||||
assert data["body"]["status"] == "success"
|
||||
assert "test_method" in data["body"]["data"]["methods"]
|
||||
assert "test_method_with_error" in data["body"]["data"]["methods"]
|
||||
|
||||
# Test API call endpoint
|
||||
request_data: dict = {"header": {}, "body": {"message": "World"}}
|
||||
@@ -191,8 +154,12 @@ class TestHttpApiServer:
|
||||
response = urlopen(req)
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
assert data["success"] is True
|
||||
assert data["data"]["data"] == {"response": "Hello World!"}
|
||||
# Response should be BackendResponse format
|
||||
assert "body" in data
|
||||
assert "header" in data
|
||||
|
||||
assert data["body"]["status"] == "success"
|
||||
assert data["body"]["data"] == {"response": "Hello World!"}
|
||||
|
||||
finally:
|
||||
# Always stop server
|
||||
@@ -206,9 +173,11 @@ class TestHttpApiServer:
|
||||
|
||||
try:
|
||||
# Test 404 error
|
||||
with pytest.raises(HTTPError) as exc_info:
|
||||
urlopen("http://127.0.0.1:8081/nonexistent")
|
||||
assert exc_info.value.code == 404
|
||||
|
||||
res = urlopen("http://127.0.0.1:8081/nonexistent")
|
||||
assert res.status == 200
|
||||
body = json.loads(res.read().decode())["body"]
|
||||
assert body["status"] == "error"
|
||||
|
||||
# Test method not found
|
||||
request_data: dict = {"header": {}, "body": {}}
|
||||
@@ -217,9 +186,11 @@ class TestHttpApiServer:
|
||||
data=json.dumps(request_data).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with pytest.raises(HTTPError) as exc_info:
|
||||
urlopen(req)
|
||||
assert exc_info.value.code == 404
|
||||
|
||||
res = urlopen(req)
|
||||
assert res.status == 200
|
||||
body = json.loads(res.read().decode())["body"]
|
||||
assert body["status"] == "error"
|
||||
|
||||
# Test invalid JSON
|
||||
req = Request(
|
||||
@@ -227,10 +198,11 @@ class TestHttpApiServer:
|
||||
data=b"invalid json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with pytest.raises(HTTPError) as exc_info:
|
||||
urlopen(req)
|
||||
assert exc_info.value.code == 400
|
||||
|
||||
res = urlopen(req)
|
||||
assert res.status == 200
|
||||
body = json.loads(res.read().decode())["body"]
|
||||
assert body["status"] == "error"
|
||||
finally:
|
||||
# Always stop server
|
||||
http_server.stop()
|
||||
@@ -270,11 +242,17 @@ class TestIntegration:
|
||||
"""Test complete request flow from server to bridge to middleware."""
|
||||
server: HttpApiServer = HttpApiServer(
|
||||
api=mock_api,
|
||||
log_manager=mock_log_manager,
|
||||
host="127.0.0.1",
|
||||
port=8082,
|
||||
)
|
||||
|
||||
# Add middleware
|
||||
server.add_middleware(ArgumentParsingMiddleware(api=mock_api))
|
||||
server.add_middleware(LoggingMiddleware(log_manager=mock_log_manager))
|
||||
server.add_middleware(MethodExecutionMiddleware(api=mock_api))
|
||||
|
||||
# Bridge will be created automatically when accessed
|
||||
|
||||
# Start server
|
||||
server.start()
|
||||
time.sleep(0.1) # Give server time to start
|
||||
@@ -293,10 +271,11 @@ class TestIntegration:
|
||||
response = urlopen(req)
|
||||
data: dict = json.loads(response.read().decode())
|
||||
|
||||
# Verify response
|
||||
assert data["success"] is True
|
||||
assert data["data"]["data"] == {"response": "Hello Integration!"}
|
||||
assert "op_key" in data
|
||||
# Verify response in BackendResponse format
|
||||
assert "body" in data
|
||||
assert "header" in data
|
||||
assert data["body"]["status"] == "success"
|
||||
assert data["body"]["data"] == {"response": "Hello Integration!"}
|
||||
|
||||
finally:
|
||||
# Always stop server
|
||||
|
||||
@@ -25,7 +25,7 @@ class WebviewBridge(ApiBridge):
|
||||
webview: "Webview"
|
||||
threads: dict[str, WebThread] = field(default_factory=dict)
|
||||
|
||||
def send_response(self, response: BackendResponse) -> None:
|
||||
def send_api_response(self, response: BackendResponse) -> None:
|
||||
"""Send response back to the webview client."""
|
||||
|
||||
serialized = json.dumps(
|
||||
@@ -79,7 +79,9 @@ class WebviewBridge(ApiBridge):
|
||||
f"Error while handling webview call {method_name} with op_key {op_key}"
|
||||
)
|
||||
log.exception(msg)
|
||||
self.send_error_response(op_key, str(e), ["webview_bridge", method_name])
|
||||
self.send_api_error_response(
|
||||
op_key, str(e), ["webview_bridge", method_name]
|
||||
)
|
||||
return
|
||||
|
||||
# Process in a separate thread
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
clan-ts-api,
|
||||
ps,
|
||||
process-compose,
|
||||
go-swagger,
|
||||
json2ts,
|
||||
playwright-driver,
|
||||
luakit,
|
||||
@@ -42,6 +43,7 @@ mkShell {
|
||||
nativeBuildInputs = clan-app.nativeBuildInputs ++ [
|
||||
ps
|
||||
process-compose
|
||||
go-swagger
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
|
||||
Reference in New Issue
Block a user