featherbit/admin/
plugin_configs.rs1use 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
17pub 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
29async 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
35async 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
53async 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
87async 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 #[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}