summaryrefslogtreecommitdiff
path: root/services/api
diff options
context:
space:
mode:
Diffstat (limited to 'services/api')
-rw-r--r--services/api/Dockerfile15
-rw-r--r--services/api/pyproject.toml6
-rw-r--r--services/api/src/trading_api/dependencies/__init__.py0
-rw-r--r--services/api/src/trading_api/dependencies/auth.py29
-rw-r--r--services/api/src/trading_api/main.py50
-rw-r--r--services/api/src/trading_api/routers/orders.py29
-rw-r--r--services/api/src/trading_api/routers/portfolio.py22
-rw-r--r--services/api/src/trading_api/routers/strategies.py7
-rw-r--r--services/api/tests/test_api.py1
-rw-r--r--services/api/tests/test_orders_router.py6
-rw-r--r--services/api/tests/test_portfolio_router.py6
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