clan-app: Working swagger
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
38
pkgs/clan-app/clan_app/deps/http/swagger.html
Normal file
38
pkgs/clan-app/clan_app/deps/http/swagger.html
Normal 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>
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
19
pkgs/clan-app/ui/package-lock.json
generated
19
pkgs/clan-app/ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user