1use std::sync::Arc;
7
8use axum::extract::{Path, State};
9use axum::http::StatusCode;
10use axum::response::IntoResponse;
11use axum::routing::get;
12use axum::{Json, Router};
13
14use crate::config::SupernodeConfig;
15use crate::state::SharedState;
16
17pub fn router() -> Router<Arc<SharedState>> {
19 Router::new()
20 .route("/api/supernodes", get(list_supernodes))
21 .route(
22 "/api/supernodes/{name}",
23 get(get_supernode)
24 .put(update_supernode)
25 .delete(delete_supernode),
26 )
27}
28
29async fn list_supernodes(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
31 let gw = state.gateway.read().await;
32 Json(&gw.supernodes).into_response()
33}
34
35async fn get_supernode(
39 State(state): State<Arc<SharedState>>,
40 Path(name): Path<String>,
41) -> impl IntoResponse {
42 let gw = state.gateway.read().await;
43 match gw.supernodes.iter().find(|s| s.name == name) {
44 Some(sn) => Json(sn).into_response(),
45 None => (
46 StatusCode::NOT_FOUND,
47 Json(serde_json::json!({"error": "not_found"})),
48 )
49 .into_response(),
50 }
51}
52
53async fn update_supernode(
61 State(state): State<Arc<SharedState>>,
62 Path(name): Path<String>,
63 Json(mut sn): Json<SupernodeConfig>,
64) -> impl IntoResponse {
65 sn.name = name.clone();
66 let candidate = {
67 let gw = state.gateway.read().await;
68 let mut candidate = gw.clone();
69 if let Some(existing) = candidate.supernodes.iter_mut().find(|s| s.name == name) {
70 *existing = sn;
71 } else {
72 candidate.supernodes.push(sn);
73 }
74 candidate
75 };
76
77 match state.config_store.clone().commit(&state, candidate).await {
78 Ok(_) => Json(serde_json::json!({"status": "updated"})).into_response(),
79 Err(e) => (
80 StatusCode::BAD_REQUEST,
81 Json(serde_json::json!({"error": e})),
82 )
83 .into_response(),
84 }
85}
86
87async fn delete_supernode(
93 State(state): State<Arc<SharedState>>,
94 Path(name): Path<String>,
95) -> impl IntoResponse {
96 let candidate = {
97 let gw = state.gateway.read().await;
98 let mut candidate = gw.clone();
99 let before = candidate.supernodes.len();
100 candidate.supernodes.retain(|s| s.name != name);
101 if candidate.supernodes.len() == before {
102 return (
103 StatusCode::NOT_FOUND,
104 Json(serde_json::json!({"error": "not_found"})),
105 )
106 .into_response();
107 }
108 candidate
109 };
110
111 match state.config_store.clone().commit(&state, candidate).await {
112 Ok(_) => Json(serde_json::json!({"status": "deleted"})).into_response(),
113 Err(e) => (
114 StatusCode::BAD_REQUEST,
115 Json(serde_json::json!({"error": e})),
116 )
117 .into_response(),
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::config::{GatewayConfig, SystemConfig};
125 use crate::config_store::FileConfigStore;
126 use axum::body::Body;
127 use axum::http::Request;
128 use tower::ServiceExt;
129
130 fn test_state(gateway_yaml: &str) -> Arc<SharedState> {
131 let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
132 let gateway: GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
133 Arc::new(
134 SharedState::new(
135 system,
136 gateway,
137 None,
138 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
139 "gateway.yaml",
140 ))),
141 )
142 .unwrap(),
143 )
144 }
145
146 fn app(state: Arc<SharedState>) -> Router {
147 router().with_state(state)
148 }
149
150 const VALID_SN: &str = r#"{
151 "name": "secured-call",
152 "nodes": [
153 { "id": "input", "type": "input", "config": {} },
154 { "id": "output", "type": "output", "config": {} },
155 { "id": "error", "type": "error", "config": {} },
156 { "id": "up", "type": "upstream", "config": { "targets": [ { "host": "127.0.0.1", "port": 9 } ] } }
157 ],
158 "edges": [
159 { "from": "input.out", "to": "up.in" },
160 { "from": "up.success", "to": "output.in" }
161 ]
162 }"#;
163
164 async fn put_supernode(state: &Arc<SharedState>, name: &str, body: &str) -> StatusCode {
165 app(state.clone())
166 .oneshot(
167 Request::put(format!("/api/supernodes/{name}"))
168 .header("content-type", "application/json")
169 .body(Body::from(body.to_string()))
170 .unwrap(),
171 )
172 .await
173 .unwrap()
174 .status()
175 }
176
177 #[tokio::test]
178 async fn test_put_list_get_delete_roundtrip() {
179 let state = test_state("{}");
180 assert_eq!(
181 put_supernode(&state, "secured-call", VALID_SN).await,
182 StatusCode::OK
183 );
184
185 let resp = app(state.clone())
186 .oneshot(
187 Request::get("/api/supernodes/secured-call")
188 .body(Body::empty())
189 .unwrap(),
190 )
191 .await
192 .unwrap();
193 assert_eq!(resp.status(), StatusCode::OK);
194
195 let resp = app(state.clone())
196 .oneshot(
197 Request::delete("/api/supernodes/secured-call")
198 .body(Body::empty())
199 .unwrap(),
200 )
201 .await
202 .unwrap();
203 assert_eq!(resp.status(), StatusCode::OK);
204
205 let resp = app(state)
206 .oneshot(
207 Request::get("/api/supernodes/secured-call")
208 .body(Body::empty())
209 .unwrap(),
210 )
211 .await
212 .unwrap();
213 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
214 }
215
216 #[tokio::test]
217 async fn test_put_invalid_definition_is_400() {
218 let state = test_state("{}");
219 let bad = r#"{ "name": "x", "nodes": [], "edges": [] }"#;
221 assert_eq!(
222 put_supernode(&state, "x", bad).await,
223 StatusCode::BAD_REQUEST
224 );
225 }
226
227 #[tokio::test]
228 async fn test_delete_while_referenced_is_400() {
229 let state = test_state("{}");
230 assert_eq!(
231 put_supernode(&state, "secured-call", VALID_SN).await,
232 StatusCode::OK
233 );
234
235 let candidate = {
237 let gw = state.gateway.read().await;
238 let mut c = gw.clone();
239 c.policies.push(
240 serde_yaml::from_str(
241 r#"
242name: p
243nodes:
244 - { id: listener, type: listener }
245 - { id: sec, type: supernode, config: { name: secured-call } }
246 - { id: client, type: client }
247edges:
248 - { from: listener.out, to: sec.in }
249 - { from: sec.success, to: client.in }
250"#,
251 )
252 .unwrap(),
253 );
254 c
255 };
256 state
257 .config_store
258 .clone()
259 .commit(&state, candidate)
260 .await
261 .unwrap();
262
263 let resp = app(state.clone())
264 .oneshot(
265 Request::delete("/api/supernodes/secured-call")
266 .body(Body::empty())
267 .unwrap(),
268 )
269 .await
270 .unwrap();
271 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
272 }
273
274 #[tokio::test]
275 async fn test_delete_missing_is_404() {
276 let state = test_state("{}");
277 let resp = app(state)
278 .oneshot(
279 Request::delete("/api/supernodes/nope")
280 .body(Body::empty())
281 .unwrap(),
282 )
283 .await
284 .unwrap();
285 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
286 }
287}