data_clickhouse / gateway.py
Subham9126's picture
Upload 11 files
84739e0 verified
Raw
History Blame Contribute Delete
7.52 kB
#!/usr/bin/env python3
"""
gateway.py β€” Lightweight HTTP gateway for the Observatory
Sits on port 7860 (the only port HF Spaces exposes) and routes:
GET /api/refresh β†’ git pull all repos + recreate all views (on-demand)
GET /api/sources β†’ return current sources.yaml as JSON
* /* β†’ proxy everything else to ClickHouse on 8123
This lets you push to GitHub, then immediately:
curl https://your-space.hf.space/api/refresh
curl https://your-space.hf.space/?query=SELECT * FROM ohlc LIMIT 5
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error
import yaml
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
CLICKHOUSE_URL = "http://127.0.0.1:8123"
CONFIG_PATH = "/app/sources.yaml"
REFRESH_SCRIPT = "/app/refresh_sources.py"
GATEWAY_PORT = 7860
class GatewayHandler(BaseHTTPRequestHandler):
# ── /api/refresh ─────────────────────────────────────────────────────
def _handle_refresh(self):
"""Run refresh_sources.sh and return JSON result."""
try:
start = time.time()
result = subprocess.run(
["python3", REFRESH_SCRIPT, "full"],
capture_output=True, text=True, timeout=1200,
)
elapsed = round(time.time() - start, 2)
body = {
"action": "refresh",
"elapsed_seconds": elapsed,
"exit_code": result.returncode,
}
# Try to parse script's JSON output
try:
body["result"] = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
body["stdout"] = result.stdout
if result.stderr:
body["stderr"] = result.stderr
self._json_response(200, body)
except subprocess.TimeoutExpired:
self._json_response(504, {"error": "refresh timed out after 1200s"})
except Exception as e:
self._json_response(500, {"error": str(e)})
# ── /api/sources ─────────────────────────────────────────────────────
def _handle_sources(self):
"""Return current sources.yaml as JSON."""
try:
with open(CONFIG_PATH, "r") as f:
config = yaml.safe_load(f)
self._json_response(200, config)
except Exception as e:
self._json_response(500, {"error": str(e)})
# ── Proxy to ClickHouse ──────────────────────────────────────────────
def _proxy_to_clickhouse(self):
"""Forward the request to ClickHouse HTTP interface."""
# Read request body if present
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length else None
url = f"{CLICKHOUSE_URL}{self.path}"
req = urllib.request.Request(url, data=body, method=self.command)
# Forward relevant headers
for header in self.headers:
lower = header.lower()
if lower not in ("host", "content-length", "transfer-encoding"):
req.add_header(header, self.headers[header])
try:
resp = urllib.request.urlopen(req, timeout=600)
self.send_response(resp.status)
# Forward response headers
for key, val in resp.headers.items():
if key.lower() != "transfer-encoding":
self.send_header(key, val)
# Add CORS headers for browser-based SQL playgrounds
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
# Stream response body
while True:
chunk = resp.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except urllib.error.HTTPError as e:
self.send_response(e.code)
for key, val in e.headers.items():
if key.lower() != "transfer-encoding":
self.send_header(key, val)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(e.read())
except Exception as e:
self._json_response(502, {"error": f"ClickHouse unreachable: {e}"})
# ── HTTP method handlers ─────────────────────────────────────────────
def do_GET(self):
if self.path == "/api/refresh":
self._handle_refresh()
elif self.path == "/api/sources":
self._handle_sources()
else:
self._proxy_to_clickhouse()
def do_POST(self):
self._proxy_to_clickhouse()
def do_OPTIONS(self):
"""CORS preflight."""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "*")
self.end_headers()
# ── Helpers ──────────────────────────────────────────────────────────
def _json_response(self, code, data):
body = json.dumps(data, indent=2).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
"""Cleaner log format. Ignores HF container ping logs for cleanliness."""
msg = format % args
if "?logs=container" in msg:
return
sys.stderr.write(f"[gateway] {self.address_string()} {msg}\n")
def wait_for_clickhouse(timeout=60):
"""Block until ClickHouse is responding on 8123."""
print(f"[gateway] Waiting for ClickHouse on {CLICKHOUSE_URL}...")
for i in range(timeout):
try:
urllib.request.urlopen(f"{CLICKHOUSE_URL}/ping", timeout=2)
print(f"[gateway] ClickHouse is ready (took {i+1}s)")
return True
except Exception:
time.sleep(1)
print(f"[gateway] WARNING: ClickHouse not ready after {timeout}s, starting anyway")
return False
if __name__ == "__main__":
# Wait for ClickHouse to be ready before accepting traffic
wait_for_clickhouse()
# Run initial full refresh (clone repos + create views)
print("[gateway] Running initial data sync + view creation...")
subprocess.run(
["python3", REFRESH_SCRIPT, "full"],
timeout=1200,
)
# Start gateway
server = ThreadingHTTPServer(("0.0.0.0", GATEWAY_PORT), GatewayHandler)
print(f"[gateway] Listening on port {GATEWAY_PORT}")
print(f"[gateway] /api/refresh β†’ on-demand git pull + view recreation")
print(f"[gateway] /api/sources β†’ current config as JSON")
print(f"[gateway] /* β†’ proxy to ClickHouse")
server.serve_forever()