clan-app: Better http architecture

This commit is contained in:
Qubasa
2025-07-09 15:47:49 +07:00
parent 1c269d1eaa
commit 4008d2c165
11 changed files with 339 additions and 338 deletions

View File

@@ -1,6 +1,6 @@
import sys import sys
from . import main from clan_app import main
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@@ -4,6 +4,8 @@ from contextlib import ExitStack
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from clan_lib.api import ApiResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from .middleware import Middleware from .middleware import Middleware
@@ -15,12 +17,12 @@ class BackendRequest:
method_name: str method_name: str
args: dict[str, Any] args: dict[str, Any]
header: dict[str, Any] header: dict[str, Any]
op_key: str op_key: str | None
@dataclass(frozen=True) @dataclass(frozen=True)
class BackendResponse: class BackendResponse:
body: Any body: ApiResponse
header: dict[str, Any] header: dict[str, Any]
_op_key: str _op_key: str
@@ -32,7 +34,7 @@ class ApiBridge(ABC):
middleware_chain: tuple["Middleware", ...] middleware_chain: tuple["Middleware", ...]
@abstractmethod @abstractmethod
def send_response(self, response: BackendResponse) -> None: def send_api_response(self, response: BackendResponse) -> None:
"""Send response back to the client.""" """Send response back to the client."""
def process_request(self, request: BackendRequest) -> None: def process_request(self, request: BackendRequest) -> None:
@@ -55,12 +57,12 @@ class ApiBridge(ABC):
middleware.process(context) middleware.process(context)
except Exception as e: except Exception as e:
# If middleware fails, handle error # If middleware fails, handle error
self.send_error_response( self.send_api_error_response(
request.op_key, str(e), ["middleware_error"] request.op_key or "unknown", str(e), ["middleware_error"]
) )
return return
def send_error_response( def send_api_error_response(
self, op_key: str, error_message: str, location: list[str] self, op_key: str, error_message: str, location: list[str]
) -> None: ) -> None:
"""Send an error response.""" """Send an error response."""
@@ -84,4 +86,4 @@ class ApiBridge(ABC):
_op_key=op_key, _op_key=op_key,
) )
self.send_response(response) self.send_api_response(response)

View File

@@ -47,8 +47,8 @@ class ArgumentParsingMiddleware(Middleware):
log.exception( log.exception(
f"Error while parsing arguments for {context.request.method_name}" f"Error while parsing arguments for {context.request.method_name}"
) )
context.bridge.send_error_response( context.bridge.send_api_error_response(
context.request.op_key, context.request.op_key or "unknown",
str(e), str(e),
["argument_parsing", context.request.method_name], ["argument_parsing", context.request.method_name],
) )

View File

@@ -36,15 +36,15 @@ class LoggingMiddleware(Middleware):
) )
# Create log file # Create log file
log_file = self.log_manager.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() ).get_file_path()
except Exception as e: except Exception as e:
log.exception( log.exception(
f"Error while handling request header of {context.request.method_name}" f"Error while handling request header of {context.request.method_name}"
) )
context.bridge.send_error_response( context.bridge.send_api_error_response(
context.request.op_key, context.request.op_key or "unknown",
str(e), str(e),
["header_middleware", context.request.method_name], ["header_middleware", context.request.method_name],
) )

View File

@@ -26,16 +26,16 @@ class MethodExecutionMiddleware(Middleware):
response = BackendResponse( response = BackendResponse(
body=result, body=result,
header={}, 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: except Exception as e:
log.exception( log.exception(
f"Error while handling result of {context.request.method_name}" f"Error while handling result of {context.request.method_name}"
) )
context.bridge.send_error_response( context.bridge.send_api_error_response(
context.request.op_key, context.request.op_key or "unknown",
str(e), str(e),
["method_execution", context.request.method_name], ["method_execution", context.request.method_name],
) )

View File

@@ -65,12 +65,38 @@ def app_run(app_opts: ClanAppOptions) -> int:
http_server = HttpApiServer( http_server = HttpApiServer(
api=API, api=API,
log_manager=log_manager,
host=app_opts.http_host, host=app_opts.http_host,
port=app_opts.http_port, 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_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 # Create webview if not running in HTTP-only mode
if not app_opts.http_api: if not app_opts.http_api:
webview = Webview( webview = Webview(
@@ -91,25 +117,5 @@ def app_run(app_opts: ClanAppOptions) -> int:
webview.bind_jsonschema_api(API, log_manager=log_manager) webview.bind_jsonschema_api(API, log_manager=log_manager)
webview.navigate(content_uri) webview.navigate(content_uri)
webview.run() 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 return 0

View File

@@ -1,46 +1,176 @@
import json import json
import logging import logging
import threading import threading
from dataclasses import dataclass, field import uuid
from http.server import BaseHTTPRequestHandler
from typing import TYPE_CHECKING, Any 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.api.tasks import WebThread
from clan_lib.async_run import set_should_cancel from clan_lib.async_run import set_should_cancel
from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from clan_app.api.middleware import Middleware
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@dataclass class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
class HttpBridge(ApiBridge): """HTTP-specific implementation of the API bridge that handles HTTP requests directly.
"""HTTP-specific implementation of the API bridge."""
threads: dict[str, WebThread] = field(default_factory=dict) This bridge combines the API bridge functionality with HTTP request handling.
response_handler: "Callable[[BackendResponse], None] | None" = None """
def send_response(self, response: BackendResponse) -> None: def __init__(
"""Send response back to the HTTP client.""" self,
if self.response_handler: api: MethodRegistry,
self.response_handler(response) middleware_chain: tuple["Middleware", ...],
else: request: Any,
# Default behavior - just log the response client_address: Any,
serialized = json.dumps( server: Any,
dataclass_to_dict(response), indent=4, ensure_ascii=False ) -> 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
) )
log.debug(f"HTTP response: {serialized}") self.wfile.write(response_data.encode("utf-8"))
def handle_http_request( # 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",
)
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 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, self,
method_name: str, method_name: str,
request_data: dict[str, Any], request_data: dict[str, Any],
op_key: str, op_key: str,
) -> None: ) -> None:
"""Handle an HTTP API request.""" """Handle an API request by processing it through middleware."""
try: try:
# Parse the HTTP request format # Parse the HTTP request format
header = request_data.get("header", {}) header = request_data.get("header", {})
@@ -59,7 +189,8 @@ class HttpBridge(ApiBridge):
) )
except Exception as e: 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 return
# Process in a separate thread # Process in a separate thread
@@ -78,8 +209,16 @@ class HttpBridge(ApiBridge):
thread.start() thread.start()
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event) self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
def set_response_handler( # Wait for the thread to complete (this blocks until response is sent)
self, handler: "Callable[[BackendResponse], None]" thread.join(timeout=60.0)
) -> None:
"""Set a custom response handler for HTTP responses.""" # If thread is still alive, it timed out
self.response_handler = handler 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}")

View File

@@ -1,229 +1,100 @@
import json
import logging import logging
import threading import threading
import uuid from http.server import HTTPServer
from http.server import BaseHTTPRequestHandler, HTTPServer from typing import TYPE_CHECKING, Any
from typing import Any
from urllib.parse import urlparse
from clan_lib.api import MethodRegistry, dataclass_to_dict from clan_lib.api import MethodRegistry
from clan_lib.log_manager import LogManager
from clan_app.api.api_bridge import BackendResponse if TYPE_CHECKING:
from clan_app.api.middleware import ( from clan_app.api.middleware import Middleware
ArgumentParsingMiddleware,
LoggingMiddleware,
MethodExecutionMiddleware,
)
from .http_bridge import HttpBridge from .http_bridge import HttpBridge
log = logging.getLogger(__name__) 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: class HttpApiServer:
"""HTTP server for the Clan API using Python's built-in HTTP server.""" """HTTP server for the Clan API using Python's built-in HTTP server."""
def __init__( def __init__(
self, self,
api: MethodRegistry, api: MethodRegistry,
log_manager: LogManager,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 8080, port: int = 8080,
) -> None: ) -> None:
self.api = api self.api = api
self.log_manager = log_manager
self.host = host self.host = host
self.port = port self.port = port
self.server: HTTPServer | None = None self._server: HTTPServer | None = None
self.server_thread: threading.Thread | None = None self._server_thread: threading.Thread | None = None
self.bridge = self._create_bridge() # Bridge is now the request handler itself, no separate instance needed
self._middleware: list[Middleware] = []
def _create_bridge(self) -> HttpBridge: def add_middleware(self, middleware: "Middleware") -> None:
"""Create HTTP bridge with middleware.""" """Add middleware to the middleware chain."""
return HttpBridge( if self._server is not None:
middleware_chain=( msg = "Cannot add middleware after server is started"
ArgumentParsingMiddleware(api=self.api), raise RuntimeError(msg)
LoggingMiddleware(log_manager=self.log_manager), self._middleware.append(middleware)
MethodExecutionMiddleware(api=self.api),
)
)
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.""" """Create a request handler class with injected dependencies."""
api = self.api api = self.api
bridge = self.bridge middleware_chain = tuple(self._middleware)
class RequestHandler(ClanAPIRequestHandler): class RequestHandler(HttpBridge):
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, request: Any, client_address: Any, server: Any) -> None:
super().__init__(*args, api=api, bridge=bridge, **kwargs) super().__init__(
api=api,
middleware_chain=middleware_chain,
request=request,
client_address=client_address,
server=server,
)
return RequestHandler return RequestHandler
def start(self) -> None: def start(self) -> None:
"""Start the HTTP server in a separate thread.""" """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") log.warning("HTTP server is already running")
return return
# Create the server # Create the server
handler_class = self._create_request_handler() 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: def run_server() -> None:
if self.server: if self._server:
self.server.serve_forever() 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 = threading.Thread(target=run_server, daemon=True)
self.server_thread.start() self._server_thread.start()
def stop(self) -> None: def stop(self) -> None:
"""Stop the HTTP server.""" """Stop the HTTP server."""
if self.server: if self._server:
self.server.shutdown() self._server.shutdown()
self.server.server_close() self._server.server_close()
self.server = None self._server = None
if self.server_thread: if self._server_thread:
self.server_thread.join(timeout=5) self._server_thread.join(timeout=5)
self.server_thread = None self._server_thread = None
log.info("HTTP API server stopped") log.info("HTTP API server stopped")
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check if the server is running.""" """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()

View File

@@ -1,23 +1,19 @@
"""Tests for HTTP API components.""" """Tests for HTTP API components."""
import json import json
import threading
import time import time
from unittest.mock import Mock from unittest.mock import Mock
from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
import pytest import pytest
from clan_lib.api import MethodRegistry from clan_lib.api import MethodRegistry
from clan_lib.log_manager import LogManager from clan_lib.log_manager import LogManager
from clan_app.api.api_bridge import BackendResponse
from clan_app.api.middleware import ( from clan_app.api.middleware import (
ArgumentParsingMiddleware, ArgumentParsingMiddleware,
LoggingMiddleware, LoggingMiddleware,
MethodExecutionMiddleware, MethodExecutionMiddleware,
) )
from clan_app.deps.http.http_bridge import HttpBridge
from clan_app.deps.http.http_server import HttpApiServer from clan_app.deps.http.http_server import HttpApiServer
@@ -48,93 +44,58 @@ def mock_log_manager() -> Mock:
@pytest.fixture @pytest.fixture
def http_bridge(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpBridge: def http_bridge(
"""Create HTTP bridge with mock dependencies.""" mock_api: MethodRegistry, mock_log_manager: Mock
return HttpBridge( ) -> tuple[MethodRegistry, tuple]:
"""Create HTTP bridge dependencies for testing."""
middleware_chain = ( middleware_chain = (
ArgumentParsingMiddleware(api=mock_api), ArgumentParsingMiddleware(api=mock_api),
LoggingMiddleware(log_manager=mock_log_manager), LoggingMiddleware(log_manager=mock_log_manager),
MethodExecutionMiddleware(api=mock_api), MethodExecutionMiddleware(api=mock_api),
) )
) return mock_api, middleware_chain
@pytest.fixture @pytest.fixture
def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServer: def http_server(mock_api: MethodRegistry, mock_log_manager: Mock) -> HttpApiServer:
"""Create HTTP server with mock dependencies.""" """Create HTTP server with mock dependencies."""
return HttpApiServer( server = HttpApiServer(
api=mock_api, api=mock_api,
log_manager=mock_log_manager,
host="127.0.0.1", host="127.0.0.1",
port=8081, # Use different port for tests 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: class TestHttpBridge:
"""Tests for HttpBridge class.""" """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.""" """Test HTTP bridge initialization."""
assert http_bridge.threads == {} # Since HttpBridge is now a request handler, we can't instantiate it directly
assert http_bridge.response_handler is None # 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: def test_http_bridge_middleware_setup(self, http_bridge: tuple) -> None:
"""Test setting response handler.""" """Test that middleware is properly set up."""
handler: Mock = Mock() api, middleware_chain = http_bridge
http_bridge.set_response_handler(handler)
assert http_bridge.response_handler == handler
def test_handle_http_request_success(self, http_bridge: HttpBridge) -> None: # Test that we can create the bridge with middleware
"""Test successful HTTP request handling.""" # The actual HTTP handling will be tested through the server integration tests
# Set up response handler assert len(middleware_chain) == 3
response_received: threading.Event = threading.Event() assert isinstance(middleware_chain[0], ArgumentParsingMiddleware)
received_response: dict = {} assert isinstance(middleware_chain[1], LoggingMiddleware)
assert isinstance(middleware_chain[2], MethodExecutionMiddleware)
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"
class TestHttpApiServer: class TestHttpApiServer:
@@ -172,14 +133,16 @@ class TestHttpApiServer:
# Test root endpoint # Test root endpoint
response = urlopen("http://127.0.0.1:8081/") response = urlopen("http://127.0.0.1:8081/")
data: dict = json.loads(response.read().decode()) data: dict = json.loads(response.read().decode())
assert data["message"] == "Clan API Server" assert data["body"]["status"] == "success"
assert data["version"] == "1.0.0" assert data["body"]["data"]["message"] == "Clan API Server"
assert data["body"]["data"]["version"] == "1.0.0"
# Test methods endpoint # Test methods endpoint
response = urlopen("http://127.0.0.1:8081/api/methods") response = urlopen("http://127.0.0.1:8081/api/methods")
data = json.loads(response.read().decode()) data = json.loads(response.read().decode())
assert "test_method" in data["methods"] assert data["body"]["status"] == "success"
assert "test_method_with_error" in data["methods"] assert "test_method" in data["body"]["data"]["methods"]
assert "test_method_with_error" in data["body"]["data"]["methods"]
# Test API call endpoint # Test API call endpoint
request_data: dict = {"header": {}, "body": {"message": "World"}} request_data: dict = {"header": {}, "body": {"message": "World"}}
@@ -191,8 +154,12 @@ class TestHttpApiServer:
response = urlopen(req) response = urlopen(req)
data = json.loads(response.read().decode()) data = json.loads(response.read().decode())
assert data["success"] is True # Response should be BackendResponse format
assert data["data"]["data"] == {"response": "Hello World!"} assert "body" in data
assert "header" in data
assert data["body"]["status"] == "success"
assert data["body"]["data"] == {"response": "Hello World!"}
finally: finally:
# Always stop server # Always stop server
@@ -206,9 +173,11 @@ class TestHttpApiServer:
try: try:
# Test 404 error # Test 404 error
with pytest.raises(HTTPError) as exc_info:
urlopen("http://127.0.0.1:8081/nonexistent") res = urlopen("http://127.0.0.1:8081/nonexistent")
assert exc_info.value.code == 404 assert res.status == 200
body = json.loads(res.read().decode())["body"]
assert body["status"] == "error"
# Test method not found # Test method not found
request_data: dict = {"header": {}, "body": {}} request_data: dict = {"header": {}, "body": {}}
@@ -217,9 +186,11 @@ class TestHttpApiServer:
data=json.dumps(request_data).encode(), data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
with pytest.raises(HTTPError) as exc_info:
urlopen(req) res = urlopen(req)
assert exc_info.value.code == 404 assert res.status == 200
body = json.loads(res.read().decode())["body"]
assert body["status"] == "error"
# Test invalid JSON # Test invalid JSON
req = Request( req = Request(
@@ -227,10 +198,11 @@ class TestHttpApiServer:
data=b"invalid json", data=b"invalid json",
headers={"Content-Type": "application/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: finally:
# Always stop server # Always stop server
http_server.stop() http_server.stop()
@@ -270,11 +242,17 @@ class TestIntegration:
"""Test complete request flow from server to bridge to middleware.""" """Test complete request flow from server to bridge to middleware."""
server: HttpApiServer = HttpApiServer( server: HttpApiServer = HttpApiServer(
api=mock_api, api=mock_api,
log_manager=mock_log_manager,
host="127.0.0.1", host="127.0.0.1",
port=8082, 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 # Start server
server.start() server.start()
time.sleep(0.1) # Give server time to start time.sleep(0.1) # Give server time to start
@@ -293,10 +271,11 @@ class TestIntegration:
response = urlopen(req) response = urlopen(req)
data: dict = json.loads(response.read().decode()) data: dict = json.loads(response.read().decode())
# Verify response # Verify response in BackendResponse format
assert data["success"] is True assert "body" in data
assert data["data"]["data"] == {"response": "Hello Integration!"} assert "header" in data
assert "op_key" in data assert data["body"]["status"] == "success"
assert data["body"]["data"] == {"response": "Hello Integration!"}
finally: finally:
# Always stop server # Always stop server

View File

@@ -25,7 +25,7 @@ class WebviewBridge(ApiBridge):
webview: "Webview" webview: "Webview"
threads: dict[str, WebThread] = field(default_factory=dict) 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.""" """Send response back to the webview client."""
serialized = json.dumps( serialized = json.dumps(
@@ -79,7 +79,9 @@ class WebviewBridge(ApiBridge):
f"Error while handling webview call {method_name} with op_key {op_key}" f"Error while handling webview call {method_name} with op_key {op_key}"
) )
log.exception(msg) 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 return
# Process in a separate thread # Process in a separate thread

View File

@@ -9,6 +9,7 @@
clan-ts-api, clan-ts-api,
ps, ps,
process-compose, process-compose,
go-swagger,
json2ts, json2ts,
playwright-driver, playwright-driver,
luakit, luakit,
@@ -42,6 +43,7 @@ mkShell {
nativeBuildInputs = clan-app.nativeBuildInputs ++ [ nativeBuildInputs = clan-app.nativeBuildInputs ++ [
ps ps
process-compose process-compose
go-swagger
]; ];
buildInputs = [ buildInputs = [