wactorz_interfaces/
rest.rs

1//! axum HTTP REST API.
2//!
3//! Exposes a thin REST layer over the actor system.
4//!
5//! ## Endpoints
6//!
7//! | Method | Path | Description |
8//! |--------|------|-------------|
9//! | GET | `/health` | Server liveness check |
10//! | GET | `/actors` | List all actors + states |
11//! | GET | `/actors/{id}` | Single actor info |
12//! | POST | `/actors/{id}/message` | Send a message to an actor |
13//! | DELETE | `/actors/{id}` | Stop an actor (if not protected) |
14//! | GET | `/actors/{id}/metrics` | Actor runtime metrics |
15//! | POST | `/chat` | Send a message to MainActor and stream response |
16
17use anyhow::Result;
18use axum::{
19    Json, Router,
20    extract::{Path, State},
21    http::StatusCode,
22    response::IntoResponse,
23    routing::{delete, get, post},
24};
25use serde::Deserialize;
26use std::net::SocketAddr;
27use tower_http::cors::CorsLayer;
28use tower_http::trace::TraceLayer;
29
30use wactorz_core::ActorSystem;
31use wactorz_core::message::{ActorCommand, Message};
32
33/// Shared application state injected into axum handlers.
34#[derive(Clone)]
35pub struct AppState {
36    pub system: ActorSystem,
37}
38
39/// JSON body for POST /actors/{id}/message
40#[derive(Debug, Deserialize)]
41pub struct SendMessageRequest {
42    pub content: String,
43    #[serde(rename = "type", default)]
44    pub message_type: String,
45}
46
47/// JSON body for POST /chat
48#[derive(Debug, Deserialize)]
49pub struct ChatRequest {
50    pub message: String,
51    pub agent_name: Option<String>,
52}
53
54/// The axum HTTP server.
55pub struct RestServer {
56    state: AppState,
57    addr: SocketAddr,
58}
59
60impl RestServer {
61    pub fn new(system: ActorSystem, addr: SocketAddr) -> Self {
62        Self {
63            state: AppState { system },
64            addr,
65        }
66    }
67
68    /// Build the axum `Router`.
69    pub fn router(&self) -> Router {
70        Router::new()
71            .route("/health", get(health_handler))
72            .route("/actors", get(list_actors_handler))
73            .route("/actors/:id", get(get_actor_handler))
74            .route("/actors/:id/message", post(send_message_handler))
75            .route("/actors/:id", delete(stop_actor_handler))
76            .route("/actors/:id/pause", post(pause_actor_handler))
77            .route("/actors/:id/resume", post(resume_actor_handler))
78            .route("/actors/:id/metrics", get(get_metrics_handler))
79            .route("/chat", post(chat_handler))
80            .layer(CorsLayer::permissive())
81            .layer(TraceLayer::new_for_http())
82            .with_state(self.state.clone())
83    }
84
85    /// Start listening and serving.
86    pub async fn serve(self) -> Result<()> {
87        let router = self.router();
88        let listener = tokio::net::TcpListener::bind(self.addr).await?;
89        tracing::info!("REST API listening on {}", self.addr);
90        axum::serve(listener, router).await?;
91        Ok(())
92    }
93}
94
95// ── Handlers ─────────────────────────────────────────────────────────────────
96
97async fn health_handler() -> impl IntoResponse {
98    Json(serde_json::json!({ "status": "ok" }))
99}
100
101async fn list_actors_handler(State(state): State<AppState>) -> impl IntoResponse {
102    let actors = state.system.registry.list().await;
103    let body: Vec<_> = actors
104        .iter()
105        .map(|e| {
106            serde_json::json!({
107                "id": e.id,
108                "name": e.name,
109                "state": format!("{}", e.state),
110                "protected": e.protected,
111            })
112        })
113        .collect();
114    Json(body)
115}
116
117async fn get_actor_handler(
118    State(state): State<AppState>,
119    Path(id): Path<String>,
120) -> impl IntoResponse {
121    match state.system.registry.get(&id).await {
122        Some(entry) => Json(serde_json::json!({
123            "id": entry.id,
124            "name": entry.name,
125            "state": format!("{}", entry.state),
126            "protected": entry.protected,
127        }))
128        .into_response(),
129        None => (StatusCode::NOT_FOUND, "actor not found").into_response(),
130    }
131}
132
133async fn send_message_handler(
134    State(state): State<AppState>,
135    Path(id): Path<String>,
136    Json(body): Json<SendMessageRequest>,
137) -> axum::response::Response {
138    let msg = Message::text(None, Some(id.clone()), body.content);
139    match state.system.registry.send(&id, msg).await {
140        Ok(_) => (StatusCode::OK, Json(serde_json::json!({"status": "sent"}))).into_response(),
141        Err(e) => (StatusCode::NOT_FOUND, e.to_string()).into_response(),
142    }
143}
144
145async fn stop_actor_handler(
146    State(state): State<AppState>,
147    Path(id): Path<String>,
148) -> axum::response::Response {
149    let entry = match state.system.registry.get(&id).await {
150        Some(e) => e,
151        None => return (StatusCode::NOT_FOUND, "actor not found").into_response(),
152    };
153    if entry.protected {
154        return (StatusCode::FORBIDDEN, "actor is protected").into_response();
155    }
156    let msg = Message::command(id.clone(), ActorCommand::Stop);
157    match state.system.registry.send(&id, msg).await {
158        Ok(_) => (StatusCode::OK, "stopping").into_response(),
159        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
160    }
161}
162
163async fn get_metrics_handler(
164    State(state): State<AppState>,
165    Path(id): Path<String>,
166) -> axum::response::Response {
167    match state.system.registry.get(&id).await {
168        Some(e) => Json(e.metrics.snapshot()).into_response(),
169        None => (StatusCode::NOT_FOUND, "actor not found").into_response(),
170    }
171}
172
173async fn pause_actor_handler(
174    State(state): State<AppState>,
175    Path(id): Path<String>,
176) -> axum::response::Response {
177    let entry = match state.system.registry.get(&id).await {
178        Some(e) => e,
179        None => return (StatusCode::NOT_FOUND, "actor not found").into_response(),
180    };
181    if entry.protected {
182        return (StatusCode::FORBIDDEN, "actor is protected").into_response();
183    }
184    let msg = Message::command(id.clone(), ActorCommand::Pause);
185    match state.system.registry.send(&id, msg).await {
186        Ok(_) => (
187            StatusCode::OK,
188            Json(serde_json::json!({"status": "pausing"})),
189        )
190            .into_response(),
191        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
192    }
193}
194
195async fn resume_actor_handler(
196    State(state): State<AppState>,
197    Path(id): Path<String>,
198) -> axum::response::Response {
199    let entry = match state.system.registry.get(&id).await {
200        Some(e) => e,
201        None => return (StatusCode::NOT_FOUND, "actor not found").into_response(),
202    };
203    if entry.protected {
204        return (StatusCode::FORBIDDEN, "actor is protected").into_response();
205    }
206    let msg = Message::command(id.clone(), ActorCommand::Resume);
207    match state.system.registry.send(&id, msg).await {
208        Ok(_) => (
209            StatusCode::OK,
210            Json(serde_json::json!({"status": "resuming"})),
211        )
212            .into_response(),
213        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
214    }
215}
216
217async fn chat_handler(
218    State(state): State<AppState>,
219    Json(body): Json<ChatRequest>,
220) -> axum::response::Response {
221    let target_name = body.agent_name.as_deref().unwrap_or("main-actor");
222    match state.system.registry.get_by_name(target_name).await {
223        None => (
224            StatusCode::NOT_FOUND,
225            format!("agent '{target_name}' not found"),
226        )
227            .into_response(),
228        Some(entry) => {
229            let msg = Message::text(None, Some(entry.id.clone()), body.message);
230            match state.system.registry.send(&entry.id, msg).await {
231                Ok(_) => Json(serde_json::json!({"status": "sent", "agent": target_name}))
232                    .into_response(),
233                Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
234            }
235        }
236    }
237}