From 0b4eb9202ea0fdddfcbd2b213459f630c4e40654 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Wed, 9 Jul 2025 15:47:49 +0700 Subject: [PATCH] clan-app: Better http architecture --- pkgs/clan-app/clan_app/__main__.py | 2 +- pkgs/clan-app/clan_app/api/api_bridge.py | 16 +- .../api/middleware/argument_parsing.py | 4 +- .../clan_app/api/middleware/logging.py | 6 +- .../api/middleware/method_execution.py | 8 +- pkgs/clan-app/clan_app/app.py | 48 ++-- .../clan_app/deps/http/http_bridge.py | 189 ++++++++++++-- .../clan_app/deps/http/http_server.py | 231 ++++-------------- .../clan_app/deps/http/test_http_api.py | 165 ++++++------- .../clan_app/deps/webview/webview_bridge.py | 6 +- pkgs/clan-app/shell.nix | 2 + 11 files changed, 339 insertions(+), 338 deletions(-) diff --git a/pkgs/clan-app/clan_app/__main__.py b/pkgs/clan-app/clan_app/__main__.py index daf509ab7..c6029ec80 100644 --- a/pkgs/clan-app/clan_app/__main__.py +++ b/pkgs/clan-app/clan_app/__main__.py @@ -1,6 +1,6 @@ import sys -from . import main +from clan_app import main if __name__ == "__main__": sys.exit(main()) diff --git a/pkgs/clan-app/clan_app/api/api_bridge.py b/pkgs/clan-app/clan_app/api/api_bridge.py index 94e310e53..89a4c8e8e 100644 --- a/pkgs/clan-app/clan_app/api/api_bridge.py +++ b/pkgs/clan-app/clan_app/api/api_bridge.py @@ -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) diff --git a/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py b/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py index b0eb5781c..107271f91 100644 --- a/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py +++ b/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py @@ -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], ) diff --git a/pkgs/clan-app/clan_app/api/middleware/logging.py b/pkgs/clan-app/clan_app/api/middleware/logging.py index 6f066c85f..ae10a0343 100644 --- a/pkgs/clan-app/clan_app/api/middleware/logging.py +++ b/pkgs/clan-app/clan_app/api/middleware/logging.py @@ -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], ) diff --git a/pkgs/clan-app/clan_app/api/middleware/method_execution.py b/pkgs/clan-app/clan_app/api/middleware/method_execution.py index 9f7b7b71c..a393932b9 100644 --- a/pkgs/clan-app/clan_app/api/middleware/method_execution.py +++ b/pkgs/clan-app/clan_app/api/middleware/method_execution.py @@ -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], ) diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py index 90b9871cd..dda4012fb 100644 --- a/pkgs/clan-app/clan_app/app.py +++ b/pkgs/clan-app/clan_app/app.py @@ -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 diff --git a/pkgs/clan-app/clan_app/deps/http/http_bridge.py b/pkgs/clan-app/clan_app/deps/http/http_bridge.py index 3c8799823..2158b95c4 100644 --- a/pkgs/clan-app/clan_app/deps/http/http_bridge.py +++ b/pkgs/clan-app/clan_app/deps/http/http_bridge.py @@ -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}") diff --git a/pkgs/clan-app/clan_app/deps/http/http_server.py b/pkgs/clan-app/clan_app/deps/http/http_server.py index e374db5f0..de508cb2e 100644 --- a/pkgs/clan-app/clan_app/deps/http/http_server.py +++ b/pkgs/clan-app/clan_app/deps/http/http_server.py @@ -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() diff --git a/pkgs/clan-app/clan_app/deps/http/test_http_api.py b/pkgs/clan-app/clan_app/deps/http/test_http_api.py index 62ca46436..eb4cb0ed2 100644 --- a/pkgs/clan-app/clan_app/deps/http/test_http_api.py +++ b/pkgs/clan-app/clan_app/deps/http/test_http_api.py @@ -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 diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py index 6c988ab30..a511de14b 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -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 diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 5c03c4b2f..89ff86c57 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -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 = [