stargazer/api/mod.rs
1//! HTTP API server for operational visibility.
2//!
3//! Provides endpoints for monitoring stargazer's internal state, querying
4//! captured data, and manual Tier 3 session management. This is NOT the
5//! primary data consumer — the Rdio API server handles transcription
6//! downstream. The routes here exist so operators (and kubernetes health
7//! probes) can answer "is capture working?" and "what did we miss?" at
8//! a glance.
9//!
10//! # Endpoints
11//!
12//! | Route | Method | Purpose |
13//! |-------|--------|---------|
14//! | `/health` | GET | Kubernetes liveness/readiness probe |
15//! | `/metrics` | GET | Tier statistics: reflectors, streams, upload queue |
16//! | `/api/reflectors` | GET | List reflectors with status and activity scores |
17//! | `/api/reflectors/{callsign}/activity` | GET | Recent activity for one reflector |
18//! | `/api/reflectors/{callsign}/nodes` | GET | Nodes currently linked to a reflector |
19//! | `/api/activity` | GET | Recent activity across all reflectors |
20//! | `/api/streams` | GET | Query captured streams with filters |
21//! | `/api/upload-queue` | GET | Pending upload status |
22//! | `/api/tier3/connect` | POST | Manually promote a reflector to Tier 3 (501 stub) |
23//! | `/api/tier3/{callsign}/{module}` | DELETE | Disconnect a Tier 3 session (501 stub) |
24//!
25//! # Error handling
26//!
27//! Database errors are logged at `warn` level and surfaced to the caller
28//! as `500 Internal Server Error` with no body. The raw `sqlx::Error` is
29//! never leaked — it can contain connection strings, schema details, or
30//! constraint names that would be useful to an attacker.
31//!
32//! # Operational ownership
33//!
34//! The server is spawned by `stargazer::run` as a top-level tokio task.
35//! On shutdown it is aborted; no graceful drain is attempted because all
36//! endpoints are idempotent reads.
37
38mod routes;
39
40use std::net::SocketAddr;
41
42use axum::Router;
43use axum::routing::{delete, get, post};
44use tokio::net::TcpListener;
45
46/// Starts the HTTP API server and listens for requests.
47///
48/// Binds to the given `listen` address and serves the operational
49/// visibility endpoints documented at the module level. Runs until the
50/// caller aborts the returned future (e.g. during SIGTERM handling in
51/// `main`).
52///
53/// # Errors
54///
55/// Returns an error if:
56/// - `listen` is not a valid `SocketAddr` (e.g. missing port).
57/// - The TCP listener cannot bind (port in use, permission denied).
58/// - `axum::serve` returns an I/O error (effectively never — see the
59/// axum docs, the underlying future currently never completes).
60pub(crate) async fn serve(
61 listen: String,
62 pool: sqlx::PgPool,
63) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
64 let addr: SocketAddr = listen.parse()?;
65 let listener = TcpListener::bind(addr).await?;
66 let bound = listener.local_addr()?;
67 tracing::info!(listen = %bound, "HTTP API server listening");
68
69 let router = build_router(pool);
70 axum::serve(listener, router).await?;
71 Ok(())
72}
73
74/// Builds the axum `Router` with all routes and shared state.
75///
76/// Extracted from [`serve`] so it can be exercised without standing up
77/// a TCP listener — useful for route-table regression tests and for
78/// driving the handlers via `tower::ServiceExt::oneshot` in integration
79/// tests.
80fn build_router(pool: sqlx::PgPool) -> Router {
81 Router::new()
82 .route("/health", get(routes::health))
83 .route("/metrics", get(routes::metrics))
84 .route("/api/reflectors", get(routes::list_reflectors))
85 .route(
86 "/api/reflectors/{callsign}/activity",
87 get(routes::reflector_activity),
88 )
89 .route(
90 "/api/reflectors/{callsign}/nodes",
91 get(routes::reflector_nodes),
92 )
93 .route("/api/activity", get(routes::list_activity))
94 .route("/api/streams", get(routes::list_streams))
95 .route("/api/upload-queue", get(routes::upload_queue))
96 .route("/api/tier3/connect", post(routes::tier3_connect))
97 .route(
98 "/api/tier3/{callsign}/{module}",
99 delete(routes::tier3_disconnect),
100 )
101 .with_state(pool)
102}