Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Backend configuration module
52 changes: 52 additions & 0 deletions backend/config/api.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 17 additions & 11 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =====
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/playground.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
25 changes: 14 additions & 11 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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]
Expand All @@ -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}
)
Expand All @@ -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}
)
Expand All @@ -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"}
)
Expand All @@ -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"}
)
Expand All @@ -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"}
)
Expand All @@ -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"}
)
Expand All @@ -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}
)
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ImpactAnalyzer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/PerformanceDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/SearchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/dashboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/dashboard/DashboardHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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}`,
Expand All @@ -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}` }
})
Expand All @@ -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}` }
})
Expand Down
59 changes: 53 additions & 6 deletions frontend/src/config/api.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
Loading