diff --git a/.gitignore b/.gitignore index fe8a0ef..a06da7e 100644 --- a/.gitignore +++ b/.gitignore @@ -168,6 +168,7 @@ gateway-files/ # Hummingbot credentials and local data bots/credentials/ bots/instances/ +bots/conf/ # Local MCP configuration (project-specific overrides) .mcp.json diff --git a/main.py b/main.py index 80c1810..31d8ce8 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,10 @@ +import logging import secrets from contextlib import asynccontextmanager from typing import Annotated from urllib.parse import urlparse import logfire -import logging from dotenv import load_dotenv # Load environment variables early @@ -22,29 +22,22 @@ def patched_save_to_yml(yml_path, cm): # Apply the patch before importing hummingbot components from hummingbot.client.config import config_helpers -config_helpers.save_to_yml = patched_save_to_yml -from hummingbot.core.rate_oracle.rate_oracle import RateOracle, RATE_ORACLE_SOURCES -from hummingbot.core.gateway.gateway_http_client import GatewayHttpClient -from hummingbot.client.config.client_config_map import GatewayConfigMap +config_helpers.save_to_yml = patched_save_to_yml from fastapi import Depends, FastAPI, HTTPException, Request, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from fastapi.middleware.cors import CORSMiddleware from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from hummingbot.data_feed.market_data_provider import MarketDataProvider +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from hummingbot.client.config.client_config_map import GatewayConfigMap from hummingbot.client.config.config_crypt import ETHKeyFileSecretManger +from hummingbot.core.gateway.gateway_http_client import GatewayHttpClient +from hummingbot.core.rate_oracle.rate_oracle import RATE_ORACLE_SOURCES, RateOracle +from hummingbot.data_feed.market_data_provider import MarketDataProvider -from utils.security import BackendAPISecurity -from services.bots_orchestrator import BotsOrchestrator -from services.accounts_service import AccountsService -from services.docker_service import DockerService -from services.gateway_service import GatewayService -from services.market_data_feed_manager import MarketDataFeedManager -# from services.executor_service import ExecutorService -from utils.bot_archiver import BotArchiver -from routers import ( +from config import settings +from routers import ( # executors, accounts, archived_bots, backtesting, @@ -52,19 +45,25 @@ def patched_save_to_yml(yml_path, cm): connectors, controllers, docker, - # executors, gateway, - gateway_swap, gateway_clmm, + gateway_proxy, + gateway_swap, market_data, portfolio, rate_oracle, scripts, - trading + trading, ) +from services.accounts_service import AccountsService +from services.bots_orchestrator import BotsOrchestrator +from services.docker_service import DockerService +from services.gateway_service import GatewayService +from services.market_data_feed_manager import MarketDataFeedManager -from config import settings - +# from services.executor_service import ExecutorService +from utils.bot_archiver import BotArchiver +from utils.security import BackendAPISecurity # Set up logging configuration logging.basicConfig( @@ -233,6 +232,7 @@ async def lifespan(app: FastAPI): description="API for managing Hummingbot trading instances", version=VERSION, lifespan=lifespan, + redirect_slashes=False, ) # Add CORS middleware @@ -311,6 +311,7 @@ def auth_user( app.include_router(backtesting.router, dependencies=[Depends(auth_user)]) app.include_router(archived_bots.router, dependencies=[Depends(auth_user)]) # app.include_router(executors.router, dependencies=[Depends(auth_user)]) +app.include_router(gateway_proxy.router, dependencies=[Depends(auth_user)]) @app.get("/") async def root(): diff --git a/routers/accounts.py b/routers/accounts.py index 8d6de87..fb0c8b3 100644 --- a/routers/accounts.py +++ b/routers/accounts.py @@ -11,7 +11,7 @@ router = APIRouter(tags=["Accounts"], prefix="/accounts") -@router.get("/", response_model=List[str]) +@router.get("", response_model=List[str]) async def list_accounts(accounts_service: AccountsService = Depends(get_accounts_service)): """ Get a list of all account names in the system. diff --git a/routers/archived_bots.py b/routers/archived_bots.py index 38b42d3..04db0e2 100644 --- a/routers/archived_bots.py +++ b/routers/archived_bots.py @@ -7,7 +7,7 @@ router = APIRouter(tags=["Archived Bots"], prefix="/archived-bots") -@router.get("/", response_model=List[str]) +@router.get("", response_model=List[str]) async def list_databases(): """ List all available database files in the system. diff --git a/routers/connectors.py b/routers/connectors.py index 3c59b87..73cc45a 100644 --- a/routers/connectors.py +++ b/routers/connectors.py @@ -11,7 +11,7 @@ router = APIRouter(tags=["Connectors"], prefix="/connectors") -@router.get("/", response_model=List[str]) +@router.get("", response_model=List[str]) async def available_connectors(): """ Get a list of all available connectors. diff --git a/routers/controllers.py b/routers/controllers.py index a1a0c1e..f489029 100644 --- a/routers/controllers.py +++ b/routers/controllers.py @@ -1,7 +1,7 @@ import json -import yaml from typing import Dict, List +import yaml from fastapi import APIRouter, HTTPException from starlette import status @@ -11,7 +11,7 @@ router = APIRouter(tags=["Controllers"], prefix="/controllers") -@router.get("/", response_model=Dict[str, List[str]]) +@router.get("", response_model=Dict[str, List[str]]) async def list_controllers(): """ List all controllers organized by type. @@ -33,7 +33,7 @@ async def list_controllers(): # Controller Configuration endpoints (must come before controller type routes) -@router.get("/configs/", response_model=List[Dict]) +@router.get("/configs", response_model=List[Dict]) async def list_controller_configs(): """ List all controller configurations with metadata. diff --git a/routers/docker.py b/routers/docker.py index 7b0f828..ca72752 100644 --- a/routers/docker.py +++ b/routers/docker.py @@ -1,11 +1,11 @@ import os -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends, HTTPException +from deps import get_bot_archiver, get_docker_service from models import DockerImage -from utils.bot_archiver import BotArchiver from services.docker_service import DockerService -from deps import get_docker_service, get_bot_archiver +from utils.bot_archiver import BotArchiver router = APIRouter(tags=["Docker"], prefix="/docker") @@ -24,7 +24,7 @@ async def is_docker_running(docker_service: DockerService = Depends(get_docker_s return docker_service.is_docker_running() -@router.get("/available-images/") +@router.get("/available-images") async def available_images(image_name: str = None, docker_service: DockerService = Depends(get_docker_service)): """ Get available Docker images matching the specified name. @@ -161,7 +161,7 @@ async def start_container(container_name: str, docker_service: DockerService = D return docker_service.start_container(container_name) -@router.post("/pull-image/") +@router.post("/pull-image") async def pull_image(image: DockerImage, docker_service: DockerService = Depends(get_docker_service)): """ Initiate Docker image pull as background task. @@ -178,7 +178,7 @@ async def pull_image(image: DockerImage, docker_service: DockerService = Depends return result -@router.get("/pull-status/") +@router.get("/pull-status") async def get_pull_status(docker_service: DockerService = Depends(get_docker_service)): """ Get status of all pull operations. diff --git a/routers/gateway_proxy.py b/routers/gateway_proxy.py new file mode 100644 index 0000000..d735c13 --- /dev/null +++ b/routers/gateway_proxy.py @@ -0,0 +1,131 @@ +""" +Gateway Proxy Router + +Catch-all router that forwards requests to Gateway server unchanged. +Dashboard calls /api/gateway-proxy/* and this router forwards to Gateway at localhost:15888/*. + +This allows the dashboard to access all Gateway endpoints through the API without +needing each endpoint to be explicitly defined. + +Examples: + GET /api/gateway-proxy/wallet -> GET localhost:15888/wallet + POST /api/gateway-proxy/wallet/add -> POST localhost:15888/wallet/add + GET /api/gateway-proxy/config -> GET localhost:15888/config + GET /api/gateway-proxy/trading/clmm/positions-owned -> GET localhost:15888/trading/clmm/positions-owned +""" + +import json +import logging + +import aiohttp +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import JSONResponse + +from deps import get_accounts_service +from services.accounts_service import AccountsService + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Gateway Proxy"], prefix="/gateway-proxy") + + +@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +async def forward_to_gateway( + path: str, + request: Request, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Forward request to Gateway server unchanged. + + This catch-all route forwards any request to /api/gateway-proxy/* to the Gateway server. + The request body, headers, and query parameters are passed through unchanged. + The response from Gateway is returned unchanged. + + Examples: + GET /api/gateway-proxy/wallet -> GET localhost:15888/wallet + POST /api/gateway-proxy/wallet/add -> POST localhost:15888/wallet/add + GET /api/gateway-proxy/config -> GET localhost:15888/config + """ + gateway_client = accounts_service.gateway_client + gateway_url = gateway_client.base_url + + # Build target URL + target_url = f"{gateway_url}/{path}" + + # Get query parameters + query_params = dict(request.query_params) + + # Get request body if present + body = None + if request.method in ["POST", "PUT", "PATCH", "DELETE"]: + try: + body = await request.json() + except Exception: + # No JSON body or invalid JSON - that's OK for some requests + body = None + + try: + # Get or create aiohttp session + session = await gateway_client._get_session() + + # Forward the request + async with session.request( + method=request.method, + url=target_url, + params=query_params if query_params else None, + json=body if body else None, + ) as response: + # Read response body + response_body = await response.read() + + # Try to parse as JSON, otherwise return as-is + content_type = response.headers.get("Content-Type", "") + + if "application/json" in content_type: + try: + json_body = json.loads(response_body) + return JSONResponse( + content=json_body, + status_code=response.status, + ) + except Exception: + pass + + # Return raw response + return Response( + content=response_body, + status_code=response.status, + media_type=content_type or "application/octet-stream", + ) + + except aiohttp.ClientError as e: + logger.error(f"Gateway proxy error: {e}") + raise HTTPException( + status_code=503, + detail=f"Gateway service unavailable: {str(e)}" + ) + except Exception as e: + logger.error(f"Gateway proxy error: {e}") + raise HTTPException( + status_code=500, + detail=f"Gateway proxy error: {str(e)}" + ) + + +# Also expose the root endpoint for health checks +@router.get("") +async def gateway_root( + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Gateway health check. + Forwards to Gateway root endpoint to check if it's online. + """ + gateway_client = accounts_service.gateway_client + result = await gateway_client._request("GET", "") + if result is None: + raise HTTPException(status_code=503, detail="Gateway service unavailable") + if "error" in result: + raise HTTPException(status_code=result.get("status", 500), detail=result["error"]) + return result diff --git a/routers/scripts.py b/routers/scripts.py index c4c1840..bea8628 100644 --- a/routers/scripts.py +++ b/routers/scripts.py @@ -1,7 +1,7 @@ import json -import yaml from typing import Dict, List +import yaml from fastapi import APIRouter, HTTPException from starlette import status @@ -11,7 +11,7 @@ router = APIRouter(tags=["Scripts"], prefix="/scripts") -@router.get("/", response_model=List[str]) +@router.get("", response_model=List[str]) async def list_scripts(): """ List all available scripts. @@ -23,7 +23,7 @@ async def list_scripts(): # Script Configuration endpoints (must come before script name routes) -@router.get("/configs/", response_model=List[Dict]) +@router.get("/configs", response_model=List[Dict]) async def list_script_configs(): """ List all script configurations with metadata.