From 0987c4b2ccc83c13d2e15690d3cb03b1afe08606 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Wed, 9 Jul 2025 18:34:58 +0700 Subject: [PATCH] clan-app: Working swagger --- pkgs/clan-app/clan_app/app.py | 6 + .../clan_app/deps/http/http_bridge.py | 129 ++++++++++++++---- .../clan_app/deps/http/http_server.py | 9 ++ pkgs/clan-app/clan_app/deps/http/swagger.html | 38 ++++++ pkgs/clan-app/flake-module.nix | 7 +- pkgs/clan-app/shell.nix | 12 +- pkgs/clan-app/ui/package-lock.json | 19 +++ pkgs/clan-app/ui/package.json | 1 + 8 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 pkgs/clan-app/clan_app/deps/http/swagger.html diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py index dda4012fb..6ccafb7b9 100644 --- a/pkgs/clan-app/clan_app/app.py +++ b/pkgs/clan-app/clan_app/app.py @@ -63,8 +63,13 @@ def app_run(app_opts: ClanAppOptions) -> int: if app_opts.http_api: from clan_app.deps.http.http_server import HttpApiServer + openapi_file = os.getenv("OPENAPI_FILE", None) + swagger_dist = os.getenv("SWAGGER_UI_DIST", None) + 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, ) @@ -85,6 +90,7 @@ def app_run(app_opts: ClanAppOptions) -> int: 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 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 2158b95c4..4ad6d7164 100644 --- a/pkgs/clan-app/clan_app/deps/http/http_bridge.py +++ b/pkgs/clan-app/clan_app/deps/http/http_bridge.py @@ -3,6 +3,7 @@ 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 @@ -31,36 +32,43 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): request: Any, client_address: Any, server: Any, + *, + openapi_file: Path | None = None, + swagger_dist: Path | None = None, ) -> None: # Initialize the API bridge fields self.api = api + self.openapi_file = openapi_file + self.swagger_dist = swagger_dist 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() + try: + # 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")) + # 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 + # Log the response for debugging + log.debug(f"HTTP response for {response._op_key}: {response_data}") # noqa: SLF001 + except BrokenPipeError as e: + # Handle broken pipe errors gracefully + log.warning(f"Client disconnected before we could send a response: {e!s}") def do_OPTIONS(self) -> None: # noqa: N802 """Handle CORS preflight requests.""" @@ -86,6 +94,65 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): _op_key="info", ) self.send_api_response(response) + elif path.startswith("/api/swagger"): + if self.swagger_dist and self.swagger_dist.exists(): + # Serve static files from swagger_dist + rel_path = parsed_url.path[len("/api/swagger") :].lstrip("/") + # Redirect /api/swagger (no trailing slash or file) to /api/swagger/index.html + if rel_path == "": + self.send_response(302) + self.send_header("Location", "/api/swagger/index.html") + self.end_headers() + return + file_path = self.swagger_dist / rel_path + if rel_path == "index.html": + file_path = Path(__file__).parent / "swagger.html" + elif rel_path == "openapi.json": + if not self.openapi_file: + self.send_error(404, "OpenAPI file not found") + return + file_path = self.openapi_file + + if file_path.exists() and file_path.is_file(): + try: + # Guess content type + if file_path.suffix == ".html": + content_type = "text/html" + elif file_path.suffix == ".js": + content_type = "application/javascript" + elif file_path.suffix == ".css": + content_type = "text/css" + elif file_path.suffix == ".json": + content_type = "application/json" + elif file_path.suffix == ".png": + content_type = "image/png" + elif file_path.suffix == ".svg": + content_type = "image/svg+xml" + else: + content_type = "application/octet-stream" + 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")) + json_data["servers"] = [ + { + "url": f"http://{getattr(self.server, 'server_address', ('localhost', 80))[0]}:{getattr(self.server, 'server_address', ('localhost', 80))[1]}/api/v1/" + } + ] + file_data = json.dumps(json_data, indent=2).encode("utf-8") + 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") + else: + self.send_error(404, "Swagger file not found") + else: + self.send_error(404, "Swagger file not found") + elif path == "/api/methods": response = BackendResponse( body=SuccessDataClass( @@ -97,8 +164,6 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): _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.""" @@ -107,7 +172,9 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): # Check if this is an API call if not path.startswith("/api/v1/"): - self.send_api_error_response("post", "Not Found", ["http_bridge", "POST"]) + self.send_api_error_response( + "post", f"Path not found {path}", ["http_bridge", "POST"] + ) return # Extract method name from path @@ -151,15 +218,15 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): return # Generate a unique operation key - op_key = str(uuid.uuid4()) + gen_op_key = str(uuid.uuid4()) # Handle the API request try: - self._handle_api_request(method_name, request_data, op_key) + 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( - op_key, + gen_op_key, f"Internal server error: {e!s}", ["http_bridge", "POST", method_name], ) @@ -168,7 +235,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): self, method_name: str, request_data: dict[str, Any], - op_key: str, + gen_op_key: str, ) -> None: """Handle an API request by processing it through middleware.""" try: @@ -183,6 +250,22 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler): 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) + + # Check if op_key is a valid UUID + 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) + # Create API request api_request = BackendRequest( method_name=method_name, args=body, header=header, op_key=op_key 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 de508cb2e..f4f6ce482 100644 --- a/pkgs/clan-app/clan_app/deps/http/http_server.py +++ b/pkgs/clan-app/clan_app/deps/http/http_server.py @@ -1,6 +1,7 @@ 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 @@ -21,8 +22,12 @@ class HttpApiServer: 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 @@ -51,6 +56,8 @@ class HttpApiServer: """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: @@ -60,6 +67,8 @@ class HttpApiServer: request=request, client_address=client_address, server=server, + openapi_file=openapi_file, + swagger_dist=swagger_dist, ) return RequestHandler diff --git a/pkgs/clan-app/clan_app/deps/http/swagger.html b/pkgs/clan-app/clan_app/deps/http/swagger.html new file mode 100644 index 000000000..855b2729c --- /dev/null +++ b/pkgs/clan-app/clan_app/deps/http/swagger.html @@ -0,0 +1,38 @@ + + + + + Swagger UI + + + + + + + +
+ + + + + + diff --git a/pkgs/clan-app/flake-module.nix b/pkgs/clan-app/flake-module.nix index 9076c0260..bcd97f2b8 100644 --- a/pkgs/clan-app/flake-module.nix +++ b/pkgs/clan-app/flake-module.nix @@ -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; }; diff --git a/pkgs/clan-app/shell.nix b/pkgs/clan-app/shell.nix index 89ff86c57..ad4f1549e 100644 --- a/pkgs/clan-app/shell.nix +++ b/pkgs/clan-app/shell.nix @@ -7,9 +7,10 @@ webview-lib, clan-app-ui, clan-ts-api, + clan-lib-openapi, ps, + fetchzip, process-compose, - go-swagger, json2ts, playwright-driver, luakit, @@ -18,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 { @@ -43,7 +50,6 @@ mkShell { nativeBuildInputs = clan-app.nativeBuildInputs ++ [ ps process-compose - go-swagger ]; buildInputs = [ @@ -77,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 diff --git a/pkgs/clan-app/ui/package-lock.json b/pkgs/clan-app/ui/package-lock.json index 024dc7c7b..9ed788df4 100644 --- a/pkgs/clan-app/ui/package-lock.json +++ b/pkgs/clan-app/ui/package-lock.json @@ -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", diff --git a/pkgs/clan-app/ui/package.json b/pkgs/clan-app/ui/package.json index f57071054..c0f0bbe26 100644 --- a/pkgs/clan-app/ui/package.json +++ b/pkgs/clan-app/ui/package.json @@ -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",