Skip to content

Commit 4f62787

Browse files
authored
Merge pull request #88 from DevanshuNEU/feature/issue-55-api-versioning
feat(api): Add centralized API versioning system (#55)
2 parents 436648f + 46ba92a commit 4f62787

21 files changed

Lines changed: 187 additions & 55 deletions

backend/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Backend configuration module

backend/config/api.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
API Configuration - Single Source of Truth for API Versioning
3+
4+
Change API_VERSION here to update all routes across the application.
5+
Example: "v1" -> "v2" will change /api/v1/* to /api/v2/*
6+
"""
7+
8+
# =============================================================================
9+
# API VERSION CONFIGURATION
10+
# =============================================================================
11+
12+
API_VERSION = "v1"
13+
14+
# =============================================================================
15+
# DERIVED PREFIXES (auto-calculated from version)
16+
# =============================================================================
17+
18+
# Current versioned API prefix: /api/v1
19+
API_PREFIX = f"/api/{API_VERSION}"
20+
21+
# Legacy prefix for backward compatibility: /api
22+
# Routes here will be deprecated but still functional
23+
LEGACY_API_PREFIX = "/api"
24+
25+
# =============================================================================
26+
# DEPRECATION SETTINGS
27+
# =============================================================================
28+
29+
# When True, legacy routes (/api/*) will include deprecation warning headers
30+
LEGACY_DEPRECATION_ENABLED = True
31+
32+
# Header to add on deprecated routes
33+
DEPRECATION_HEADER = "X-API-Deprecated"
34+
DEPRECATION_MESSAGE = f"This endpoint is deprecated. Please use {API_PREFIX} instead."
35+
36+
# =============================================================================
37+
# HELPER FUNCTIONS
38+
# =============================================================================
39+
40+
def get_versioned_prefix() -> str:
41+
"""Get the current versioned API prefix."""
42+
return API_PREFIX
43+
44+
45+
def get_legacy_prefix() -> str:
46+
"""Get the legacy (deprecated) API prefix."""
47+
return LEGACY_API_PREFIX
48+
49+
50+
def is_legacy_route(path: str) -> bool:
51+
"""Check if a route path is using the legacy prefix."""
52+
return path.startswith(LEGACY_API_PREFIX) and not path.startswith(API_PREFIX)

backend/main.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from starlette.responses import JSONResponse
1111
import os
1212

13+
# Import API config (single source of truth for versioning)
14+
from config.api import API_PREFIX, API_VERSION
15+
1316
# Import routers
1417
from routes.auth import router as auth_router
1518
from routes.health import router as health_router
@@ -68,17 +71,20 @@ async def dispatch(self, request: Request, call_next):
6871

6972

7073
# ===== ROUTERS =====
71-
72-
app.include_router(health_router)
73-
app.include_router(auth_router)
74-
app.include_router(playground_router)
75-
app.include_router(repos_router)
76-
app.include_router(search_router)
77-
app.include_router(analysis_router)
78-
app.include_router(api_keys_router)
79-
80-
# WebSocket endpoint (can't be in router easily)
81-
app.add_api_websocket_route("/ws/index/{repo_id}", websocket_index)
74+
# All API routes are prefixed with API_PREFIX (e.g., /api/v1)
75+
# Route files define their sub-path (e.g., /auth, /repos)
76+
# Final paths: /api/v1/auth, /api/v1/repos, etc.
77+
78+
app.include_router(health_router) # /health stays at root (no versioning needed)
79+
app.include_router(auth_router, prefix=API_PREFIX)
80+
app.include_router(playground_router, prefix=API_PREFIX)
81+
app.include_router(repos_router, prefix=API_PREFIX)
82+
app.include_router(search_router, prefix=API_PREFIX)
83+
app.include_router(analysis_router, prefix=API_PREFIX)
84+
app.include_router(api_keys_router, prefix=API_PREFIX)
85+
86+
# WebSocket endpoint (versioned)
87+
app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index)
8288

8389

8490
# ===== ERROR HANDLERS =====

backend/routes/analysis.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from services.input_validator import InputValidator
1010
from middleware.auth import require_auth, AuthContext
1111

12-
router = APIRouter(prefix="/api/repos", tags=["Analysis"])
12+
router = APIRouter(prefix="/repos", tags=["Analysis"])
1313

1414

1515
class ImpactRequest(BaseModel):

backend/routes/api_keys.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dependencies import api_key_manager, rate_limiter, metrics
66
from middleware.auth import require_auth, AuthContext
77

8-
router = APIRouter(prefix="/api", tags=["API Keys"])
8+
router = APIRouter(prefix="", tags=["API Keys"])
99

1010

1111
class CreateAPIKeyRequest(BaseModel):

backend/routes/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from middleware.auth import get_current_user
1010

1111
# Create router
12-
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
12+
router = APIRouter(prefix="/auth", tags=["Authentication"])
1313

1414

1515
# Request/Response Models

backend/routes/playground.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dependencies import indexer, cache, repo_manager
88
from services.input_validator import InputValidator
99

10-
router = APIRouter(prefix="/api/playground", tags=["Playground"])
10+
router = APIRouter(prefix="/playground", tags=["Playground"])
1111

1212
# Demo repo mapping (populated on startup)
1313
DEMO_REPO_IDS = {}

backend/routes/repos.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from services.input_validator import InputValidator
1414
from middleware.auth import require_auth, AuthContext
1515

16-
router = APIRouter(prefix="/api/repos", tags=["Repositories"])
16+
router = APIRouter(prefix="/repos", tags=["Repositories"])
1717

1818

1919
class AddRepoRequest(BaseModel):

backend/routes/search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from services.input_validator import InputValidator
1212
from middleware.auth import require_auth, AuthContext
1313

14-
router = APIRouter(prefix="/api", tags=["Search"])
14+
router = APIRouter(prefix="", tags=["Search"])
1515

1616

1717
class SearchRequest(BaseModel):

backend/tests/test_api.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"""
55
import pytest
66

7+
# Import API prefix from centralized config (single source of truth)
8+
from config.api import API_PREFIX
9+
710

811
class TestAPIAuthentication:
912
"""Test authentication and authorization"""
@@ -15,20 +18,20 @@ def test_health_check_no_auth_required(self, client_no_auth):
1518

1619
def test_protected_endpoint_requires_auth(self, client_no_auth):
1720
"""Protected endpoints should require API key"""
18-
response = client_no_auth.get("/api/repos")
21+
response = client_no_auth.get(f"{API_PREFIX}/repos")
1922
assert response.status_code in [401, 403] # Either unauthorized or forbidden
2023

2124
def test_valid_dev_key_works(self, client_no_auth, valid_headers):
2225
"""Valid development API key should work in debug mode"""
2326
# Note: This tests actual auth, requires DEBUG=true and DEV_API_KEY set
24-
response = client_no_auth.get("/api/repos", headers=valid_headers)
27+
response = client_no_auth.get(f"{API_PREFIX}/repos", headers=valid_headers)
2528
# May return 200 or 401 depending on env setup during test
2629
assert response.status_code in [200, 401]
2730

2831
def test_invalid_key_rejected(self, client_no_auth):
2932
"""Invalid API keys should be rejected"""
3033
response = client_no_auth.get(
31-
"/api/repos",
34+
f"{API_PREFIX}/repos",
3235
headers={"Authorization": "Bearer invalid-random-key"}
3336
)
3437
assert response.status_code in [401, 403]
@@ -41,7 +44,7 @@ def test_reject_file_scheme_urls(self, client, valid_headers, malicious_payloads
4144
"""Should block file:// URLs"""
4245
for url in malicious_payloads["file_urls"]:
4346
response = client.post(
44-
"/api/repos",
47+
f"{API_PREFIX}/repos",
4548
headers=valid_headers,
4649
json={"name": "test", "git_url": url}
4750
)
@@ -52,7 +55,7 @@ def test_reject_localhost_urls(self, client, valid_headers, malicious_payloads):
5255
"""Should block localhost/private IP URLs"""
5356
for url in malicious_payloads["localhost_urls"]:
5457
response = client.post(
55-
"/api/repos",
58+
f"{API_PREFIX}/repos",
5659
headers=valid_headers,
5760
json={"name": "test", "git_url": url}
5861
)
@@ -65,7 +68,7 @@ def test_reject_invalid_repo_names(self, client, valid_headers):
6568

6669
for name in invalid_names:
6770
response = client.post(
68-
"/api/repos",
71+
f"{API_PREFIX}/repos",
6972
headers=valid_headers,
7073
json={"name": name, "git_url": "https://github.com/test/repo"}
7174
)
@@ -79,7 +82,7 @@ def test_reject_sql_injection_attempts(self, client, valid_headers, malicious_pa
7982
"""Should block SQL injection in search queries"""
8083
for sql_query in malicious_payloads["sql_injection"]:
8184
response = client.post(
82-
"/api/search",
85+
f"{API_PREFIX}/search",
8386
headers=valid_headers,
8487
json={"query": sql_query, "repo_id": "test-id"}
8588
)
@@ -90,7 +93,7 @@ def test_reject_sql_injection_attempts(self, client, valid_headers, malicious_pa
9093
def test_reject_empty_queries(self, client, valid_headers):
9194
"""Should reject empty search queries"""
9295
response = client.post(
93-
"/api/search",
96+
f"{API_PREFIX}/search",
9497
headers=valid_headers,
9598
json={"query": "", "repo_id": "test-id"}
9699
)
@@ -100,7 +103,7 @@ def test_reject_empty_queries(self, client, valid_headers):
100103
def test_reject_oversized_queries(self, client, valid_headers):
101104
"""Should reject queries over max length"""
102105
response = client.post(
103-
"/api/search",
106+
f"{API_PREFIX}/search",
104107
headers=valid_headers,
105108
json={"query": "a" * 1000, "repo_id": "test-id"}
106109
)
@@ -115,7 +118,7 @@ def test_reject_path_traversal_attempts(self, client, valid_headers, malicious_p
115118
"""Should block path traversal in impact analysis"""
116119
for path in malicious_payloads["path_traversal"]:
117120
response = client.post(
118-
"/api/repos/test-id/impact",
121+
f"{API_PREFIX}/repos/test-id/impact",
119122
headers=valid_headers,
120123
json={"repo_id": "test-id", "file_path": path}
121124
)
@@ -141,7 +144,7 @@ def test_max_limits_configured(self):
141144
def test_search_results_capped(self, client, valid_headers):
142145
"""Search results should be capped at maximum"""
143146
response = client.post(
144-
"/api/search",
147+
f"{API_PREFIX}/search",
145148
headers=valid_headers,
146149
json={
147150
"query": "test query",

0 commit comments

Comments
 (0)