chat_headless/
api.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use axum::{
5    extract::{Path, State},
6    http::StatusCode,
7    response::Json,
8    routing::{get, post},
9    Router,
10};
11use serde::{Deserialize, Serialize};
12use tokio::sync::{mpsc, Mutex};
13
14#[derive(Clone)]
15pub struct AppState {
16    pub cmd_tx: mpsc::Sender<NodeCommand>,
17    pub joined_rooms: Arc<Mutex<HashSet<String>>>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct JoinRoomRequest {
22    pub room: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SendMessageRequest {
27    pub nickname: String,
28    pub content: String,
29}
30
31#[derive(Debug)]
32pub enum NodeCommand {
33    JoinRoom { room: String },
34    LeaveRoom { room: String },
35    SendMessage { content: String },
36    Shutdown,
37}
38
39pub fn router(state: AppState) -> Router {
40    Router::new()
41        .route("/health", get(health_handler))
42        .route("/rooms", get(list_rooms).post(join_room_handler))
43        .route("/rooms/:room/leave", post(leave_room_handler))
44        .route("/rooms/:room/messages", post(send_message_handler))
45        .with_state(state)
46}
47
48async fn health_handler() -> &'static str {
49    "ok"
50}
51
52async fn list_rooms(State(state): State<AppState>) -> Json<Vec<String>> {
53    let rooms = state.joined_rooms.lock().await.iter().cloned().collect();
54    Json(rooms)
55}
56
57async fn join_room_handler(
58    State(state): State<AppState>,
59    Json(req): Json<JoinRoomRequest>,
60) -> StatusCode {
61    let _ = state.cmd_tx.send(NodeCommand::JoinRoom { room: req.room.clone() }).await;
62    state.joined_rooms.lock().await.insert(req.room);
63    StatusCode::OK
64}
65
66async fn leave_room_handler(
67    State(state): State<AppState>,
68    Path(room): Path<String>,
69) -> StatusCode {
70    let _ = state.cmd_tx.send(NodeCommand::LeaveRoom { room: room.clone() }).await;
71    state.joined_rooms.lock().await.remove(&room);
72    StatusCode::OK
73}
74
75async fn send_message_handler(
76    State(state): State<AppState>,
77    Path(_room): Path<String>,
78    Json(req): Json<SendMessageRequest>,
79) -> StatusCode {
80    let _ = state.cmd_tx.send(NodeCommand::SendMessage {
81        content: req.content,
82    }).await;
83    StatusCode::ACCEPTED
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use axum::body::Body;
90    use axum::http::Request;
91    use http_body_util::BodyExt;
92    use tower::util::ServiceExt;
93
94    fn test_state() -> AppState {
95        let (tx, _rx) = mpsc::channel(10);
96        AppState {
97            cmd_tx: tx,
98            joined_rooms: Arc::new(Mutex::new(HashSet::new())),
99        }
100    }
101
102    #[tokio::test]
103    async fn health_check_returns_ok() {
104        let state = test_state();
105        let app = router(state);
106        let response = app
107            .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
108            .await
109            .unwrap();
110        assert_eq!(response.status(), StatusCode::OK);
111    }
112
113    #[tokio::test]
114    async fn join_room_adds_to_list() {
115        let state = test_state();
116        let app = router(state);
117        let response = app
118            .oneshot(
119                Request::builder()
120                    .uri("/rooms")
121                    .method("POST")
122                    .header("content-type", "application/json")
123                    .body(Body::from(r#"{"room":"test-room"}"#))
124                    .unwrap(),
125            )
126            .await
127            .unwrap();
128        assert_eq!(response.status(), StatusCode::OK);
129    }
130
131    #[tokio::test]
132    async fn list_rooms_returns_empty_initially() {
133        let state = test_state();
134        let app = router(state);
135        let response = app
136            .oneshot(Request::builder().uri("/rooms").body(Body::empty()).unwrap())
137            .await
138            .unwrap();
139        assert_eq!(response.status(), StatusCode::OK);
140        let body = response.into_body().collect().await.unwrap().to_bytes();
141        let rooms: Vec<String> = serde_json::from_slice(&body).unwrap();
142        assert!(rooms.is_empty());
143    }
144
145    #[tokio::test]
146    async fn send_message_returns_accepted() {
147        let state = test_state();
148        let app = router(state);
149        let response = app
150            .oneshot(
151                Request::builder()
152                    .uri("/rooms/general/messages")
153                    .method("POST")
154                    .header("content-type", "application/json")
155                    .body(Body::from(r#"{"nickname":"alice","content":"hello"}"#))
156                    .unwrap(),
157            )
158            .await
159            .unwrap();
160        assert_eq!(response.status(), StatusCode::ACCEPTED);
161    }
162
163    #[tokio::test]
164    async fn leave_room_returns_ok() {
165        let state = test_state();
166        // pre-seed the room
167        state.joined_rooms.lock().await.insert("general".into());
168        let app = router(state);
169        let response = app
170            .oneshot(
171                Request::builder()
172                    .uri("/rooms/general/leave")
173                    .method("POST")
174                    .body(Body::empty())
175                    .unwrap(),
176            )
177            .await
178            .unwrap();
179        assert_eq!(response.status(), StatusCode::OK);
180    }
181}