use std::sync::Arc;
use std::time::Duration;
use axum::{
Router,
body::Body,
extract::{Path, State},
http::{Response, StatusCode, header},
response::IntoResponse,
routing::{any, get},
};
use rust_embed::RustEmbed;
use serde_json::json;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::connector::ServiceConnector;
use crate::poller::PollerCache;
#[derive(RustEmbed)]
#[folder = "ui/dist/"]
struct UiAssets;
#[derive(Clone)]
pub struct AppState {
connectors: Arc<Vec<Box<dyn ServiceConnector>>>,
poller_cache: PollerCache,
http_client: Arc<reqwest::Client>,
}
impl AppState {
pub fn new(connectors: Vec<Box<dyn ServiceConnector>>) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client init");
Self {
connectors: Arc::new(connectors),
poller_cache: PollerCache::new(),
http_client: Arc::new(client),
}
}
pub fn connectors(&self) -> Arc<Vec<Box<dyn ServiceConnector>>> {
Arc::clone(&self.connectors)
}
pub fn poller_cache(&self) -> &PollerCache {
&self.poller_cache
}
pub fn http_client(&self) -> Arc<reqwest::Client> {
Arc::clone(&self.http_client)
}
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/api/console/services", get(services_handler))
.route("/proxy/{daemon}/{*path}", any(crate::proxy::proxy_handler))
.route("/", get(spa_index_handler))
.route("/ui", get(spa_index_handler))
.route("/ui/", get(spa_index_handler))
.route("/ui/{*path}", get(spa_asset_handler))
.with_state(state)
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
}
async fn health_handler() -> impl IntoResponse {
axum::Json(json!({
"status": "ok",
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn services_handler(State(state): State<AppState>) -> axum::response::Response {
if let Some(snap) = state.poller_cache().snapshot().await {
return axum::Json(snap.services).into_response();
}
let connectors = state.connectors();
match tokio::task::spawn_blocking(move || {
connectors.iter().map(|c| c.detect()).collect::<Vec<_>>()
})
.await
{
Ok(infos) => axum::Json(infos).into_response(),
Err(e) => {
tracing::error!("service detection task panicked: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn spa_index_handler() -> impl IntoResponse {
serve_asset("index.html")
}
async fn spa_asset_handler(Path(path): Path<String>) -> impl IntoResponse {
let path = path.trim_start_matches('/');
serve_asset(path)
}
fn serve_asset(path: &str) -> Response<Body> {
match UiAssets::get(path) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(content.data.to_vec()))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.expect("static response")
})
}
None => {
match UiAssets::get("index.html") {
Some(content) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from(content.data.to_vec()))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.expect("static response")
}),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("not found"))
.expect("static 404"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::header::CONTENT_TYPE;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use crate::connector::{ServiceInfo, ServiceStatus};
struct StubConnector {
id: &'static str,
display_name: &'static str,
status: ServiceStatus,
}
impl ServiceConnector for StubConnector {
fn id(&self) -> &'static str {
self.id
}
fn display_name(&self) -> &'static str {
self.display_name
}
fn detect(&self) -> ServiceInfo {
ServiceInfo {
id: self.id.to_string(),
display_name: self.display_name.to_string(),
status: self.status.clone(),
version: None,
url: None,
}
}
}
fn make_test_state() -> AppState {
AppState::new(vec![
Box::new(StubConnector {
id: "trusty-search",
display_name: "Trusty Search",
status: ServiceStatus::Running,
}),
Box::new(StubConnector {
id: "trusty-memory",
display_name: "Trusty Memory",
status: ServiceStatus::Available,
}),
Box::new(StubConnector {
id: "trusty-analyze",
display_name: "Trusty Analyze",
status: ServiceStatus::Absent,
}),
])
}
async fn get_bytes(resp: axum::http::Response<Body>) -> Vec<u8> {
resp.into_body()
.collect()
.await
.expect("collect body")
.to_bytes()
.to_vec()
}
#[tokio::test]
async fn test_services_route_returns_json() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: Vec<serde_json::Value> = serde_json::from_slice(&bytes).expect("parse json");
assert_eq!(body.len(), 3);
assert_eq!(body[0]["id"], "trusty-search");
assert_eq!(body[0]["status"], "running");
assert_eq!(body[0]["display_name"], "Trusty Search");
assert_eq!(body[1]["id"], "trusty-memory");
assert_eq!(body[1]["status"], "available");
assert_eq!(body[2]["id"], "trusty-analyze");
assert_eq!(body[2]["status"], "absent");
}
#[tokio::test]
async fn test_health_route() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = get_bytes(resp).await;
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("parse json");
assert_eq!(body["status"], "ok");
assert!(body["version"].is_string());
}
#[tokio::test]
async fn test_spa_root_returns_html() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
}
struct PanicConnector;
impl ServiceConnector for PanicConnector {
fn id(&self) -> &'static str {
"panic-svc"
}
fn display_name(&self) -> &'static str {
"Panic Service"
}
fn detect(&self) -> ServiceInfo {
panic!("intentional test panic from PanicConnector");
}
}
#[tokio::test]
async fn test_services_handler_returns_500_on_panic() {
let state = AppState::new(vec![Box::new(PanicConnector)]);
let router = build_router(state);
let req = Request::builder()
.uri("/api/console/services")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn test_proxy_unknown_daemon_returns_400() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/proxy/unknown/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_proxy_known_daemon_cold_cache_returns_503() {
let router = build_router(make_test_state());
let req = Request::builder()
.uri("/proxy/search/health")
.body(Body::empty())
.expect("request");
let resp = router.oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}