clan-app: Working swagger

This commit is contained in:
Qubasa
2025-07-09 18:34:58 +07:00
parent 0b4eb9202e
commit 0987c4b2cc
8 changed files with 195 additions and 26 deletions

View File

@@ -63,8 +63,13 @@ def app_run(app_opts: ClanAppOptions) -> int:
if app_opts.http_api: if app_opts.http_api:
from clan_app.deps.http.http_server import HttpApiServer 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( http_server = HttpApiServer(
api=API, 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, host=app_opts.http_host,
port=app_opts.http_port, port=app_opts.http_port,
) )
@@ -85,6 +90,7 @@ def app_run(app_opts: ClanAppOptions) -> int:
log.info( log.info(
f"Example request: curl -X POST http://{app_opts.http_host}:{app_opts.http_port}/api/v1/list_log_days" 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") log.info("Press Ctrl+C to stop the server")
try: try:
# Keep the main thread alive # Keep the main thread alive

View File

@@ -3,6 +3,7 @@ import logging
import threading import threading
import uuid import uuid
from http.server import BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -31,36 +32,43 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
request: Any, request: Any,
client_address: Any, client_address: Any,
server: Any, server: Any,
*,
openapi_file: Path | None = None,
swagger_dist: Path | None = None,
) -> None: ) -> None:
# Initialize the API bridge fields # Initialize the API bridge fields
self.api = api self.api = api
self.openapi_file = openapi_file
self.swagger_dist = swagger_dist
self.middleware_chain = middleware_chain self.middleware_chain = middleware_chain
self.threads: dict[str, WebThread] = {} self.threads: dict[str, WebThread] = {}
self._current_response: BackendResponse | None = None
# Initialize the HTTP handler # Initialize the HTTP handler
super(BaseHTTPRequestHandler, self).__init__(request, client_address, server) super(BaseHTTPRequestHandler, self).__init__(request, client_address, server)
def send_api_response(self, response: BackendResponse) -> None: def send_api_response(self, response: BackendResponse) -> None:
"""Send HTTP response directly to the client.""" """Send HTTP response directly to the client."""
self._current_response = response
# Send HTTP response try:
self.send_response_only(200) # Send HTTP response
self.send_header("Content-Type", "application/json") self.send_response_only(200)
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.end_headers() self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
# Write response data # Write response data
response_data = json.dumps( response_data = json.dumps(
dataclass_to_dict(response), indent=2, ensure_ascii=False dataclass_to_dict(response), indent=2, ensure_ascii=False
) )
self.wfile.write(response_data.encode("utf-8")) self.wfile.write(response_data.encode("utf-8"))
# Log the response for debugging # Log the response for debugging
log.debug(f"HTTP response for {response._op_key}: {response_data}") # noqa: SLF001 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 def do_OPTIONS(self) -> None: # noqa: N802
"""Handle CORS preflight requests.""" """Handle CORS preflight requests."""
@@ -86,6 +94,65 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
_op_key="info", _op_key="info",
) )
self.send_api_response(response) 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": elif path == "/api/methods":
response = BackendResponse( response = BackendResponse(
body=SuccessDataClass( body=SuccessDataClass(
@@ -97,8 +164,6 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
_op_key="methods", _op_key="methods",
) )
self.send_api_response(response) self.send_api_response(response)
else:
self.send_api_error_response("info", "Not Found", ["http_bridge", "GET"])
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
"""Handle POST requests.""" """Handle POST requests."""
@@ -107,7 +172,9 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
# Check if this is an API call # Check if this is an API call
if not path.startswith("/api/v1/"): 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 return
# Extract method name from path # Extract method name from path
@@ -151,15 +218,15 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
return return
# Generate a unique operation key # Generate a unique operation key
op_key = str(uuid.uuid4()) gen_op_key = str(uuid.uuid4())
# Handle the API request # Handle the API request
try: 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: except Exception as e:
log.exception(f"Error processing API request {method_name}") log.exception(f"Error processing API request {method_name}")
self.send_api_error_response( self.send_api_error_response(
op_key, gen_op_key,
f"Internal server error: {e!s}", f"Internal server error: {e!s}",
["http_bridge", "POST", method_name], ["http_bridge", "POST", method_name],
) )
@@ -168,7 +235,7 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
self, self,
method_name: str, method_name: str,
request_data: dict[str, Any], request_data: dict[str, Any],
op_key: str, gen_op_key: str,
) -> None: ) -> None:
"""Handle an API request by processing it through middleware.""" """Handle an API request by processing it through middleware."""
try: try:
@@ -183,6 +250,22 @@ class HttpBridge(ApiBridge, BaseHTTPRequestHandler):
msg = f"Expected body to be a dict, got {type(body)}" msg = f"Expected body to be a dict, got {type(body)}"
raise TypeError(msg) 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 # Create API request
api_request = BackendRequest( api_request = BackendRequest(
method_name=method_name, args=body, header=header, op_key=op_key method_name=method_name, args=body, header=header, op_key=op_key

View File

@@ -1,6 +1,7 @@
import logging import logging
import threading import threading
from http.server import HTTPServer from http.server import HTTPServer
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from clan_lib.api import MethodRegistry from clan_lib.api import MethodRegistry
@@ -21,8 +22,12 @@ class HttpApiServer:
api: MethodRegistry, api: MethodRegistry,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 8080, port: int = 8080,
openapi_file: Path | None = None,
swagger_dist: Path | None = None,
) -> None: ) -> None:
self.api = api self.api = api
self.openapi = openapi_file
self.swagger_dist = swagger_dist
self.host = host self.host = host
self.port = port self.port = port
self._server: HTTPServer | None = None self._server: HTTPServer | None = None
@@ -51,6 +56,8 @@ class HttpApiServer:
"""Create a request handler class with injected dependencies.""" """Create a request handler class with injected dependencies."""
api = self.api api = self.api
middleware_chain = tuple(self._middleware) middleware_chain = tuple(self._middleware)
openapi_file = self.openapi
swagger_dist = self.swagger_dist
class RequestHandler(HttpBridge): class RequestHandler(HttpBridge):
def __init__(self, request: Any, client_address: Any, server: Any) -> None: def __init__(self, request: Any, client_address: Any, server: Any) -> None:
@@ -60,6 +67,8 @@ class HttpApiServer:
request=request, request=request,
client_address=client_address, client_address=client_address,
server=server, server=server,
openapi_file=openapi_file,
swagger_dist=swagger_dist,
) )
return RequestHandler return RequestHandler

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

@@ -32,7 +32,12 @@
devShells.clan-app = pkgs.callPackage ./shell.nix { devShells.clan-app = pkgs.callPackage ./shell.nix {
inherit self'; 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; inherit (config.packages) clan-ts-api;
}; };

View File

@@ -7,9 +7,10 @@
webview-lib, webview-lib,
clan-app-ui, clan-app-ui,
clan-ts-api, clan-ts-api,
clan-lib-openapi,
ps, ps,
fetchzip,
process-compose, process-compose,
go-swagger,
json2ts, json2ts,
playwright-driver, playwright-driver,
luakit, luakit,
@@ -18,6 +19,12 @@
let let
GREEN = "\\033[1;32m"; GREEN = "\\033[1;32m";
NC = "\\033[0m"; 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 in
mkShell { mkShell {
@@ -43,7 +50,6 @@ mkShell {
nativeBuildInputs = clan-app.nativeBuildInputs ++ [ nativeBuildInputs = clan-app.nativeBuildInputs ++ [
ps ps
process-compose process-compose
go-swagger
]; ];
buildInputs = [ buildInputs = [
@@ -77,6 +83,8 @@ mkShell {
export XDG_DATA_DIRS=$GSETTINGS_SCHEMAS_PATH:$XDG_DATA_DIRS export XDG_DATA_DIRS=$GSETTINGS_SCHEMAS_PATH:$XDG_DATA_DIRS
export WEBVIEW_LIB_DIR=${webview-lib}/lib export WEBVIEW_LIB_DIR=${webview-lib}/lib
export OPENAPI_FILE="${clan-lib-openapi}"
export SWAGGER_UI_DIST="${swagger-ui-dist}/dist"
## Webview UI ## Webview UI
# Add clan-app-ui scripts to PATH # Add clan-app-ui scripts to PATH

View File

@@ -54,6 +54,7 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"solid-devtools": "^0.34.0", "solid-devtools": "^0.34.0",
"storybook": "^9.0.8", "storybook": "^9.0.8",
"swagger-ui-dist": "^5.26.2",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"typescript-eslint": "^8.32.1", "typescript-eslint": "^8.32.1",
@@ -1756,6 +1757,14 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@sideway/address": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -7431,6 +7440,16 @@
"node": ">= 10" "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": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

View File

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