diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..aeb7ec5 --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1 @@ +# Backend configuration module diff --git a/backend/config/api.py b/backend/config/api.py new file mode 100644 index 0000000..4bff8ef --- /dev/null +++ b/backend/config/api.py @@ -0,0 +1,52 @@ +""" +API Configuration - Single Source of Truth for API Versioning + +Change API_VERSION here to update all routes across the application. +Example: "v1" -> "v2" will change /api/v1/* to /api/v2/* +""" + +# ============================================================================= +# API VERSION CONFIGURATION +# ============================================================================= + +API_VERSION = "v1" + +# ============================================================================= +# DERIVED PREFIXES (auto-calculated from version) +# ============================================================================= + +# Current versioned API prefix: /api/v1 +API_PREFIX = f"/api/{API_VERSION}" + +# Legacy prefix for backward compatibility: /api +# Routes here will be deprecated but still functional +LEGACY_API_PREFIX = "/api" + +# ============================================================================= +# DEPRECATION SETTINGS +# ============================================================================= + +# When True, legacy routes (/api/*) will include deprecation warning headers +LEGACY_DEPRECATION_ENABLED = True + +# Header to add on deprecated routes +DEPRECATION_HEADER = "X-API-Deprecated" +DEPRECATION_MESSAGE = f"This endpoint is deprecated. Please use {API_PREFIX} instead." + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +def get_versioned_prefix() -> str: + """Get the current versioned API prefix.""" + return API_PREFIX + + +def get_legacy_prefix() -> str: + """Get the legacy (deprecated) API prefix.""" + return LEGACY_API_PREFIX + + +def is_legacy_route(path: str) -> bool: + """Check if a route path is using the legacy prefix.""" + return path.startswith(LEGACY_API_PREFIX) and not path.startswith(API_PREFIX) diff --git a/backend/main.py b/backend/main.py index 0ed534e..7ea4307 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,9 @@ from starlette.responses import JSONResponse import os +# Import API config (single source of truth for versioning) +from config.api import API_PREFIX, API_VERSION + # Import routers from routes.auth import router as auth_router from routes.health import router as health_router @@ -68,17 +71,20 @@ async def dispatch(self, request: Request, call_next): # ===== ROUTERS ===== - -app.include_router(health_router) -app.include_router(auth_router) -app.include_router(playground_router) -app.include_router(repos_router) -app.include_router(search_router) -app.include_router(analysis_router) -app.include_router(api_keys_router) - -# WebSocket endpoint (can't be in router easily) -app.add_api_websocket_route("/ws/index/{repo_id}", websocket_index) +# All API routes are prefixed with API_PREFIX (e.g., /api/v1) +# Route files define their sub-path (e.g., /auth, /repos) +# Final paths: /api/v1/auth, /api/v1/repos, etc. + +app.include_router(health_router) # /health stays at root (no versioning needed) +app.include_router(auth_router, prefix=API_PREFIX) +app.include_router(playground_router, prefix=API_PREFIX) +app.include_router(repos_router, prefix=API_PREFIX) +app.include_router(search_router, prefix=API_PREFIX) +app.include_router(analysis_router, prefix=API_PREFIX) +app.include_router(api_keys_router, prefix=API_PREFIX) + +# WebSocket endpoint (versioned) +app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index) # ===== ERROR HANDLERS ===== diff --git a/backend/routes/analysis.py b/backend/routes/analysis.py index 2aea1e1..cca4356 100644 --- a/backend/routes/analysis.py +++ b/backend/routes/analysis.py @@ -9,7 +9,7 @@ from services.input_validator import InputValidator from middleware.auth import require_auth, AuthContext -router = APIRouter(prefix="/api/repos", tags=["Analysis"]) +router = APIRouter(prefix="/repos", tags=["Analysis"]) class ImpactRequest(BaseModel): diff --git a/backend/routes/api_keys.py b/backend/routes/api_keys.py index f810e9d..f6f46ea 100644 --- a/backend/routes/api_keys.py +++ b/backend/routes/api_keys.py @@ -5,7 +5,7 @@ from dependencies import api_key_manager, rate_limiter, metrics from middleware.auth import require_auth, AuthContext -router = APIRouter(prefix="/api", tags=["API Keys"]) +router = APIRouter(prefix="", tags=["API Keys"]) class CreateAPIKeyRequest(BaseModel): diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 87ceff5..321af13 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -9,7 +9,7 @@ from middleware.auth import get_current_user # Create router -router = APIRouter(prefix="/api/auth", tags=["Authentication"]) +router = APIRouter(prefix="/auth", tags=["Authentication"]) # Request/Response Models diff --git a/backend/routes/playground.py b/backend/routes/playground.py index 2680d4f..51aa40d 100644 --- a/backend/routes/playground.py +++ b/backend/routes/playground.py @@ -7,7 +7,7 @@ from dependencies import indexer, cache, repo_manager from services.input_validator import InputValidator -router = APIRouter(prefix="/api/playground", tags=["Playground"]) +router = APIRouter(prefix="/playground", tags=["Playground"]) # Demo repo mapping (populated on startup) DEMO_REPO_IDS = {} diff --git a/backend/routes/repos.py b/backend/routes/repos.py index a015ecd..4bc0bea 100644 --- a/backend/routes/repos.py +++ b/backend/routes/repos.py @@ -13,7 +13,7 @@ from services.input_validator import InputValidator from middleware.auth import require_auth, AuthContext -router = APIRouter(prefix="/api/repos", tags=["Repositories"]) +router = APIRouter(prefix="/repos", tags=["Repositories"]) class AddRepoRequest(BaseModel): diff --git a/backend/routes/search.py b/backend/routes/search.py index 80141fd..a907a81 100644 --- a/backend/routes/search.py +++ b/backend/routes/search.py @@ -11,7 +11,7 @@ from services.input_validator import InputValidator from middleware.auth import require_auth, AuthContext -router = APIRouter(prefix="/api", tags=["Search"]) +router = APIRouter(prefix="", tags=["Search"]) class SearchRequest(BaseModel): diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e7b56d9..f9e087c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -4,6 +4,9 @@ """ import pytest +# Import API prefix from centralized config (single source of truth) +from config.api import API_PREFIX + class TestAPIAuthentication: """Test authentication and authorization""" @@ -15,20 +18,20 @@ def test_health_check_no_auth_required(self, client_no_auth): def test_protected_endpoint_requires_auth(self, client_no_auth): """Protected endpoints should require API key""" - response = client_no_auth.get("/api/repos") + response = client_no_auth.get(f"{API_PREFIX}/repos") assert response.status_code in [401, 403] # Either unauthorized or forbidden def test_valid_dev_key_works(self, client_no_auth, valid_headers): """Valid development API key should work in debug mode""" # Note: This tests actual auth, requires DEBUG=true and DEV_API_KEY set - response = client_no_auth.get("/api/repos", headers=valid_headers) + response = client_no_auth.get(f"{API_PREFIX}/repos", headers=valid_headers) # May return 200 or 401 depending on env setup during test assert response.status_code in [200, 401] def test_invalid_key_rejected(self, client_no_auth): """Invalid API keys should be rejected""" response = client_no_auth.get( - "/api/repos", + f"{API_PREFIX}/repos", headers={"Authorization": "Bearer invalid-random-key"} ) assert response.status_code in [401, 403] @@ -41,7 +44,7 @@ def test_reject_file_scheme_urls(self, client, valid_headers, malicious_payloads """Should block file:// URLs""" for url in malicious_payloads["file_urls"]: response = client.post( - "/api/repos", + f"{API_PREFIX}/repos", headers=valid_headers, json={"name": "test", "git_url": url} ) @@ -52,7 +55,7 @@ def test_reject_localhost_urls(self, client, valid_headers, malicious_payloads): """Should block localhost/private IP URLs""" for url in malicious_payloads["localhost_urls"]: response = client.post( - "/api/repos", + f"{API_PREFIX}/repos", headers=valid_headers, json={"name": "test", "git_url": url} ) @@ -65,7 +68,7 @@ def test_reject_invalid_repo_names(self, client, valid_headers): for name in invalid_names: response = client.post( - "/api/repos", + f"{API_PREFIX}/repos", headers=valid_headers, json={"name": name, "git_url": "https://github.com/test/repo"} ) @@ -79,7 +82,7 @@ def test_reject_sql_injection_attempts(self, client, valid_headers, malicious_pa """Should block SQL injection in search queries""" for sql_query in malicious_payloads["sql_injection"]: response = client.post( - "/api/search", + f"{API_PREFIX}/search", headers=valid_headers, json={"query": sql_query, "repo_id": "test-id"} ) @@ -90,7 +93,7 @@ def test_reject_sql_injection_attempts(self, client, valid_headers, malicious_pa def test_reject_empty_queries(self, client, valid_headers): """Should reject empty search queries""" response = client.post( - "/api/search", + f"{API_PREFIX}/search", headers=valid_headers, json={"query": "", "repo_id": "test-id"} ) @@ -100,7 +103,7 @@ def test_reject_empty_queries(self, client, valid_headers): def test_reject_oversized_queries(self, client, valid_headers): """Should reject queries over max length""" response = client.post( - "/api/search", + f"{API_PREFIX}/search", headers=valid_headers, json={"query": "a" * 1000, "repo_id": "test-id"} ) @@ -115,7 +118,7 @@ def test_reject_path_traversal_attempts(self, client, valid_headers, malicious_p """Should block path traversal in impact analysis""" for path in malicious_payloads["path_traversal"]: response = client.post( - "/api/repos/test-id/impact", + f"{API_PREFIX}/repos/test-id/impact", headers=valid_headers, json={"repo_id": "test-id", "file_path": path} ) @@ -141,7 +144,7 @@ def test_max_limits_configured(self): def test_search_results_capped(self, client, valid_headers): """Search results should be capped at maximum""" response = client.post( - "/api/search", + f"{API_PREFIX}/search", headers=valid_headers, json={ "query": "test query", diff --git a/frontend/src/components/ImpactAnalyzer.tsx b/frontend/src/components/ImpactAnalyzer.tsx index fade8f6..6130e6a 100644 --- a/frontend/src/components/ImpactAnalyzer.tsx +++ b/frontend/src/components/ImpactAnalyzer.tsx @@ -32,7 +32,7 @@ export function ImpactAnalyzer({ repoId, apiUrl, apiKey }: ImpactAnalyzerProps) setError('') try { - const response = await fetch(`${apiUrl}/api/repos/${repoId}/impact`, { + const response = await fetch(`${apiUrl}/repos/${repoId}/impact`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, diff --git a/frontend/src/components/PerformanceDashboard.tsx b/frontend/src/components/PerformanceDashboard.tsx index 80b1a5d..28411b8 100644 --- a/frontend/src/components/PerformanceDashboard.tsx +++ b/frontend/src/components/PerformanceDashboard.tsx @@ -17,7 +17,7 @@ export function PerformanceDashboard({ apiUrl, apiKey }: PerformanceProps) { const loadMetrics = async () => { try { - const response = await fetch(`${apiUrl}/api/metrics`, { + const response = await fetch(`${apiUrl}/metrics`, { headers: { 'Authorization': `Bearer ${apiKey}` } }) const result = await response.json() diff --git a/frontend/src/components/SearchPanel.tsx b/frontend/src/components/SearchPanel.tsx index 826ccea..66aaeb6 100644 --- a/frontend/src/components/SearchPanel.tsx +++ b/frontend/src/components/SearchPanel.tsx @@ -25,7 +25,7 @@ export function SearchPanel({ repoId, apiUrl, apiKey }: SearchPanelProps) { const startTime = Date.now() try { - const response = await fetch(`${apiUrl}/api/search`, { + const response = await fetch(`${apiUrl}/search`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, diff --git a/frontend/src/components/dashboard/CommandPalette.tsx b/frontend/src/components/dashboard/CommandPalette.tsx index 5ec69da..59a71a0 100644 --- a/frontend/src/components/dashboard/CommandPalette.tsx +++ b/frontend/src/components/dashboard/CommandPalette.tsx @@ -83,7 +83,7 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { const fetchRepos = async () => { try { - const response = await fetch(`${API_URL}/api/repos`, { + const response = await fetch(`${API_URL}/repos`, { headers: { 'Authorization': `Bearer ${session?.access_token}` } }) const data = await response.json() diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 181e9e2..045e856 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -27,7 +27,7 @@ export function DashboardHome() { if (!session?.access_token) return try { - const response = await fetch(`${API_URL}/api/repos`, { + const response = await fetch(`${API_URL}/repos`, { headers: { 'Authorization': `Bearer ${session.access_token}` } }) const data = await response.json() @@ -50,7 +50,7 @@ export function DashboardHome() { setLoading(true) const name = gitUrl.split('/').pop()?.replace('.git', '') || 'unknown' - const response = await fetch(`${API_URL}/api/repos`, { + const response = await fetch(`${API_URL}/repos`, { method: 'POST', headers: { 'Authorization': `Bearer ${session?.access_token}`, @@ -61,7 +61,7 @@ export function DashboardHome() { const data = await response.json() - await fetch(`${API_URL}/api/repos/${data.repo_id}/index`, { + await fetch(`${API_URL}/repos/${data.repo_id}/index`, { method: 'POST', headers: { 'Authorization': `Bearer ${session?.access_token}` } }) @@ -85,7 +85,7 @@ export function DashboardHome() { try { setLoading(true) - await fetch(`${API_URL}/api/repos/${selectedRepo}/index`, { + await fetch(`${API_URL}/repos/${selectedRepo}/index`, { method: 'POST', headers: { 'Authorization': `Bearer ${session?.access_token}` } }) diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index a9fbb01..b52f347 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -1,12 +1,59 @@ /** - * API Configuration + * API Configuration - Single Source of Truth for API Versioning * - * Centralizes API URL configuration for all frontend components. + * Change API_VERSION here to update all API calls across the frontend. + * Example: "v1" -> "v2" will change /api/v1/* to /api/v2/* */ -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' +// ============================================================================= +// BASE CONFIGURATION +// ============================================================================= -// WebSocket URL - convert http(s) to ws(s) -const WS_URL = API_URL.replace(/^http/, 'ws') +const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' -export { API_URL, WS_URL } +// ============================================================================= +// API VERSION CONFIGURATION +// ============================================================================= + +export const API_VERSION = 'v1' + +// ============================================================================= +// DERIVED URLs (auto-calculated from version) +// ============================================================================= + +// API prefix: /api/v1 +export const API_PREFIX = `/api/${API_VERSION}` + +// Full API URL: http://localhost:8000/api/v1 +export const API_URL = `${BASE_URL}${API_PREFIX}` + +// WebSocket URL: ws://localhost:8000/api/v1 +const WS_BASE = BASE_URL.replace(/^http/, 'ws') +export const WS_URL = `${WS_BASE}${API_PREFIX}` + +// Legacy URL (for backward compatibility if needed) +export const LEGACY_API_URL = `${BASE_URL}/api` + +// ============================================================================= +// ENDPOINT HELPERS +// ============================================================================= + +/** + * Build a full API endpoint URL + * @param path - Endpoint path (e.g., '/repos', '/search') + * @returns Full URL (e.g., 'http://localhost:8000/api/v1/repos') + */ +export const buildApiUrl = (path: string): string => { + const cleanPath = path.startsWith('/') ? path : `/${path}` + return `${API_URL}${cleanPath}` +} + +/** + * Build a WebSocket endpoint URL + * @param path - WebSocket path (e.g., '/ws/index/repo-123') + * @returns Full WS URL (e.g., 'ws://localhost:8000/api/v1/ws/index/repo-123') + */ +export const buildWsUrl = (path: string): string => { + const cleanPath = path.startsWith('/') ? path : `/${path}` + return `${WS_URL}${cleanPath}` +} diff --git a/frontend/src/hooks/useCachedQuery.ts b/frontend/src/hooks/useCachedQuery.ts index 946cb8b..7f8c268 100644 --- a/frontend/src/hooks/useCachedQuery.ts +++ b/frontend/src/hooks/useCachedQuery.ts @@ -49,7 +49,7 @@ export function useDependencyGraph({ repoId, apiKey, enabled = true }: UseCached queryKey: ['dependencies', repoId], queryFn: async () => { const data = await fetchWithAuth( - `${API_URL}/api/repos/${repoId}/dependencies`, + `${API_URL}/repos/${repoId}/dependencies`, apiKey ) // Save to localStorage on successful fetch @@ -77,7 +77,7 @@ export function useStyleAnalysis({ repoId, apiKey, enabled = true }: UseCachedQu queryKey: ['style-analysis', repoId], queryFn: async () => { const data = await fetchWithAuth( - `${API_URL}/api/repos/${repoId}/style-analysis`, + `${API_URL}/repos/${repoId}/style-analysis`, apiKey ) saveToCache('style-analysis', repoId, data) @@ -109,7 +109,7 @@ export function useImpactAnalysis({ queryKey: ['impact', repoId, filePath], queryFn: async () => { const data = await fetchWithAuth( - `${API_URL}/api/repos/${repoId}/impact?file_path=${encodeURIComponent(filePath)}`, + `${API_URL}/repos/${repoId}/impact?file_path=${encodeURIComponent(filePath)}`, apiKey ) saveToCache(cacheKey, repoId, data) diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 4d55a6e..7c7db46 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -114,7 +114,7 @@ export function LandingPage() { const remaining = FREE_LIMIT - searchCount useEffect(() => { - fetch(`${API_URL}/api/playground/repos`) + fetch(`${API_URL}/playground/repos`) .then(res => res.json()) .then(data => { const available = data.repos?.filter((r: any) => r.available).map((r: any) => r.id) || [] @@ -132,7 +132,7 @@ export function LandingPage() { const startTime = Date.now() try { - const response = await fetch(`${API_URL}/api/playground/search`, { + const response = await fetch(`${API_URL}/playground/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, demo_repo: selectedRepo, max_results: 10 }) diff --git a/frontend/src/pages/Playground.tsx b/frontend/src/pages/Playground.tsx index 0a6a652..ece3d8d 100644 --- a/frontend/src/pages/Playground.tsx +++ b/frontend/src/pages/Playground.tsx @@ -48,7 +48,7 @@ export function Playground({ onSignupClick }: PlaygroundProps) { const startTime = Date.now() try { - const response = await fetch(`${API_URL}/api/playground/search`, { + const response = await fetch(`${API_URL}/playground/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/mcp-server/config.py b/mcp-server/config.py new file mode 100644 index 0000000..4885fbb --- /dev/null +++ b/mcp-server/config.py @@ -0,0 +1,19 @@ +""" +API Configuration - Single Source of Truth for API Versioning + +Change API_VERSION here to update all API calls across the MCP server. +Example: "v1" -> "v2" will change /api/v1/* to /api/v2/* +""" + +# ============================================================================= +# API VERSION CONFIGURATION +# ============================================================================= + +API_VERSION = "v1" + +# ============================================================================= +# DERIVED PREFIXES (auto-calculated from version) +# ============================================================================= + +# Current versioned API prefix: /api/v1 +API_PREFIX = f"/api/{API_VERSION}" diff --git a/mcp-server/server.py b/mcp-server/server.py index 2d7c1ab..30e187e 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -14,11 +14,15 @@ import mcp.types as types from dotenv import load_dotenv +# Import API config (single source of truth for versioning) +from config import API_PREFIX + # Load environment variables load_dotenv() # Configuration -BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000") +BACKEND_BASE_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000") +BACKEND_API_URL = f"{BACKEND_BASE_URL}{API_PREFIX}" # Full versioned URL API_KEY = os.getenv("API_KEY", "dev-secret-key") # Create MCP server instance @@ -138,7 +142,7 @@ async def handle_call_tool( if name == "search_code": response = await client.post( - f"{BACKEND_API_URL}/api/search", + f"{BACKEND_API_URL}/search", json=arguments, headers=headers ) @@ -167,7 +171,7 @@ async def handle_call_tool( elif name == "list_repositories": response = await client.get( - f"{BACKEND_API_URL}/api/repos", + f"{BACKEND_API_URL}/repos", headers=headers ) response.raise_for_status() @@ -188,7 +192,7 @@ async def handle_call_tool( elif name == "get_dependency_graph": response = await client.get( - f"{BACKEND_API_URL}/api/repos/{arguments['repo_id']}/dependencies", + f"{BACKEND_API_URL}/repos/{arguments['repo_id']}/dependencies", headers=headers ) response.raise_for_status() @@ -214,7 +218,7 @@ async def handle_call_tool( elif name == "analyze_code_style": response = await client.get( - f"{BACKEND_API_URL}/api/repos/{arguments['repo_id']}/style", + f"{BACKEND_API_URL}/repos/{arguments['repo_id']}/style-analysis", headers=headers ) response.raise_for_status() @@ -245,7 +249,7 @@ async def handle_call_tool( elif name == "analyze_impact": response = await client.post( - f"{BACKEND_API_URL}/api/repos/{arguments['repo_id']}/impact", + f"{BACKEND_API_URL}/repos/{arguments['repo_id']}/impact", json={"repo_id": arguments['repo_id'], "file_path": arguments['file_path']}, headers=headers ) @@ -276,7 +280,7 @@ async def handle_call_tool( elif name == "get_repository_insights": response = await client.get( - f"{BACKEND_API_URL}/api/repos/{arguments['repo_id']}/insights", + f"{BACKEND_API_URL}/repos/{arguments['repo_id']}/insights", headers=headers ) response.raise_for_status()