Merge pull request 'clan-app: init clan http api' (#4278) from Qubasa/clan-core:add_middleware_tests into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4278
This commit is contained in:
Luis Hebendanz
2025-07-09 11:53:10 +00:00
18 changed files with 940 additions and 33 deletions

View File

@@ -16,9 +16,32 @@ def main(argv: list[str] = sys.argv) -> int:
"--content-uri", type=str, help="The URI of the content to display"
)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument(
"--http-api",
action="store_true",
help="Enable HTTP API mode (default: False)",
)
parser.add_argument(
"--http-host",
type=str,
default="localhost",
help="The host for the HTTP API server (default: localhost)",
)
parser.add_argument(
"--http-port",
type=int,
default=8080,
help="The host and port for the HTTP API server (default: 8080)",
)
args = parser.parse_args(argv[1:])
app_opts = ClanAppOptions(content_uri=args.content_uri, debug=args.debug)
app_opts = ClanAppOptions(
content_uri=args.content_uri,
http_api=args.http_api,
http_host=args.http_host,
http_port=args.http_port,
debug=args.debug,
)
try:
app_run(app_opts)
except KeyboardInterrupt:

View File

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

View File

@@ -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)

View File

@@ -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],
)

View File

@@ -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],
)

View File

@@ -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],
)

View File

@@ -25,6 +25,9 @@ log = logging.getLogger(__name__)
class ClanAppOptions:
content_uri: str
debug: bool
http_api: bool = False
http_host: str = "127.0.0.1"
http_port: int = 8080
@profile
@@ -55,19 +58,67 @@ def app_run(app_opts: ClanAppOptions) -> int:
load_in_all_api_functions()
API.overwrite_fn(open_file)
webview = Webview(
debug=app_opts.debug, title="Clan App", size=Size(1280, 1024, SizeHint.NONE)
)
# Start HTTP API server if requested
http_server = None
if app_opts.http_api:
from clan_app.deps.http.http_server import HttpApiServer
# Add middleware to the webview
webview.add_middleware(ArgumentParsingMiddleware(api=API))
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
webview.add_middleware(MethodExecutionMiddleware(api=API))
openapi_file = os.getenv("OPENAPI_FILE", None)
swagger_dist = os.getenv("SWAGGER_UI_DIST", None)
# Init BAKEND_THREADS global in tasks module
tasks.BAKEND_THREADS = webview.threads
http_server = HttpApiServer(
api=API,
openapi_file=Path(openapi_file) if openapi_file else None,
swagger_dist=Path(swagger_dist) if swagger_dist else None,
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"Swagger: http://{app_opts.http_host}:{app_opts.http_port}/api/swagger"
)
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(
debug=app_opts.debug, title="Clan App", size=Size(1280, 1024, SizeHint.NONE)
)
# Add middleware to the webview
webview.add_middleware(ArgumentParsingMiddleware(api=API))
webview.add_middleware(LoggingMiddleware(log_manager=log_manager))
webview.add_middleware(MethodExecutionMiddleware(api=API))
# Create the bridge
webview.create_bridge()
# Init BAKEND_THREADS global in tasks module
tasks.BAKEND_THREADS = webview.threads
webview.bind_jsonschema_api(API, log_manager=log_manager)
webview.navigate(content_uri)
webview.run()
webview.bind_jsonschema_api(API, log_manager=log_manager)
webview.navigate(content_uri)
webview.run()
return 0

View File

@@ -0,0 +1,360 @@
import json
import logging
import threading
import uuid
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
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 clan_app.api.middleware import Middleware
log = logging.getLogger(__name__)
class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
"""HTTP-specific implementation of the API bridge that handles HTTP requests directly.
This bridge combines the API bridge functionality with HTTP request handling.
"""
def __init__(
self,
api: MethodRegistry,
middleware_chain: tuple["Middleware", ...],
request: Any,
client_address: Any,
server: Any,
*,
openapi_file: Path | None = None,
swagger_dist: Path | None = None,
) -> None:
# Initialize API bridge fields
self.api = api
self.middleware_chain = middleware_chain
self.threads: dict[str, WebThread] = {}
# Initialize OpenAPI/Swagger fields
self.openapi_file = openapi_file
self.swagger_dist = swagger_dist
# Initialize HTTP handler
super(BaseHTTPRequestHandler, self).__init__(request, client_address, server)
def _send_cors_headers(self) -> None:
"""Send CORS headers for cross-origin requests."""
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")
def _send_json_response_with_status(
self, data: dict[str, Any], status_code: int = 200
) -> None:
"""Send a JSON response with the given status code."""
try:
self.send_response_only(status_code)
self.send_header("Content-Type", "application/json")
self._send_cors_headers()
self.end_headers()
response_data = json.dumps(data, indent=2, ensure_ascii=False)
self.wfile.write(response_data.encode("utf-8"))
except BrokenPipeError as e:
log.warning(f"Client disconnected before we could send a response: {e!s}")
def send_api_response(self, response: BackendResponse) -> None:
"""Send HTTP response directly to the client."""
response_dict = dataclass_to_dict(response)
self._send_json_response_with_status(response_dict, 200)
log.debug(
f"HTTP response for {response._op_key}: {json.dumps(response_dict, indent=2)}" # noqa: SLF001
)
def _create_success_response(
self, op_key: str, data: dict[str, Any]
) -> BackendResponse:
"""Create a successful API response."""
return BackendResponse(
body=SuccessDataClass(op_key=op_key, status="success", data=data),
header={},
_op_key=op_key,
)
def _send_info_response(self) -> None:
"""Send server information response."""
response = self._create_success_response(
"info", {"message": "Clan API Server", "version": "1.0.0"}
)
self.send_api_response(response)
def _send_methods_response(self) -> None:
"""Send available API methods response."""
response = self._create_success_response(
"methods", {"methods": list(self.api.functions.keys())}
)
self.send_api_response(response)
def _handle_swagger_request(self, parsed_url: Any) -> None:
"""Handle Swagger UI related requests."""
if not self.swagger_dist or not self.swagger_dist.exists():
self.send_error(404, "Swagger file not found")
return
rel_path = parsed_url.path[len("/api/swagger") :].lstrip("/")
# Redirect /api/swagger to /api/swagger/index.html
if rel_path == "":
self.send_response(302)
self.send_header("Location", "/api/swagger/index.html")
self.end_headers()
return
self._serve_swagger_file(rel_path)
def _serve_swagger_file(self, rel_path: str) -> None:
"""Serve a specific Swagger UI file."""
file_path = self._get_swagger_file_path(rel_path)
if not file_path.exists() or not file_path.is_file():
self.send_error(404, "Swagger file not found")
return
try:
content_type = self._get_content_type(file_path)
file_data = self._read_and_process_file(file_path, rel_path)
self.send_response(200)
self.send_header("Content-Type", content_type)
self.end_headers()
self.wfile.write(file_data)
except Exception as e:
log.error(f"Error reading Swagger file: {e!s}")
self.send_error(500, "Internal Server Error")
def _get_swagger_file_path(self, rel_path: str) -> Path:
"""Get the file path for a Swagger resource."""
if rel_path == "index.html":
return Path(__file__).parent / "swagger.html"
if rel_path == "openapi.json":
if not self.openapi_file:
return Path("/nonexistent") # Will fail exists() check
return self.openapi_file
return (
self.swagger_dist / rel_path if self.swagger_dist else Path("/nonexistent")
)
def _get_content_type(self, file_path: Path) -> str:
"""Get the content type for a file based on its extension."""
content_types = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
}
return content_types.get(file_path.suffix, "application/octet-stream")
def _read_and_process_file(self, file_path: Path, rel_path: str) -> bytes:
"""Read and optionally process a file (e.g., inject server URL into openapi.json)."""
with file_path.open("rb") as f:
file_data = f.read()
if rel_path == "openapi.json":
json_data = json.loads(file_data.decode("utf-8"))
server_address = getattr(self.server, "server_address", ("localhost", 80))
json_data["servers"] = [
{"url": f"http://{server_address[0]}:{server_address[1]}/api/v1/"}
]
file_data = json.dumps(json_data, indent=2).encode("utf-8")
return file_data
def do_OPTIONS(self) -> None: # noqa: N802
"""Handle CORS preflight requests."""
self.send_response_only(200)
self._send_cors_headers()
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_info_response()
elif path.startswith("/api/swagger"):
self._handle_swagger_request(parsed_url)
elif path == "/api/methods":
self._send_methods_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
# Validate API path
if not path.startswith("/api/v1/"):
self.send_api_error_response(
"post", f"Path not found: {path}", ["http_bridge", "POST"]
)
return
# Extract and validate method name
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 and parse request body
request_data = self._read_request_body(method_name)
if request_data is None:
return # Error already sent
# Generate operation key and handle request
gen_op_key = str(uuid.uuid4())
try:
self._handle_api_request(method_name, request_data, gen_op_key)
except Exception as e:
log.exception(f"Error processing API request {method_name}")
self.send_api_error_response(
gen_op_key,
f"Internal server error: {e!s}",
["http_bridge", "POST", method_name],
)
def _read_request_body(self, method_name: str) -> dict[str, Any] | None:
"""Read and parse the request body. Returns None if there was an error."""
try:
content_length = int(self.headers.get("Content-Length", 0))
if content_length > 0:
body = self.rfile.read(content_length)
return json.loads(body.decode("utf-8"))
return {}
except json.JSONDecodeError:
self.send_api_error_response(
"post",
"Invalid JSON in request body",
["http_bridge", "POST", method_name],
)
return None
except Exception as e:
self.send_api_error_response(
"post",
f"Error reading request: {e!s}",
["http_bridge", "POST", method_name],
)
return None
def _handle_api_request(
self,
method_name: str,
request_data: dict[str, Any],
gen_op_key: str,
) -> None:
"""Handle an API request by processing it through middleware."""
try:
# Validate and parse request data
header, body, op_key = self._parse_request_data(request_data, gen_op_key)
# Validate operation key
self._validate_operation_key(op_key)
# Create API request
api_request = BackendRequest(
method_name=method_name, args=body, header=header, op_key=op_key
)
except Exception as e:
self.send_api_error_response(
gen_op_key, str(e), ["http_bridge", method_name]
)
return
self._process_api_request_in_thread(api_request, method_name)
def _parse_request_data(
self, request_data: dict[str, Any], gen_op_key: str
) -> tuple[dict[str, Any], dict[str, Any], str]:
"""Parse and validate request data components."""
header = request_data.get("header", {})
if not isinstance(header, dict):
msg = f"Expected header to be a dict, got {type(header)}"
raise TypeError(msg)
body = request_data.get("body", {})
if not isinstance(body, dict):
msg = f"Expected body to be a dict, got {type(body)}"
raise TypeError(msg)
op_key = header.get("op_key", gen_op_key)
if not isinstance(op_key, str):
msg = f"Expected op_key to be a string, got {type(op_key)}"
raise TypeError(msg)
return header, body, op_key
def _validate_operation_key(self, op_key: str) -> None:
"""Validate that the operation key is valid and not in use."""
try:
uuid.UUID(op_key)
except ValueError as e:
msg = f"op_key '{op_key}' is not a valid UUID"
raise TypeError(msg) from e
if op_key in self.threads:
msg = f"Operation key '{op_key}' is already in use. Please try again."
raise ValueError(msg)
def _process_api_request_in_thread(
self, api_request: BackendRequest, method_name: str
) -> None:
"""Process the API request in a separate thread."""
op_key = api_request.op_key or "unknown"
def thread_task(stop_event: threading.Event) -> None:
set_should_cancel(lambda: stop_event.is_set())
try:
self.process_request(api_request)
finally:
self.threads.pop(op_key, None)
stop_event = threading.Event()
thread = threading.Thread(
target=thread_task, args=(stop_event,), name="HttpThread"
)
thread.start()
self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event)
# Wait for the thread to complete (this blocks until response is sent)
thread.join(timeout=60.0)
# Handle timeout
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

@@ -0,0 +1,109 @@
import logging
import threading
from http.server import HTTPServer
from pathlib import Path
from typing import TYPE_CHECKING, Any
from clan_lib.api import MethodRegistry
if TYPE_CHECKING:
from clan_app.api.middleware import Middleware
from .http_bridge import HttpBridge
log = logging.getLogger(__name__)
class HttpApiServer:
"""HTTP server for the Clan API using Python's built-in HTTP server."""
def __init__(
self,
api: MethodRegistry,
host: str = "127.0.0.1",
port: int = 8080,
openapi_file: Path | None = None,
swagger_dist: Path | None = None,
) -> None:
self.api = api
self.openapi = openapi_file
self.swagger_dist = swagger_dist
self.host = host
self.port = port
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 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)
@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
middleware_chain = tuple(self._middleware)
openapi_file = self.openapi
swagger_dist = self.swagger_dist
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,
openapi_file=openapi_file,
swagger_dist=swagger_dist,
)
return RequestHandler
def start(self) -> None:
"""Start the HTTP server in a separate thread."""
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)
def run_server() -> None:
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()
def stop(self) -> None:
"""Stop the HTTP server."""
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
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()

View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="stylesheet" type="text/css" href="index.css" />
<link
rel="icon"
type="image/png"
href="./favicon-32x32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="./favicon-16x16.png"
sizes="16x16"
/>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"></script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"></script>
<script src="./swagger-initializer.js" charset="UTF-8"></script>
<script>
window.onload = () => {
SwaggerUIBundle({
url: "./openapi.json", // Path to your OpenAPI 3 spec (YAML or JSON)
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
});
};
</script>
</body>
</html>

View File

@@ -0,0 +1,286 @@
"""Tests for HTTP API components."""
import json
import time
from unittest.mock import Mock
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.middleware import (
ArgumentParsingMiddleware,
LoggingMiddleware,
MethodExecutionMiddleware,
)
from clan_app.deps.http.http_server import HttpApiServer
@pytest.fixture
def mock_api() -> MethodRegistry:
"""Create a mock API with test methods."""
api = MethodRegistry()
@api.register
def test_method(message: str) -> dict[str, str]:
return {"response": f"Hello {message}!"}
@api.register
def test_method_with_error() -> dict[str, str]:
msg = "Test error"
raise ValueError(msg)
return api
@pytest.fixture
def mock_log_manager() -> Mock:
"""Create a mock log manager."""
log_manager = Mock(spec=LogManager)
log_manager.create_log_file.return_value.get_file_path.return_value = Mock()
log_manager.create_log_file.return_value.get_file_path.return_value.open.return_value = Mock()
return log_manager
@pytest.fixture
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."""
server = HttpApiServer(
api=mock_api,
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: tuple) -> None:
"""Test HTTP bridge initialization."""
# 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_http_bridge_middleware_setup(self, http_bridge: tuple) -> None:
"""Test that middleware is properly set up."""
api, middleware_chain = http_bridge
# 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:
"""Tests for HttpApiServer class."""
def test_server_initialization(self, http_server: HttpApiServer) -> None:
"""Test HTTP server initialization."""
assert http_server.host == "127.0.0.1"
assert http_server.port == 8081
assert http_server.server is None
assert http_server.server_thread is None
assert not http_server.is_running()
def test_server_start_stop(self, http_server: HttpApiServer) -> None:
"""Test starting and stopping the server."""
# Start server
http_server.start()
time.sleep(0.1) # Give server time to start
assert http_server.is_running()
# Stop server
http_server.stop()
time.sleep(0.1) # Give server time to stop
assert not http_server.is_running()
def test_server_endpoints(self, http_server: HttpApiServer) -> None:
"""Test server endpoints."""
# Start server
http_server.start()
time.sleep(0.1) # Give server time to start
try:
# Test root endpoint
response = urlopen("http://127.0.0.1:8081/")
data: dict = json.loads(response.read().decode())
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 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"}}
req: Request = Request(
"http://127.0.0.1:8081/api/v1/test_method",
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
response = urlopen(req)
data = json.loads(response.read().decode())
# 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
http_server.stop()
def test_server_error_handling(self, http_server: HttpApiServer) -> None:
"""Test server error handling."""
# Start server
http_server.start()
time.sleep(0.1) # Give server time to start
try:
# Test 404 error
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": {}}
req: Request = Request(
"http://127.0.0.1:8081/api/v1/nonexistent_method",
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
res = urlopen(req)
assert res.status == 200
body = json.loads(res.read().decode())["body"]
assert body["status"] == "error"
# Test invalid JSON
req = Request(
"http://127.0.0.1:8081/api/v1/test_method",
data=b"invalid json",
headers={"Content-Type": "application/json"},
)
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()
def test_server_cors_headers(self, http_server: HttpApiServer) -> None:
"""Test CORS headers are properly set."""
# Start server
http_server.start()
time.sleep(0.1) # Give server time to start
try:
# Test OPTIONS request
class OptionsRequest(Request):
def get_method(self) -> str:
return "OPTIONS"
req: Request = OptionsRequest("http://127.0.0.1:8081/api/call/test_method")
response = urlopen(req)
# Check CORS headers
headers = response.info()
assert headers.get("Access-Control-Allow-Origin") == "*"
assert "GET" in headers.get("Access-Control-Allow-Methods", "")
assert "POST" in headers.get("Access-Control-Allow-Methods", "")
finally:
# Always stop server
http_server.stop()
class TestIntegration:
"""Integration tests for HTTP API components."""
def test_full_request_flow(
self, mock_api: MethodRegistry, mock_log_manager: Mock
) -> None:
"""Test complete request flow from server to bridge to middleware."""
server: HttpApiServer = HttpApiServer(
api=mock_api,
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
try:
# Make API call
request_data: dict = {
"header": {"logging": {"group_path": ["test", "group"]}},
"body": {"message": "Integration"},
}
req: Request = Request(
"http://127.0.0.1:8082/api/v1/test_method",
data=json.dumps(request_data).encode(),
headers={"Content-Type": "application/json"},
)
response = urlopen(req)
data: dict = json.loads(response.read().decode())
# 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
server.stop()
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -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

View File

@@ -32,7 +32,12 @@
devShells.clan-app = pkgs.callPackage ./shell.nix {
inherit self';
inherit (self'.packages) clan-app webview-lib clan-app-ui;
inherit (self'.packages)
clan-app
webview-lib
clan-app-ui
clan-lib-openapi
;
inherit (config.packages) clan-ts-api;
};

View File

@@ -7,7 +7,9 @@
webview-lib,
clan-app-ui,
clan-ts-api,
clan-lib-openapi,
ps,
fetchzip,
process-compose,
json2ts,
playwright-driver,
@@ -17,6 +19,12 @@
let
GREEN = "\\033[1;32m";
NC = "\\033[0m";
swagger-ui-dist = fetchzip {
url = "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.26.2.zip";
sha256 = "sha256-KoFOsCheR1N+7EigFDV3r7frMMQtT43HE5H1/xsKLG4=";
};
in
mkShell {
@@ -75,6 +83,8 @@ mkShell {
export XDG_DATA_DIRS=$GSETTINGS_SCHEMAS_PATH:$XDG_DATA_DIRS
export WEBVIEW_LIB_DIR=${webview-lib}/lib
export OPENAPI_FILE="${clan-lib-openapi}"
export SWAGGER_UI_DIST="${swagger-ui-dist}/dist"
## Webview UI
# Add clan-app-ui scripts to PATH

View File

@@ -54,6 +54,7 @@
"prettier": "^3.2.5",
"solid-devtools": "^0.34.0",
"storybook": "^9.0.8",
"swagger-ui-dist": "^5.26.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
@@ -1756,6 +1757,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -7431,6 +7440,16 @@
"node": ">= 10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.26.2",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.2.tgz",
"integrity": "sha512-WmMS9iMlHQejNm/Uw5ZTo4e3M2QMmEavRz7WLWVsq7Mlx4PSHJbY+VCrLsAz9wLxyHVgrJdt7N8+SdQLa52Ykg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

View File

@@ -54,6 +54,7 @@
"prettier": "^3.2.5",
"solid-devtools": "^0.34.0",
"storybook": "^9.0.8",
"swagger-ui-dist": "^5.26.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",

View File

@@ -50,6 +50,7 @@ lint.ignore = [
# We might actually want to fix this.
"A005",
"TRY301",
"TRY300",
"ANN401",
"RUF100",
"TRY400",