The registry serves no UI — that is maestro-web's job (connection- orchestration spec §"UI hosting"). The rust-embed of web/dist was legacy from NAP-218, before maestro-web was extracted (NAP-241), and it made the registry (and thus the umbrella maestro binary) fail to compile whenever web/dist was absent — e.g. a fresh cargo install --git after the tracked .gitkeep was dropped. Remove web_assets.rs, the rust-embed dep, and the / + /assets routes; the registry is now JSON/WS API only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
6.3 KiB
Rust
192 lines
6.3 KiB
Rust
//! Client-facing HTTP API routes (NAP-215).
|
|
//!
|
|
//! Mounts under the same router as the daemon-facing `WS /daemon`. The cookie
|
|
//! middleware in [`crate::auth`] gates everything except the WS upgrade for
|
|
//! `/daemon` (daemons authenticate via the shared token in `Hello`).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::{ws::WebSocketUpgrade, Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::routing::{any, get};
|
|
use axum::{Json, Router};
|
|
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, Session};
|
|
use uuid::Uuid;
|
|
|
|
use crate::events::events_ws;
|
|
use crate::jwt::issue_attach_jwt;
|
|
use crate::state::{Registry, RegistryError, RPC_TIMEOUT};
|
|
|
|
/// Shared state plumbed into client-facing handlers.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClientApiState {
|
|
pub registry: Registry,
|
|
pub token: Arc<String>,
|
|
pub rpc_timeout: Duration,
|
|
}
|
|
|
|
/// Mount client-facing routes on the given router. Caller is responsible for
|
|
/// applying the cookie middleware (we do that in [`crate::server::router`]).
|
|
pub fn routes(state: ClientApiState) -> Router {
|
|
// The registry serves no web UI — that's `maestro-web`'s job (see
|
|
// connection-orchestration spec §"UI hosting"). Only the JSON/WS API here.
|
|
Router::new()
|
|
.route("/api/daemons", get(list_daemons))
|
|
.route("/api/sessions", get(list_sessions).post(create_session))
|
|
.route("/api/sessions/:id", axum::routing::delete(delete_session))
|
|
.route("/api/sessions/:id/connect-info", get(session_connect_info))
|
|
.route("/api/events", any(events_ws_handler))
|
|
.with_state(state)
|
|
}
|
|
|
|
async fn list_daemons(State(s): State<ClientApiState>) -> Json<Vec<DaemonInfo>> {
|
|
let infos: Vec<DaemonInfo> = s
|
|
.registry
|
|
.daemons()
|
|
.await
|
|
.into_iter()
|
|
.map(Into::into)
|
|
.collect();
|
|
Json(infos)
|
|
}
|
|
|
|
async fn list_sessions(State(s): State<ClientApiState>) -> Json<Vec<Session>> {
|
|
Json(s.registry.sessions().await)
|
|
}
|
|
|
|
async fn create_session(
|
|
State(s): State<ClientApiState>,
|
|
Json(body): Json<CreateSessionRequest>,
|
|
) -> Response {
|
|
let daemon = body.daemon_id.clone();
|
|
let outcome =
|
|
tokio::time::timeout(s.rpc_timeout, s.registry.create_session(&daemon, body)).await;
|
|
match outcome {
|
|
Err(_) => (
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
Json(error_body("timeout", "daemon did not reply in time")),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(error_body("unknown_daemon", "daemon is not connected")),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(RegistryError::Timeout(_))) => (
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
Json(error_body("timeout", "daemon did not reply in time")),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(e)) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(error_body("transport", &e.to_string())),
|
|
)
|
|
.into_response(),
|
|
Ok(Ok(Err(rpc_err))) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
|
|
)
|
|
.into_response(),
|
|
Ok(Ok(Ok(session))) => (StatusCode::CREATED, Json(session)).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn delete_session(State(s): State<ClientApiState>, Path(id): Path<Uuid>) -> Response {
|
|
let Some(daemon) = s.registry.daemon_for_session(id).await else {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(error_body("unknown_session", "no such session")),
|
|
)
|
|
.into_response();
|
|
};
|
|
|
|
let outcome = tokio::time::timeout(s.rpc_timeout, s.registry.kill_session(&daemon, id)).await;
|
|
match outcome {
|
|
Err(_) => (
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
Json(error_body("timeout", "daemon did not reply in time")),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(error_body("unknown_daemon", "daemon is not connected")),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(RegistryError::Timeout(_))) => (
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
Json(error_body("timeout", "daemon did not reply in time")),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(e)) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(error_body("transport", &e.to_string())),
|
|
)
|
|
.into_response(),
|
|
Ok(Ok(Err(rpc_err))) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
|
|
)
|
|
.into_response(),
|
|
Ok(Ok(Ok(()))) => StatusCode::NO_CONTENT.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn session_connect_info(State(s): State<ClientApiState>, Path(id): Path<Uuid>) -> Response {
|
|
let Some(daemon) = s.registry.daemon_for_session(id).await else {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(error_body("unknown_session", "no such session")),
|
|
)
|
|
.into_response();
|
|
};
|
|
let Some(daemon_url) = s.registry.daemon_url(&daemon).await else {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(error_body("unknown_daemon", "owning daemon is offline")),
|
|
)
|
|
.into_response();
|
|
};
|
|
let jwt = match issue_attach_jwt(s.token.as_str(), &daemon, id) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(error_body("jwt_failed", &e.to_string())),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
Json(ConnectInfo { daemon_url, jwt }).into_response()
|
|
}
|
|
|
|
async fn events_ws_handler(
|
|
State(s): State<ClientApiState>,
|
|
ws: WebSocketUpgrade,
|
|
) -> impl IntoResponse {
|
|
let registry = s.registry.clone();
|
|
ws.on_upgrade(move |socket| async move {
|
|
let _ = events_ws(socket, registry).await;
|
|
})
|
|
}
|
|
|
|
fn error_body(code: &str, message: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"error": {
|
|
"code": code,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Convenience: produce a `ClientApiState` from a `Registry` and shared token.
|
|
/// Uses the production [`RPC_TIMEOUT`].
|
|
pub fn state_for(registry: Registry, token: String) -> ClientApiState {
|
|
ClientApiState {
|
|
registry,
|
|
token: Arc::new(token),
|
|
rpc_timeout: RPC_TIMEOUT,
|
|
}
|
|
}
|