Skip to main content

featherbit/admin/
supernodes.rs

1//! Admin API endpoints for supernode CRUD. Mutations rewrite the in-memory
2//! gateway config and trigger validation + recompilation of every policy
3//! (supernodes are inlined at compile time), so a breaking edit or a
4//! delete-while-referenced is rejected with 400 before anything changes.
5
6use 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
17/// Builds the router for `/api/supernodes`.
18pub 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
29/// `GET /api/supernodes` — returns all supernode definitions as a JSON array.
30async 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
35/// `GET /api/supernodes/{name}` — returns the named definition as JSON.
36///
37/// Errors: `404 Not Found` if no supernode with that name exists.
38async 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
53/// `PUT /api/supernodes/{name}` — upserts a definition from the JSON body
54/// (the path name overrides any name in the body), then revalidates and
55/// recompiles all route graphs — every policy using this supernode picks up
56/// the change atomically. Returns `{"status": "updated"}` on success.
57///
58/// Errors: `400 Bad Request` if the definition is invalid or any consuming
59/// policy stops compiling (the previous compiled routes stay active).
60async 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
87/// `DELETE /api/supernodes/{name}` — removes the named definition, then
88/// revalidates and recompiles. Returns `{"status": "deleted"}` on success.
89///
90/// Errors: `404 Not Found` if it does not exist; `400 Bad Request` if a
91/// policy still references it (recompilation fails, nothing changes).
92async 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        // Missing boundary nodes entirely.
220        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        // Reference it from a policy by committing a candidate directly.
236        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}