Skip to main content

featherbit/admin/
plugin_configs.rs

1//! Admin API endpoints for plugin config CRUD. Mutations rewrite the in-memory
2//! gateway config and trigger validation + recompilation of every policy
3//! (plugin configs are resolved 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::PluginConfigDef;
15use crate::state::SharedState;
16
17/// Builds the router for `/api/plugin-configs`.
18pub fn router() -> Router<Arc<SharedState>> {
19    Router::new()
20        .route("/api/plugin-configs", get(list_plugin_configs))
21        .route(
22            "/api/plugin-configs/{name}",
23            get(get_plugin_config)
24                .put(update_plugin_config)
25                .delete(delete_plugin_config),
26        )
27}
28
29/// `GET /api/plugin-configs` — returns all plugin config definitions as a JSON array.
30async fn list_plugin_configs(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
31    let gw = state.gateway.read().await;
32    Json(&gw.plugin_configs).into_response()
33}
34
35/// `GET /api/plugin-configs/{name}` — returns the named definition as JSON.
36///
37/// Errors: `404 Not Found` if no plugin config with that name exists.
38async fn get_plugin_config(
39    State(state): State<Arc<SharedState>>,
40    Path(name): Path<String>,
41) -> impl IntoResponse {
42    let gw = state.gateway.read().await;
43    match gw.plugin_configs.iter().find(|p| p.name == name) {
44        Some(pc) => Json(pc).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/plugin-configs/{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 plugin config 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_plugin_config(
61    State(state): State<Arc<SharedState>>,
62    Path(name): Path<String>,
63    Json(mut pc): Json<PluginConfigDef>,
64) -> impl IntoResponse {
65    pc.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.plugin_configs.iter_mut().find(|p| p.name == name) {
70            *existing = pc;
71        } else {
72            candidate.plugin_configs.push(pc);
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/plugin-configs/{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_plugin_config(
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.plugin_configs.len();
100        candidate.plugin_configs.retain(|p| p.name != name);
101        if candidate.plugin_configs.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_DEF: &str = r#"{
151        "name": "shared-mock",
152        "type": "mocking",
153        "config": { "response_status": 200, "response_example": "hi", "content_type": "text/plain" }
154    }"#;
155
156    async fn put_def(state: &Arc<SharedState>, name: &str, body: &str) -> StatusCode {
157        app(state.clone())
158            .oneshot(
159                Request::put(format!("/api/plugin-configs/{name}"))
160                    .header("content-type", "application/json")
161                    .body(Body::from(body.to_string()))
162                    .unwrap(),
163            )
164            .await
165            .unwrap()
166            .status()
167    }
168
169    #[tokio::test]
170    async fn test_put_list_get_delete_roundtrip() {
171        let state = test_state("{}");
172        assert_eq!(
173            put_def(&state, "shared-mock", VALID_DEF).await,
174            StatusCode::OK
175        );
176
177        let resp = app(state.clone())
178            .oneshot(
179                Request::get("/api/plugin-configs/shared-mock")
180                    .body(Body::empty())
181                    .unwrap(),
182            )
183            .await
184            .unwrap();
185        assert_eq!(resp.status(), StatusCode::OK);
186
187        let resp = app(state.clone())
188            .oneshot(
189                Request::delete("/api/plugin-configs/shared-mock")
190                    .body(Body::empty())
191                    .unwrap(),
192            )
193            .await
194            .unwrap();
195        assert_eq!(resp.status(), StatusCode::OK);
196
197        let resp = app(state)
198            .oneshot(
199                Request::get("/api/plugin-configs/shared-mock")
200                    .body(Body::empty())
201                    .unwrap(),
202            )
203            .await
204            .unwrap();
205        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
206    }
207
208    #[tokio::test]
209    async fn test_put_unknown_type_is_400() {
210        let state = test_state("{}");
211        let bad = r#"{ "name": "x", "type": "openid-conect", "config": {} }"#;
212        assert_eq!(put_def(&state, "x", bad).await, StatusCode::BAD_REQUEST);
213    }
214
215    /// Referenced from a SUPERNODE DEFINITION (the harder case): delete must 400.
216    #[tokio::test]
217    async fn test_delete_while_referenced_by_supernode_is_400() {
218        let state = test_state(
219            r#"
220plugin_configs:
221  - name: shared-mock
222    type: mocking
223    config: { response_status: 200, response_example: "hi", content_type: "text/plain" }
224supernodes:
225  - name: wrapped
226    nodes:
227      - { id: input,  type: input }
228      - { id: output, type: output }
229      - { id: error,  type: error }
230      - { id: mock, type: mocking, config_ref: shared-mock }
231    edges:
232      - { from: input.out,    to: mock.in }
233      - { from: mock.success, to: output.in }
234"#,
235        );
236        let resp = app(state)
237            .oneshot(
238                Request::delete("/api/plugin-configs/shared-mock")
239                    .body(Body::empty())
240                    .unwrap(),
241            )
242            .await
243            .unwrap();
244        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
245    }
246
247    #[tokio::test]
248    async fn test_delete_missing_is_404() {
249        let state = test_state("{}");
250        let resp = app(state)
251            .oneshot(
252                Request::delete("/api/plugin-configs/nope")
253                    .body(Body::empty())
254                    .unwrap(),
255            )
256            .await
257            .unwrap();
258        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
259    }
260}