DevWizard-Vandan commited on
Commit
cede2ad
·
1 Parent(s): da1cd75

fix(api): mount Next.js frontend as default app and prefix all API routes under /api

Browse files
synthra/api/app.py CHANGED
@@ -2,9 +2,11 @@ import os
2
  import logging
3
  import time
4
  from contextlib import asynccontextmanager
5
- from typing import AsyncGenerator
6
- from fastapi import FastAPI
7
  from fastapi.staticfiles import StaticFiles
 
 
8
 
9
  from synthra.api.routers import health, status, campaigns, auth
10
  from synthra.api.state import ServiceState
@@ -14,6 +16,28 @@ logger = logging.getLogger(__name__)
14
  _START_TIME: float = 0.0
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  @asynccontextmanager
18
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
19
  """Handle startup and shutdown lifecycle events."""
@@ -47,12 +71,6 @@ def create_app() -> FastAPI:
47
  redoc_url="/redoc",
48
  )
49
 
50
- # Root-level router registration (backward compatibility for existing tests)
51
- application.include_router(health.router)
52
- application.include_router(status.router)
53
- application.include_router(campaigns.router)
54
- application.include_router(auth.router)
55
-
56
  # Prefix-level router registration (production frontend)
57
  application.include_router(health.router, prefix="/api")
58
  application.include_router(status.router, prefix="/api")
@@ -68,15 +86,6 @@ def create_app() -> FastAPI:
68
  "status": "running",
69
  }
70
 
71
- @application.get("/", tags=["root"])
72
- async def root() -> dict[str, str]:
73
- """Root endpoint returning service identity."""
74
- return {
75
- "service": "Synthra",
76
- "version": "0.1.0",
77
- "status": "running",
78
- }
79
-
80
  # Mount Next.js static exported HTML
81
  root_dir = os.path.dirname(
82
  os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -84,7 +93,7 @@ def create_app() -> FastAPI:
84
  frontend_out = os.path.join(root_dir, "frontend", "out")
85
  if os.path.exists(frontend_out):
86
  application.mount(
87
- "/", StaticFiles(directory=frontend_out, html=True), name="static"
88
  )
89
 
90
  return application
 
2
  import logging
3
  import time
4
  from contextlib import asynccontextmanager
5
+ from typing import Any, AsyncGenerator
6
+ from fastapi import FastAPI, HTTPException
7
  from fastapi.staticfiles import StaticFiles
8
+ from fastapi.responses import FileResponse
9
+ from starlette.exceptions import HTTPException as StarletteHTTPException
10
 
11
  from synthra.api.routers import health, status, campaigns, auth
12
  from synthra.api.state import ServiceState
 
16
  _START_TIME: float = 0.0
17
 
18
 
19
+ class SPAStaticFiles(StaticFiles):
20
+ """Custom StaticFiles subclass supporting SPA routing fallback to index.html."""
21
+
22
+ async def get_response(self, path: str, scope: Any) -> Any:
23
+ # Check if the path is an API path (normalizing path separators for cross-platform compatibility)
24
+ clean_path = path.replace("\\", "/").lstrip("/")
25
+ if clean_path.startswith("api/") or clean_path == "api":
26
+ raise HTTPException(status_code=404, detail="Not Found")
27
+
28
+ try:
29
+ response = await super().get_response(path, scope)
30
+ if response.status_code == 404:
31
+ return FileResponse(os.path.join(self.directory, "index.html"))
32
+ return response
33
+ except (HTTPException, StarletteHTTPException) as ex:
34
+ if ex.status_code == 404:
35
+ return FileResponse(os.path.join(self.directory, "index.html"))
36
+ raise ex
37
+ except Exception:
38
+ return FileResponse(os.path.join(self.directory, "index.html"))
39
+
40
+
41
  @asynccontextmanager
42
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
43
  """Handle startup and shutdown lifecycle events."""
 
71
  redoc_url="/redoc",
72
  )
73
 
 
 
 
 
 
 
74
  # Prefix-level router registration (production frontend)
75
  application.include_router(health.router, prefix="/api")
76
  application.include_router(status.router, prefix="/api")
 
86
  "status": "running",
87
  }
88
 
 
 
 
 
 
 
 
 
 
89
  # Mount Next.js static exported HTML
90
  root_dir = os.path.dirname(
91
  os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
93
  frontend_out = os.path.join(root_dir, "frontend", "out")
94
  if os.path.exists(frontend_out):
95
  application.mount(
96
+ "/", SPAStaticFiles(directory=frontend_out, html=True), name="static"
97
  )
98
 
99
  return application
tests/api/test_app.py CHANGED
@@ -47,8 +47,8 @@ def client() -> Generator[TestClient, None, None]:
47
 
48
 
49
  def test_root_returns_service_identity(client: TestClient) -> None:
50
- """GET / returns service name, version, and running status."""
51
- response = client.get("/")
52
  assert response.status_code == 200
53
  body = response.json()
54
  assert body["service"] == "Synthra"
@@ -62,18 +62,18 @@ def test_root_returns_service_identity(client: TestClient) -> None:
62
 
63
 
64
  def test_health_returns_healthy(client: TestClient) -> None:
65
- """GET /health returns status=healthy."""
66
- response = client.get("/health")
67
  assert response.status_code == 200
68
  assert response.json() == {"status": "healthy"}
69
 
70
 
71
  def test_health_is_fast(client: TestClient) -> None:
72
- """GET /health must complete without touching state or DB."""
73
  import time
74
 
75
  start = time.perf_counter()
76
- response = client.get("/health")
77
  elapsed = time.perf_counter() - start
78
 
79
  assert response.status_code == 200
@@ -87,8 +87,8 @@ def test_health_is_fast(client: TestClient) -> None:
87
 
88
 
89
  def test_status_returns_expected_schema(client: TestClient) -> None:
90
- """GET /status returns version, uptime_seconds, and memory sub-object."""
91
- response = client.get("/status")
92
  assert response.status_code == 200
93
  body = response.json()
94
 
@@ -108,8 +108,8 @@ def test_status_returns_expected_schema(client: TestClient) -> None:
108
 
109
 
110
  def test_campaigns_list_returns_empty(client: TestClient) -> None:
111
- """GET /campaigns returns empty list and zero total."""
112
- response = client.get("/campaigns")
113
  assert response.status_code == 200
114
  body = response.json()
115
  assert body["campaigns"] == []
@@ -136,12 +136,12 @@ def test_state_module_importable() -> None:
136
 
137
 
138
  def test_run_campaign_endpoint(client: TestClient) -> None:
139
- """POST /campaigns/{campaign_id}/run calls governor.run_campaign."""
140
  from synthra.api.app import ServiceState
141
 
142
  mock_state = ServiceState.get_instance()
143
 
144
- response = client.post("/campaigns/CMP-0001/run")
145
  assert response.status_code == 200
146
  assert response.json() == {
147
  "status": "success",
@@ -151,13 +151,13 @@ def test_run_campaign_endpoint(client: TestClient) -> None:
151
 
152
 
153
  def test_run_campaign_endpoint_error(client: TestClient) -> None:
154
- """POST /campaigns/{campaign_id}/run handles errors properly."""
155
  from synthra.api.app import ServiceState
156
 
157
  mock_state = ServiceState.get_instance()
158
  mock_state.governor.run_campaign.side_effect = ValueError("Some campaign error")
159
 
160
- response = client.post("/campaigns/CMP-0001/run")
161
  assert response.status_code == 200
162
  assert response.json() == {
163
  "status": "error",
 
47
 
48
 
49
  def test_root_returns_service_identity(client: TestClient) -> None:
50
+ """GET /api returns service name, version, and running status."""
51
+ response = client.get("/api")
52
  assert response.status_code == 200
53
  body = response.json()
54
  assert body["service"] == "Synthra"
 
62
 
63
 
64
  def test_health_returns_healthy(client: TestClient) -> None:
65
+ """GET /api/health returns status=healthy."""
66
+ response = client.get("/api/health")
67
  assert response.status_code == 200
68
  assert response.json() == {"status": "healthy"}
69
 
70
 
71
  def test_health_is_fast(client: TestClient) -> None:
72
+ """GET /api/health must complete without touching state or DB."""
73
  import time
74
 
75
  start = time.perf_counter()
76
+ response = client.get("/api/health")
77
  elapsed = time.perf_counter() - start
78
 
79
  assert response.status_code == 200
 
87
 
88
 
89
  def test_status_returns_expected_schema(client: TestClient) -> None:
90
+ """GET /api/status returns version, uptime_seconds, and memory sub-object."""
91
+ response = client.get("/api/status")
92
  assert response.status_code == 200
93
  body = response.json()
94
 
 
108
 
109
 
110
  def test_campaigns_list_returns_empty(client: TestClient) -> None:
111
+ """GET /api/campaigns returns empty list and total."""
112
+ response = client.get("/api/campaigns")
113
  assert response.status_code == 200
114
  body = response.json()
115
  assert body["campaigns"] == []
 
136
 
137
 
138
  def test_run_campaign_endpoint(client: TestClient) -> None:
139
+ """POST /api/campaigns/{campaign_id}/run calls governor.run_campaign."""
140
  from synthra.api.app import ServiceState
141
 
142
  mock_state = ServiceState.get_instance()
143
 
144
+ response = client.post("/api/campaigns/CMP-0001/run")
145
  assert response.status_code == 200
146
  assert response.json() == {
147
  "status": "success",
 
151
 
152
 
153
  def test_run_campaign_endpoint_error(client: TestClient) -> None:
154
+ """POST /api/campaigns/{campaign_id}/run handles errors properly."""
155
  from synthra.api.app import ServiceState
156
 
157
  mock_state = ServiceState.get_instance()
158
  mock_state.governor.run_campaign.side_effect = ValueError("Some campaign error")
159
 
160
+ response = client.post("/api/campaigns/CMP-0001/run")
161
  assert response.status_code == 200
162
  assert response.json() == {
163
  "status": "error",
tests/api/test_auth.py CHANGED
@@ -43,11 +43,11 @@ def client(mock_service_state):
43
 
44
 
45
  def test_login_success(client, mock_service_state):
46
- """POST /auth/login returns success on valid credentials."""
47
  mock_service_state.execution_client.authenticate.return_value = None
48
 
49
  response = client.post(
50
- "/auth/login",
51
  json={"username": "test_user", "password": "test_password", "remember": True},
52
  )
53
  assert response.status_code == 200
@@ -64,13 +64,13 @@ def test_login_success(client, mock_service_state):
64
 
65
 
66
  def test_login_invalid_credentials(client, mock_service_state):
67
- """POST /auth/login returns error on invalid credentials."""
68
  mock_service_state.execution_client.authenticate.side_effect = (
69
  ExecutionAuthenticationError("Auth failed")
70
  )
71
 
72
  response = client.post(
73
- "/auth/login",
74
  json={"username": "bad_user", "password": "bad_password", "remember": False},
75
  )
76
  assert response.status_code == 200
@@ -84,13 +84,13 @@ def test_login_invalid_credentials(client, mock_service_state):
84
 
85
 
86
  def test_login_verification_required(client, mock_service_state):
87
- """POST /auth/login returns verification_required status on MFA."""
88
  mock_service_state.execution_client.authenticate.side_effect = (
89
  VerificationRequiredError("https://mfa.example.test")
90
  )
91
 
92
  response = client.post(
93
- "/auth/login",
94
  json={"username": "mfa_user", "password": "mfa_password", "remember": False},
95
  )
96
  assert response.status_code == 200
@@ -105,8 +105,8 @@ def test_login_verification_required(client, mock_service_state):
105
 
106
 
107
  def test_status_unauthenticated(client):
108
- """GET /auth/status returns unauthenticated when no session exists."""
109
- response = client.get("/auth/status")
110
  assert response.status_code == 200
111
  assert response.json() == {
112
  "authenticated": False,
@@ -117,18 +117,18 @@ def test_status_unauthenticated(client):
117
 
118
 
119
  def test_status_authenticated_and_logout(client, mock_service_state):
120
- """GET /auth/status returns authenticated and POST /auth/logout clears session."""
121
  mock_service_state.execution_client.authenticate.return_value = None
122
 
123
  # 1. Login to establish session
124
  login_resp = client.post(
125
- "/auth/login",
126
  json={"username": "user1", "password": "pass1", "remember": False},
127
  )
128
  assert login_resp.status_code == 200
129
 
130
  # 2. Get status with cookie
131
- status_resp = client.get("/auth/status")
132
  assert status_resp.status_code == 200
133
  assert status_resp.json() == {
134
  "authenticated": True,
@@ -138,12 +138,12 @@ def test_status_authenticated_and_logout(client, mock_service_state):
138
  }
139
 
140
  # 3. Invalidate via logout
141
- logout_resp = client.post("/auth/logout")
142
  assert logout_resp.status_code == 200
143
  assert logout_resp.json() == {"status": "success", "message": None}
144
 
145
  # Cookie should be deleted
146
  # 4. Status should now be unauthenticated
147
- status_resp_after = client.get("/auth/status")
148
  assert status_resp_after.status_code == 200
149
  assert status_resp_after.json()["authenticated"] is False
 
43
 
44
 
45
  def test_login_success(client, mock_service_state):
46
+ """POST /api/auth/login returns success on valid credentials."""
47
  mock_service_state.execution_client.authenticate.return_value = None
48
 
49
  response = client.post(
50
+ "/api/auth/login",
51
  json={"username": "test_user", "password": "test_password", "remember": True},
52
  )
53
  assert response.status_code == 200
 
64
 
65
 
66
  def test_login_invalid_credentials(client, mock_service_state):
67
+ """POST /api/auth/login returns error on invalid credentials."""
68
  mock_service_state.execution_client.authenticate.side_effect = (
69
  ExecutionAuthenticationError("Auth failed")
70
  )
71
 
72
  response = client.post(
73
+ "/api/auth/login",
74
  json={"username": "bad_user", "password": "bad_password", "remember": False},
75
  )
76
  assert response.status_code == 200
 
84
 
85
 
86
  def test_login_verification_required(client, mock_service_state):
87
+ """POST /api/auth/login returns verification_required status on MFA."""
88
  mock_service_state.execution_client.authenticate.side_effect = (
89
  VerificationRequiredError("https://mfa.example.test")
90
  )
91
 
92
  response = client.post(
93
+ "/api/auth/login",
94
  json={"username": "mfa_user", "password": "mfa_password", "remember": False},
95
  )
96
  assert response.status_code == 200
 
105
 
106
 
107
  def test_status_unauthenticated(client):
108
+ """GET /api/auth/status returns unauthenticated when no session exists."""
109
+ response = client.get("/api/auth/status")
110
  assert response.status_code == 200
111
  assert response.json() == {
112
  "authenticated": False,
 
117
 
118
 
119
  def test_status_authenticated_and_logout(client, mock_service_state):
120
+ """GET /api/auth/status returns authenticated and logout clears session."""
121
  mock_service_state.execution_client.authenticate.return_value = None
122
 
123
  # 1. Login to establish session
124
  login_resp = client.post(
125
+ "/api/auth/login",
126
  json={"username": "user1", "password": "pass1", "remember": False},
127
  )
128
  assert login_resp.status_code == 200
129
 
130
  # 2. Get status with cookie
131
+ status_resp = client.get("/api/auth/status")
132
  assert status_resp.status_code == 200
133
  assert status_resp.json() == {
134
  "authenticated": True,
 
138
  }
139
 
140
  # 3. Invalidate via logout
141
+ logout_resp = client.post("/api/auth/logout")
142
  assert logout_resp.status_code == 200
143
  assert logout_resp.json() == {"status": "success", "message": None}
144
 
145
  # Cookie should be deleted
146
  # 4. Status should now be unauthenticated
147
+ status_resp_after = client.get("/api/auth/status")
148
  assert status_resp_after.status_code == 200
149
  assert status_resp_after.json()["authenticated"] is False
tests/api/test_telemetry_endpoints.py CHANGED
@@ -112,8 +112,8 @@ def client() -> Generator[TestClient, None, None]:
112
 
113
 
114
  def test_get_metrics(client: TestClient) -> None:
115
- """GET /metrics returns the telemetry metrics."""
116
- response = client.get("/metrics")
117
  assert response.status_code == 200
118
  metrics = response.json()
119
  assert metrics["running_workers"] == 2
@@ -122,8 +122,8 @@ def test_get_metrics(client: TestClient) -> None:
122
 
123
 
124
  def test_get_workers(client: TestClient) -> None:
125
- """GET /workers returns the status of active worker threads."""
126
- response = client.get("/workers")
127
  assert response.status_code == 200
128
  workers = response.json()
129
  assert len(workers) == 2
@@ -134,8 +134,8 @@ def test_get_workers(client: TestClient) -> None:
134
 
135
 
136
  def test_get_campaigns(client: TestClient) -> None:
137
- """GET /campaigns returns the list of all campaigns with state."""
138
- response = client.get("/campaigns")
139
  assert response.status_code == 200
140
  body = response.json()
141
  assert body["total"] == 1
@@ -145,8 +145,8 @@ def test_get_campaigns(client: TestClient) -> None:
145
 
146
 
147
  def test_get_queue(client: TestClient) -> None:
148
- """GET /queue returns campaigns currently in the queue."""
149
- response = client.get("/queue")
150
  assert response.status_code == 200
151
  queue = response.json()
152
  assert len(queue) == 1
@@ -155,8 +155,8 @@ def test_get_queue(client: TestClient) -> None:
155
 
156
 
157
  def test_get_candidates(client: TestClient) -> None:
158
- """GET /candidates returns alpha candidates enqueued for submission."""
159
- response = client.get("/candidates")
160
  assert response.status_code == 200
161
  candidates = response.json()
162
  assert len(candidates) == 1
@@ -165,8 +165,8 @@ def test_get_candidates(client: TestClient) -> None:
165
 
166
 
167
  def test_get_events(client: TestClient) -> None:
168
- """GET /events returns recent events."""
169
- response = client.get("/events")
170
  assert response.status_code == 200
171
  events = response.json()
172
  assert len(events) == 1
@@ -175,8 +175,8 @@ def test_get_events(client: TestClient) -> None:
175
 
176
 
177
  def test_get_system(client: TestClient) -> None:
178
- """GET /system returns system stats."""
179
- response = client.get("/system")
180
  assert response.status_code == 200
181
  system = response.json()
182
  assert system["database_backend"] == "sqlite"
@@ -185,8 +185,8 @@ def test_get_system(client: TestClient) -> None:
185
 
186
 
187
  def test_get_governor(client: TestClient) -> None:
188
- """GET /governor returns governor stats and configuration."""
189
- response = client.get("/governor")
190
  assert response.status_code == 200
191
  governor = response.json()
192
  assert governor["status"] == "running"
@@ -195,7 +195,7 @@ def test_get_governor(client: TestClient) -> None:
195
 
196
 
197
  def test_events_stream(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
198
- """GET /events/stream returns event-stream media type."""
199
  from typing import Any, AsyncGenerator
200
  from fastapi import Request
201
 
@@ -206,6 +206,6 @@ def test_events_stream(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> N
206
 
207
  monkeypatch.setattr("synthra.api.routers.status.sse_generator", mock_sse_generator)
208
 
209
- with client.stream("GET", "/events/stream") as response:
210
  assert response.status_code == 200
211
  assert "text/event-stream" in response.headers["content-type"]
 
112
 
113
 
114
  def test_get_metrics(client: TestClient) -> None:
115
+ """GET /api/metrics returns the telemetry metrics."""
116
+ response = client.get("/api/metrics")
117
  assert response.status_code == 200
118
  metrics = response.json()
119
  assert metrics["running_workers"] == 2
 
122
 
123
 
124
  def test_get_workers(client: TestClient) -> None:
125
+ """GET /api/workers returns the status of active worker threads."""
126
+ response = client.get("/api/workers")
127
  assert response.status_code == 200
128
  workers = response.json()
129
  assert len(workers) == 2
 
134
 
135
 
136
  def test_get_campaigns(client: TestClient) -> None:
137
+ """GET /api/campaigns returns the list of all campaigns with state."""
138
+ response = client.get("/api/campaigns")
139
  assert response.status_code == 200
140
  body = response.json()
141
  assert body["total"] == 1
 
145
 
146
 
147
  def test_get_queue(client: TestClient) -> None:
148
+ """GET /api/queue returns campaigns currently in the queue."""
149
+ response = client.get("/api/queue")
150
  assert response.status_code == 200
151
  queue = response.json()
152
  assert len(queue) == 1
 
155
 
156
 
157
  def test_get_candidates(client: TestClient) -> None:
158
+ """GET /api/candidates returns alpha candidates enqueued for submission."""
159
+ response = client.get("/api/candidates")
160
  assert response.status_code == 200
161
  candidates = response.json()
162
  assert len(candidates) == 1
 
165
 
166
 
167
  def test_get_events(client: TestClient) -> None:
168
+ """GET /api/events returns recent events."""
169
+ response = client.get("/api/events")
170
  assert response.status_code == 200
171
  events = response.json()
172
  assert len(events) == 1
 
175
 
176
 
177
  def test_get_system(client: TestClient) -> None:
178
+ """GET /api/system returns system stats."""
179
+ response = client.get("/api/system")
180
  assert response.status_code == 200
181
  system = response.json()
182
  assert system["database_backend"] == "sqlite"
 
185
 
186
 
187
  def test_get_governor(client: TestClient) -> None:
188
+ """GET /api/governor returns governor stats and configuration."""
189
+ response = client.get("/api/governor")
190
  assert response.status_code == 200
191
  governor = response.json()
192
  assert governor["status"] == "running"
 
195
 
196
 
197
  def test_events_stream(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
198
+ """GET /api/events/stream returns event-stream media type."""
199
  from typing import Any, AsyncGenerator
200
  from fastapi import Request
201
 
 
206
 
207
  monkeypatch.setattr("synthra.api.routers.status.sse_generator", mock_sse_generator)
208
 
209
+ with client.stream("GET", "/api/events/stream") as response:
210
  assert response.status_code == 200
211
  assert "text/event-stream" in response.headers["content-type"]