mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
feat: implement /api/provider/health JSON route and guard /api/* catch-all (fixes #151)
Register a dedicated GET /api/provider/health handler before the SPA catch-all so API clients receive JSON instead of dashboard HTML. Unmatched /api/* paths now return a JSON 404 in both the static-mount fallback and the path:path catch-all. Docs updated to match the implementation schema. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
1 parent
ee62c7b908
commit
faec8047b4
3 files changed
+106
-5
No files matched your search
+16
-3
@@ -48,18 +48,31 @@ Returns information about the active AI provider.
|
||||
|
||||
### GET /api/provider/health
|
||||
|
||||
Returns detailed health information from the provider gateway.
|
||||
Returns detailed JSON health information from the provider gateway. Always
|
||||
responds with `Content-Type: application/json`. The base fields from the
|
||||
provider's `health()` payload are merged with the normalised fields below.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"type": "gateway",
|
||||
"status": "ok",
|
||||
"provider": "PraisonAIProvider",
|
||||
"type": "PraisonAIProvider",
|
||||
"agents": 5,
|
||||
"detail": "All agents operational"
|
||||
"detail": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `status` | string | One of the health status values above. |
|
||||
| `type` | string | Provider type (from `provider` field, falls back to class name). |
|
||||
| `agents` | integer | Count of agents reported by the provider. |
|
||||
| `detail` | string | Human-readable detail; `"ok"` for healthy providers. |
|
||||
|
||||
> **Note:** Any unmatched `/api/*` path returns a JSON `404` (never the SPA
|
||||
> HTML shell), so API clients can rely on parsing the response body.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### health-check
|
||||
|
||||
@@ -825,8 +825,17 @@ async def _plugins_config(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"plugins": plugins})
|
||||
|
||||
|
||||
async def _serve_index(request: Request) -> HTMLResponse:
|
||||
"""Serve dynamically generated HTML based on active style."""
|
||||
async def _serve_index(request: Request) -> Response:
|
||||
"""Serve dynamically generated HTML based on active style.
|
||||
|
||||
Unmatched ``/api/*`` paths return a JSON 404 instead of the SPA shell so
|
||||
API clients never receive HTML with a 200 status.
|
||||
"""
|
||||
if request.url.path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
{"error": "Not Found", "path": request.url.path},
|
||||
status_code=404,
|
||||
)
|
||||
style = _effective_style # Single source of truth (set by create_app)
|
||||
return HTMLResponse(_build_html(style))
|
||||
|
||||
@@ -1527,6 +1536,34 @@ async def api_provider(request: Request) -> JSONResponse:
|
||||
return JSONResponse(info)
|
||||
|
||||
|
||||
async def api_provider_health(request: Request) -> JSONResponse:
|
||||
"""Return JSON health information for the active provider gateway."""
|
||||
provider = get_provider()
|
||||
try:
|
||||
health = await provider.health()
|
||||
except Exception as exc:
|
||||
health = {"status": "error", "detail": str(exc)}
|
||||
agents: list = []
|
||||
try:
|
||||
listed = await provider.list_agents()
|
||||
if isinstance(listed, list):
|
||||
agents = listed
|
||||
except Exception:
|
||||
agents = []
|
||||
status = health.get("status", "unknown")
|
||||
detail = health.get("detail") or (
|
||||
"ok" if status in ("ok", "healthy") else str(health)
|
||||
)
|
||||
body = {
|
||||
**health,
|
||||
"type": health.get("provider", type(provider).__name__),
|
||||
"status": status,
|
||||
"agents": len(agents),
|
||||
"detail": detail,
|
||||
}
|
||||
return JSONResponse(body)
|
||||
|
||||
|
||||
async def api_action_click(request: Request) -> JSONResponse:
|
||||
"""Handle action button clicks and dispatch to registered callbacks.
|
||||
|
||||
@@ -3026,6 +3063,7 @@ def create_app(
|
||||
# Note: /api/usage is now provided by PraisonAIUsage feature
|
||||
Route("/api/debug", api_debug, methods=["GET"]),
|
||||
Route("/api/provider", api_provider, methods=["GET"]),
|
||||
Route("/api/provider/health", api_provider_health, methods=["GET"]),
|
||||
# Action callback API
|
||||
Route("/api/actions/{action_id}/click", api_action_click, methods=["POST"]),
|
||||
# Page registry protocol
|
||||
@@ -3499,6 +3537,15 @@ def create_app(
|
||||
response_status = 404
|
||||
|
||||
if response_status == 404:
|
||||
request_path = scope.get("path", "")
|
||||
if request_path.startswith("/api/"):
|
||||
# Never serve the SPA HTML shell for unmatched API paths.
|
||||
response = JSONResponse(
|
||||
{"error": "Not Found", "path": request_path},
|
||||
status_code=404,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
# SPA fallback — serve the generated index page
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
|
||||
@@ -103,6 +103,47 @@ class TestHealthEndpoint:
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestProviderHealthEndpoint:
|
||||
"""Tests for the provider health JSON route (#151)."""
|
||||
|
||||
def test_api_provider_health_is_json(self, client):
|
||||
"""/api/provider/health returns JSON, not the SPA HTML shell."""
|
||||
response = client.get("/api/provider/health")
|
||||
assert response.status_code == 200
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
body = response.json()
|
||||
assert "status" in body
|
||||
assert "type" in body
|
||||
assert "agents" in body
|
||||
assert "detail" in body
|
||||
|
||||
def test_api_provider_health_accept_json(self, client):
|
||||
"""Accept: application/json still yields JSON body."""
|
||||
response = client.get(
|
||||
"/api/provider/health", headers={"Accept": "application/json"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
assert "status" in response.json()
|
||||
|
||||
def test_unknown_api_path_not_html(self, client):
|
||||
"""Unmatched /api/* paths return JSON 404, never SPA HTML 200."""
|
||||
response = client.get("/api/this-route-does-not-exist")
|
||||
assert response.status_code == 404
|
||||
assert "text/html" not in response.headers.get("content-type", "")
|
||||
assert "application/json" in response.headers["content-type"]
|
||||
|
||||
def test_provider_health_route_before_catchall(self, client):
|
||||
"""The dedicated route is registered before the SPA catch-all."""
|
||||
paths = [getattr(r, "path", None) for r in client.app.routes]
|
||||
assert "/api/provider/health" in paths
|
||||
catchall = next(
|
||||
(i for i, p in enumerate(paths) if p == "/{path:path}"), None
|
||||
)
|
||||
if catchall is not None:
|
||||
assert paths.index("/api/provider/health") < catchall
|
||||
|
||||
|
||||
class TestAgentsEndpoint:
|
||||
"""Tests for agents endpoint."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user