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 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}