diff --git a/.env.example b/.env.example index 93c76b1fa..89d169631 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,6 @@ INFOQUEST_API_KEY=your-infoquest-api-key # # WECOM_BOT_ID=your-wecom-bot-id # WECOM_BOT_SECRET=your-wecom-bot-secret + +# Set to "false" to disable Swagger UI, ReDoc, and OpenAPI schema in production +# GATEWAY_ENABLE_DOCS=false diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 4414a0203..5a786ac95 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -205,7 +205,7 @@ Configuration priority: ### Gateway API (`app/gateway/`) -FastAPI application on port 8001 with health check at `GET /health`. +FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled). **Routers**: diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 6fff53d24..5842557d2 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -216,6 +216,8 @@ def create_app() -> FastAPI: Returns: Configured FastAPI application instance. """ + config = get_gateway_config() + docs_kwargs = {"docs_url": "/docs", "redoc_url": "/redoc", "openapi_url": "/openapi.json"} if config.enable_docs else {"docs_url": None, "redoc_url": None, "openapi_url": None} app = FastAPI( title="DeerFlow API Gateway", @@ -240,9 +242,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an """, version="0.1.0", lifespan=lifespan, - docs_url="/docs", - redoc_url="/redoc", - openapi_url="/openapi.json", + **docs_kwargs, openapi_tags=[ { "name": "models", diff --git a/backend/app/gateway/config.py b/backend/app/gateway/config.py index 66f1f2a48..95221dad2 100644 --- a/backend/app/gateway/config.py +++ b/backend/app/gateway/config.py @@ -9,6 +9,7 @@ class GatewayConfig(BaseModel): host: str = Field(default="0.0.0.0", description="Host to bind the gateway server") port: int = Field(default=8001, description="Port to bind the gateway server") cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"], description="Allowed CORS origins") + enable_docs: bool = Field(default=True, description="Enable Swagger/ReDoc/OpenAPI endpoints") _gateway_config: GatewayConfig | None = None @@ -23,5 +24,6 @@ def get_gateway_config() -> GatewayConfig: host=os.getenv("GATEWAY_HOST", "0.0.0.0"), port=int(os.getenv("GATEWAY_PORT", "8001")), cors_origins=cors_origins_str.split(","), + enable_docs=os.getenv("GATEWAY_ENABLE_DOCS", "true").lower() == "true", ) return _gateway_config diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 701c0278e..3c7fe8dd7 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -320,6 +320,7 @@ models: - `NOVITA_API_KEY` - Novita API key (OpenAI-compatible endpoint) - `TAVILY_API_KEY` - Tavily search API key - `DEER_FLOW_CONFIG_PATH` - Custom config file path +- `GATEWAY_ENABLE_DOCS` - Set to `false` to disable Swagger UI (`/docs`), ReDoc (`/redoc`), and OpenAPI schema (`/openapi.json`) endpoints (default: `true`) ## Configuration Location diff --git a/backend/tests/test_gateway_docs_toggle.py b/backend/tests/test_gateway_docs_toggle.py new file mode 100644 index 000000000..54392ee2e --- /dev/null +++ b/backend/tests/test_gateway_docs_toggle.py @@ -0,0 +1,124 @@ +"""Tests for GATEWAY_ENABLE_DOCS configuration toggle. + +Verifies that Swagger UI (/docs), ReDoc (/redoc), and the OpenAPI schema +(/openapi.json) can be disabled via the GATEWAY_ENABLE_DOCS environment +variable for production deployments. +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + + +def _reset_gateway_config(): + """Reset the cached gateway config so env changes take effect.""" + import app.gateway.config as cfg + + cfg._gateway_config = None + + +@pytest.fixture(autouse=True) +def _clean_config(): + """Ensure gateway config cache is cleared before and after each test.""" + _reset_gateway_config() + yield + _reset_gateway_config() + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + + +def test_enable_docs_defaults_to_true(): + """When GATEWAY_ENABLE_DOCS is not set, enable_docs should be True.""" + with patch.dict(os.environ, {}, clear=False): + if "GATEWAY_ENABLE_DOCS" in os.environ: + del os.environ["GATEWAY_ENABLE_DOCS"] + _reset_gateway_config() + from app.gateway.config import get_gateway_config + + config = get_gateway_config() + assert config.enable_docs is True + + +def test_enable_docs_false(): + """GATEWAY_ENABLE_DOCS=false should disable docs.""" + with patch.dict(os.environ, {"GATEWAY_ENABLE_DOCS": "false"}): + _reset_gateway_config() + from app.gateway.config import get_gateway_config + + config = get_gateway_config() + assert config.enable_docs is False + + +def test_enable_docs_case_insensitive(): + """GATEWAY_ENABLE_DOCS is case-insensitive (FALSE, False, false).""" + for value in ("FALSE", "False", "false"): + with patch.dict(os.environ, {"GATEWAY_ENABLE_DOCS": value}): + _reset_gateway_config() + from app.gateway.config import get_gateway_config + + config = get_gateway_config() + assert config.enable_docs is False, f"Expected False for GATEWAY_ENABLE_DOCS={value}" + + +def test_enable_docs_unexpected_value_disables(): + """Any non-'true' value should disable docs (fail-closed).""" + for value in ("0", "no", "off", "anything"): + with patch.dict(os.environ, {"GATEWAY_ENABLE_DOCS": value}): + _reset_gateway_config() + from app.gateway.config import get_gateway_config + + config = get_gateway_config() + assert config.enable_docs is False, f"Expected False for GATEWAY_ENABLE_DOCS={value}" + + +# --------------------------------------------------------------------------- +# App-level endpoint visibility +# --------------------------------------------------------------------------- + + +def test_docs_endpoints_available_by_default(): + """With enable_docs=True (default), /docs, /redoc, /openapi.json return 200.""" + with patch.dict(os.environ, {}, clear=False): + if "GATEWAY_ENABLE_DOCS" in os.environ: + del os.environ["GATEWAY_ENABLE_DOCS"] + _reset_gateway_config() + from app.gateway.app import create_app + + app = create_app() + client = TestClient(app) + assert client.get("/docs").status_code == 200 + assert client.get("/redoc").status_code == 200 + assert client.get("/openapi.json").status_code == 200 + + +def test_docs_endpoints_disabled_when_false(): + """With GATEWAY_ENABLE_DOCS=false, /docs, /redoc, /openapi.json return 404.""" + with patch.dict(os.environ, {"GATEWAY_ENABLE_DOCS": "false"}): + _reset_gateway_config() + from app.gateway.app import create_app + + app = create_app() + client = TestClient(app) + assert client.get("/docs").status_code == 404 + assert client.get("/redoc").status_code == 404 + assert client.get("/openapi.json").status_code == 404 + + +def test_health_still_works_when_docs_disabled(): + """Disabling docs should NOT affect /health or other normal endpoints.""" + with patch.dict(os.environ, {"GATEWAY_ENABLE_DOCS": "false"}): + _reset_gateway_config() + from app.gateway.app import create_app + + app = create_app() + client = TestClient(app) + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "healthy"