diff options
Diffstat (limited to 'services/api')
| -rw-r--r-- | services/api/Dockerfile | 15 | ||||
| -rw-r--r-- | services/api/pyproject.toml | 6 | ||||
| -rw-r--r-- | services/api/src/trading_api/dependencies/__init__.py | 0 | ||||
| -rw-r--r-- | services/api/src/trading_api/dependencies/auth.py | 29 | ||||
| -rw-r--r-- | services/api/src/trading_api/main.py | 50 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/orders.py | 29 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/portfolio.py | 22 | ||||
| -rw-r--r-- | services/api/src/trading_api/routers/strategies.py | 7 | ||||
| -rw-r--r-- | services/api/tests/test_api.py | 1 | ||||
| -rw-r--r-- | services/api/tests/test_orders_router.py | 6 | ||||
| -rw-r--r-- | services/api/tests/test_portfolio_router.py | 6 |
11 files changed, 133 insertions, 38 deletions
diff --git a/services/api/Dockerfile b/services/api/Dockerfile index b942075..93d2b75 100644 --- a/services/api/Dockerfile +++ b/services/api/Dockerfile @@ -1,11 +1,18 @@ -FROM python:3.12-slim +FROM python:3.12-slim AS builder WORKDIR /app COPY shared/ shared/ RUN pip install --no-cache-dir ./shared COPY services/api/ services/api/ RUN pip install --no-cache-dir ./services/api -COPY services/strategy-engine/strategies/ /app/strategies/ COPY services/strategy-engine/ services/strategy-engine/ RUN pip install --no-cache-dir ./services/strategy-engine -ENV PYTHONPATH=/app -CMD ["uvicorn", "trading_api.main:app", "--host", "0.0.0.0", "--port", "8000"] + +FROM python:3.12-slim +RUN useradd -r -s /bin/false appuser +WORKDIR /app +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin +COPY services/strategy-engine/strategies/ /app/strategies/ +ENV PYTHONPATH=/app STRATEGIES_DIR=/app/strategies +USER appuser +CMD ["uvicorn", "trading_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "30"] diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml index fd2598d..95099d2 100644 --- a/services/api/pyproject.toml +++ b/services/api/pyproject.toml @@ -3,11 +3,7 @@ name = "trading-api" version = "0.1.0" description = "REST API for the trading platform" requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.110", - "uvicorn>=0.27", - "trading-shared", -] +dependencies = ["fastapi>=0.110,<1", "uvicorn>=0.27,<1", "slowapi>=0.1.9,<1", "trading-shared"] [project.optional-dependencies] dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"] diff --git a/services/api/src/trading_api/dependencies/__init__.py b/services/api/src/trading_api/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/api/src/trading_api/dependencies/__init__.py diff --git a/services/api/src/trading_api/dependencies/auth.py b/services/api/src/trading_api/dependencies/auth.py new file mode 100644 index 0000000..a5e76c1 --- /dev/null +++ b/services/api/src/trading_api/dependencies/auth.py @@ -0,0 +1,29 @@ +"""Bearer token authentication dependency.""" + +import logging + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from shared.config import Settings + +logger = logging.getLogger(__name__) + +_security = HTTPBearer(auto_error=False) +_settings = Settings() + + +async def verify_token( + credentials: HTTPAuthorizationCredentials | None = Depends(_security), +) -> None: + """Verify Bearer token. Skip auth if API_AUTH_TOKEN is not configured.""" + token = _settings.api_auth_token.get_secret_value() + if not token: + return # Auth disabled in dev mode + + if credentials is None or credentials.credentials != token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/services/api/src/trading_api/main.py b/services/api/src/trading_api/main.py index 39f7b43..05c6d2f 100644 --- a/services/api/src/trading_api/main.py +++ b/services/api/src/trading_api/main.py @@ -1,33 +1,71 @@ """Trading Platform REST API.""" +import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address from shared.config import Settings from shared.db import Database +from trading_api.dependencies.auth import verify_token +from trading_api.routers import orders, portfolio, strategies -from trading_api.routers import portfolio, orders, strategies +logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): settings = Settings() - app.state.db = Database(settings.database_url) + if not settings.api_auth_token.get_secret_value(): + logger.warning("API_AUTH_TOKEN not set — authentication is disabled") + app.state.db = Database(settings.database_url.get_secret_value()) await app.state.db.connect() yield await app.state.db.close() +cfg = Settings() + +limiter = Limiter(key_func=get_remote_address) + app = FastAPI( title="Trading Platform API", version="0.1.0", lifespan=lifespan, ) -app.include_router(portfolio.router, prefix="/api/v1/portfolio", tags=["portfolio"]) -app.include_router(orders.router, prefix="/api/v1/orders", tags=["orders"]) -app.include_router(strategies.router, prefix="/api/v1/strategies", tags=["strategies"]) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +app.add_middleware( + CORSMiddleware, + allow_origins=cfg.cors_origins.split(","), + allow_methods=["GET", "POST"], + allow_headers=["Authorization", "Content-Type"], +) + +app.include_router( + portfolio.router, + prefix="/api/v1/portfolio", + tags=["portfolio"], + dependencies=[Depends(verify_token)], +) +app.include_router( + orders.router, + prefix="/api/v1/orders", + tags=["orders"], + dependencies=[Depends(verify_token)], +) +app.include_router( + strategies.router, + prefix="/api/v1/strategies", + tags=["strategies"], + dependencies=[Depends(verify_token)], +) @app.get("/health") diff --git a/services/api/src/trading_api/routers/orders.py b/services/api/src/trading_api/routers/orders.py index c69dc10..b664e2a 100644 --- a/services/api/src/trading_api/routers/orders.py +++ b/services/api/src/trading_api/routers/orders.py @@ -2,17 +2,23 @@ import logging -from fastapi import APIRouter, HTTPException, Request -from shared.sa_models import OrderRow, SignalRow +from fastapi import APIRouter, HTTPException, Query, Request +from slowapi import Limiter +from slowapi.util import get_remote_address from sqlalchemy import select +from sqlalchemy.exc import OperationalError + +from shared.sa_models import OrderRow, SignalRow logger = logging.getLogger(__name__) router = APIRouter() +limiter = Limiter(key_func=get_remote_address) @router.get("/") -async def get_orders(request: Request, limit: int = 50): +@limiter.limit("60/minute") +async def get_orders(request: Request, limit: int = Query(50, ge=1, le=1000)): """Get recent orders.""" try: db = request.app.state.db @@ -35,13 +41,17 @@ async def get_orders(request: Request, limit: int = 50): } for r in rows ] + except OperationalError as exc: + logger.error("Database error fetching orders: %s", exc) + raise HTTPException(status_code=503, detail="Database unavailable") from exc except Exception as exc: - logger.error("Failed to get orders: %s", exc) - raise HTTPException(status_code=500, detail="Failed to retrieve orders") + logger.error("Failed to get orders: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to retrieve orders") from exc @router.get("/signals") -async def get_signals(request: Request, limit: int = 50): +@limiter.limit("60/minute") +async def get_signals(request: Request, limit: int = Query(50, ge=1, le=1000)): """Get recent signals.""" try: db = request.app.state.db @@ -62,6 +72,9 @@ async def get_signals(request: Request, limit: int = 50): } for r in rows ] + except OperationalError as exc: + logger.error("Database error fetching signals: %s", exc) + raise HTTPException(status_code=503, detail="Database unavailable") from exc except Exception as exc: - logger.error("Failed to get signals: %s", exc) - raise HTTPException(status_code=500, detail="Failed to retrieve signals") + logger.error("Failed to get signals: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to retrieve signals") from exc diff --git a/services/api/src/trading_api/routers/portfolio.py b/services/api/src/trading_api/routers/portfolio.py index d76d85d..56bee7c 100644 --- a/services/api/src/trading_api/routers/portfolio.py +++ b/services/api/src/trading_api/routers/portfolio.py @@ -2,9 +2,11 @@ import logging -from fastapi import APIRouter, HTTPException, Request -from shared.sa_models import PositionRow +from fastapi import APIRouter, HTTPException, Query, Request from sqlalchemy import select +from sqlalchemy.exc import OperationalError + +from shared.sa_models import PositionRow logger = logging.getLogger(__name__) @@ -29,13 +31,16 @@ async def get_positions(request: Request): } for r in rows ] + except OperationalError as exc: + logger.error("Database error fetching positions: %s", exc) + raise HTTPException(status_code=503, detail="Database unavailable") from exc except Exception as exc: - logger.error("Failed to get positions: %s", exc) - raise HTTPException(status_code=500, detail="Failed to retrieve positions") + logger.error("Failed to get positions: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to retrieve positions") from exc @router.get("/snapshots") -async def get_snapshots(request: Request, days: int = 30): +async def get_snapshots(request: Request, days: int = Query(30, ge=1, le=365)): """Get portfolio snapshots for the last N days.""" try: db = request.app.state.db @@ -49,6 +54,9 @@ async def get_snapshots(request: Request, days: int = 30): } for s in snapshots ] + except OperationalError as exc: + logger.error("Database error fetching snapshots: %s", exc) + raise HTTPException(status_code=503, detail="Database unavailable") from exc except Exception as exc: - logger.error("Failed to get snapshots: %s", exc) - raise HTTPException(status_code=500, detail="Failed to retrieve snapshots") + logger.error("Failed to get snapshots: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to retrieve snapshots") from exc diff --git a/services/api/src/trading_api/routers/strategies.py b/services/api/src/trading_api/routers/strategies.py index 7ddd54e..157094c 100644 --- a/services/api/src/trading_api/routers/strategies.py +++ b/services/api/src/trading_api/routers/strategies.py @@ -42,6 +42,9 @@ async def list_strategies(): } for s in strategies ] + except (ImportError, FileNotFoundError) as exc: + logger.error("Strategy loading error: %s", exc) + raise HTTPException(status_code=503, detail="Strategy engine unavailable") from exc except Exception as exc: - logger.error("Failed to list strategies: %s", exc) - raise HTTPException(status_code=500, detail="Failed to list strategies") + logger.error("Failed to list strategies: %s", exc, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to list strategies") from exc diff --git a/services/api/tests/test_api.py b/services/api/tests/test_api.py index 669143b..f3b0a47 100644 --- a/services/api/tests/test_api.py +++ b/services/api/tests/test_api.py @@ -1,6 +1,7 @@ """Tests for the REST API.""" from unittest.mock import AsyncMock, patch + from fastapi.testclient import TestClient diff --git a/services/api/tests/test_orders_router.py b/services/api/tests/test_orders_router.py index 0658619..52252c5 100644 --- a/services/api/tests/test_orders_router.py +++ b/services/api/tests/test_orders_router.py @@ -1,10 +1,10 @@ """Tests for orders API router.""" -import pytest from unittest.mock import AsyncMock, MagicMock -from fastapi.testclient import TestClient -from fastapi import FastAPI +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from trading_api.routers.orders import router diff --git a/services/api/tests/test_portfolio_router.py b/services/api/tests/test_portfolio_router.py index 3bd1b2c..8cd8ff8 100644 --- a/services/api/tests/test_portfolio_router.py +++ b/services/api/tests/test_portfolio_router.py @@ -1,11 +1,11 @@ """Tests for portfolio API router.""" -import pytest from decimal import Decimal from unittest.mock import AsyncMock, MagicMock -from fastapi.testclient import TestClient -from fastapi import FastAPI +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from trading_api.routers.portfolio import router |
