1use std::sync::Arc;
14
15use axum::extract::{Path, State};
16use axum::http::StatusCode;
17use axum::response::IntoResponse;
18use axum::routing::get;
19use axum::{Json, Router};
20
21use crate::config::{GatewayConfig, NodeConfig, StoreConfig};
22use crate::state::SharedState;
23
24pub fn router() -> Router<Arc<SharedState>> {
25 Router::new()
26 .route("/api/stores", get(list_stores).post(create_store))
27 .route(
28 "/api/stores/{name}",
29 get(get_store).put(update_store).delete(delete_store),
30 )
31 .route("/api/stores/{name}/ping", axum::routing::post(ping_store))
32}
33
34async fn list_stores(State(state): State<Arc<SharedState>>) -> impl IntoResponse {
35 let gw = state.gateway.read().await;
36 Json(gw.stores.clone()).into_response()
37}
38
39async fn get_store(
40 State(state): State<Arc<SharedState>>,
41 Path(name): Path<String>,
42) -> impl IntoResponse {
43 let gw = state.gateway.read().await;
44 match gw.stores.iter().find(|s| s.name == name) {
45 Some(s) => Json(s.clone()).into_response(),
46 None => (
47 StatusCode::NOT_FOUND,
48 Json(serde_json::json!({"error": "not_found"})),
49 )
50 .into_response(),
51 }
52}
53
54async fn create_store(
55 State(state): State<Arc<SharedState>>,
56 Json(store): Json<StoreConfig>,
57) -> impl IntoResponse {
58 let candidate = {
59 let gw = state.gateway.read().await;
60 if gw.stores.iter().any(|s| s.name == store.name) {
61 return (
62 StatusCode::CONFLICT,
63 Json(serde_json::json!({"error": "store already exists"})),
64 )
65 .into_response();
66 }
67 let mut candidate = gw.clone();
68 candidate.stores.push(store);
69 candidate
70 };
71 match state.config_store.clone().commit(&state, candidate).await {
72 Ok(_) => (
73 StatusCode::CREATED,
74 Json(serde_json::json!({"status": "created"})),
75 )
76 .into_response(),
77 Err(e) => (
78 StatusCode::BAD_REQUEST,
79 Json(serde_json::json!({"error": e})),
80 )
81 .into_response(),
82 }
83}
84
85async fn update_store(
86 State(state): State<Arc<SharedState>>,
87 Path(name): Path<String>,
88 Json(mut store): Json<StoreConfig>,
89) -> impl IntoResponse {
90 store.name = name.clone();
91 let candidate = {
92 let gw = state.gateway.read().await;
93 let mut candidate = gw.clone();
94 if let Some(existing) = candidate.stores.iter_mut().find(|s| s.name == name) {
95 *existing = store;
96 } else {
97 candidate.stores.push(store);
98 }
99 candidate
100 };
101 match state.config_store.clone().commit(&state, candidate).await {
102 Ok(_) => Json(serde_json::json!({"status": "updated"})).into_response(),
103 Err(e) => (
104 StatusCode::BAD_REQUEST,
105 Json(serde_json::json!({"error": e})),
106 )
107 .into_response(),
108 }
109}
110
111async fn delete_store(
112 State(state): State<Arc<SharedState>>,
113 Path(name): Path<String>,
114) -> impl IntoResponse {
115 let candidate = {
116 let gw = state.gateway.read().await;
117 let mut referrers = store_referrers(&gw, &name);
118 if let Some(crate::config::AcmeConfig {
119 storage: crate::config::AcmeStorageConfig::Store { store, .. },
120 ..
121 }) = &state.system.acme
122 {
123 if store == &name {
124 referrers.push("acme.storage (system.yaml)".to_string());
125 }
126 }
127 if !referrers.is_empty() {
128 return (
129 StatusCode::CONFLICT,
130 Json(serde_json::json!({"error": "in_use", "referrers": referrers})),
131 )
132 .into_response();
133 }
134 let mut candidate = gw.clone();
135 let before = candidate.stores.len();
136 candidate.stores.retain(|s| s.name != name);
137 if candidate.stores.len() == before {
138 return (
139 StatusCode::NOT_FOUND,
140 Json(serde_json::json!({"error": "not_found"})),
141 )
142 .into_response();
143 }
144 candidate
145 };
146 match state.config_store.clone().commit(&state, candidate).await {
147 Ok(_) => Json(serde_json::json!({"status": "deleted"})).into_response(),
148 Err(e) => (
149 StatusCode::BAD_REQUEST,
150 Json(serde_json::json!({"error": e})),
151 )
152 .into_response(),
153 }
154}
155
156#[cfg(feature = "redis-store")]
160async fn ping_store(
161 State(state): State<Arc<SharedState>>,
162 Path(name): Path<String>,
163) -> impl IntoResponse {
164 let cfg = {
165 let gw = state.gateway.read().await;
166 match gw.stores.iter().find(|s| s.name == name) {
167 Some(s) => s.clone(),
168 None => {
169 return (
170 StatusCode::NOT_FOUND,
171 Json(serde_json::json!({"error": "not_found"})),
172 )
173 .into_response()
174 }
175 }
176 };
177 let client = match crate::stores::redis_store::RedisStoreClient::build(&cfg) {
178 Ok(c) => c,
179 Err(e) => {
180 return (
181 StatusCode::BAD_REQUEST,
182 Json(serde_json::json!({"error": e})),
183 )
184 .into_response()
185 }
186 };
187 match tokio::time::timeout(client.connect_timeout(), client.ping()).await {
188 Err(_) => (
189 StatusCode::GATEWAY_TIMEOUT,
190 Json(serde_json::json!({
191 "error": "ping_timeout",
192 "message": format!("no reply within connect_timeout_ms ({}ms)", cfg.connect_timeout_ms),
193 })),
194 )
195 .into_response(),
196 Ok(Err(e)) => (
197 StatusCode::BAD_GATEWAY,
198 Json(serde_json::json!({"error": e})),
199 )
200 .into_response(),
201 Ok(Ok(info)) => Json(serde_json::json!({
202 "status": "ok",
203 "latency_ms": info.latency_ms,
204 "version": info.version,
205 }))
206 .into_response(),
207 }
208}
209
210#[cfg(not(feature = "redis-store"))]
211async fn ping_store(
212 State(_state): State<Arc<SharedState>>,
213 Path(_name): Path<String>,
214) -> impl IntoResponse {
215 (
216 StatusCode::NOT_IMPLEMENTED,
217 Json(serde_json::json!({
218 "error": "this binary was built without the redis-store feature"
219 })),
220 )
221 .into_response()
222}
223
224pub(crate) fn store_referrers(gw: &GatewayConfig, name: &str) -> Vec<String> {
230 fn config_references(
231 config: &std::collections::HashMap<String, serde_json::Value>,
232 name: &str,
233 ) -> bool {
234 config.get("store").and_then(|v| v.as_str()) == Some(name)
235 || config.get("session_store").and_then(|v| v.as_str()) == Some(name)
236 || config
237 .get("session")
238 .and_then(|v| v.get("store"))
239 .and_then(|v| v.as_str())
240 == Some(name)
241 || config
242 .get("rules")
243 .and_then(|v| v.as_array())
244 .is_some_and(|rules| rules.iter().any(|rule| rule_references(rule, name)))
245 }
246
247 fn rule_references(rule: &serde_json::Value, name: &str) -> bool {
248 rule.get("actions")
249 .and_then(|v| v.as_array())
250 .is_some_and(|actions| actions.iter().any(|action| action_references(action, name)))
251 }
252
253 fn action_references(action: &serde_json::Value, name: &str) -> bool {
254 action
255 .as_array()
256 .and_then(|a| a.get(1))
257 .and_then(|params| params.as_object())
258 .and_then(|params| params.get("store"))
259 .and_then(|v| v.as_str())
260 == Some(name)
261 }
262 fn scan_nodes(nodes: &[NodeConfig], owner: &str, name: &str, out: &mut Vec<String>) {
263 for n in nodes {
264 if config_references(&n.config, name) {
265 out.push(format!("{} node '{}'", owner, n.id));
266 }
267 }
268 }
269 let mut refs = Vec::new();
270 for p in &gw.policies {
271 scan_nodes(&p.nodes, &format!("policy '{}'", p.name), name, &mut refs);
272 }
273 for s in &gw.supernodes {
274 scan_nodes(
275 &s.nodes,
276 &format!("supernode '{}'", s.name),
277 name,
278 &mut refs,
279 );
280 }
281 for pc in &gw.plugin_configs {
282 if config_references(&pc.config, name) {
283 refs.push(format!("plugin_config '{}'", pc.name));
284 }
285 }
286 refs
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::config::{GatewayConfig, SystemConfig};
293 use crate::config_store::FileConfigStore;
294 use axum::body::Body;
295 use axum::http::{Request, StatusCode};
296 use tower::ServiceExt;
297
298 fn test_state(gateway_yaml: &str) -> Arc<SharedState> {
299 let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
300 let gateway: GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
301 Arc::new(
302 SharedState::new(
303 system,
304 gateway,
305 None,
306 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
307 "gateway.yaml",
308 ))),
309 )
310 .unwrap(),
311 )
312 }
313
314 fn app(state: Arc<SharedState>) -> Router {
315 router().with_state(state)
316 }
317
318 const VALID_STORE: &str = r#"{
319 "name": "s1",
320 "type": "redis",
321 "url": "${TEST_STORES_URL:-redis://127.0.0.1:6379}"
322 }"#;
323
324 async fn send(state: &Arc<SharedState>, req: Request<Body>) -> (StatusCode, serde_json::Value) {
325 let resp = app(state.clone()).oneshot(req).await.unwrap();
326 let status = resp.status();
327 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
328 .await
329 .unwrap();
330 let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
331 (status, v)
332 }
333
334 #[tokio::test]
335 async fn test_crud_roundtrip_keeps_placeholders_raw() {
336 let state = test_state("{}");
337 let (status, _) = send(
338 &state,
339 Request::post("/api/stores")
340 .header("content-type", "application/json")
341 .body(Body::from(VALID_STORE))
342 .unwrap(),
343 )
344 .await;
345 assert_eq!(status, StatusCode::CREATED);
346
347 let (status, _) = send(
349 &state,
350 Request::post("/api/stores")
351 .header("content-type", "application/json")
352 .body(Body::from(VALID_STORE))
353 .unwrap(),
354 )
355 .await;
356 assert_eq!(status, StatusCode::CONFLICT);
357
358 let (status, body) = send(
360 &state,
361 Request::get("/api/stores/s1").body(Body::empty()).unwrap(),
362 )
363 .await;
364 assert_eq!(status, StatusCode::OK);
365 assert_eq!(
366 body["url"].as_str().unwrap(),
367 "${TEST_STORES_URL:-redis://127.0.0.1:6379}"
368 );
369
370 let (status, body) = send(
372 &state,
373 Request::put("/api/stores/s1")
374 .header("content-type", "application/json")
375 .body(Body::from(
376 r#"{"name":"s1","type":"memcached","url":"redis://x"}"#,
377 ))
378 .unwrap(),
379 )
380 .await;
381 assert_eq!(status, StatusCode::BAD_REQUEST);
382 assert!(
383 body["error"].as_str().unwrap().contains("unknown type"),
384 "{body}"
385 );
386
387 let (status, _) = send(
389 &state,
390 Request::delete("/api/stores/s1")
391 .body(Body::empty())
392 .unwrap(),
393 )
394 .await;
395 assert_eq!(status, StatusCode::OK);
396 let (status, _) = send(
397 &state,
398 Request::get("/api/stores/s1").body(Body::empty()).unwrap(),
399 )
400 .await;
401 assert_eq!(status, StatusCode::NOT_FOUND);
402 }
403
404 #[tokio::test]
405 async fn test_delete_referenced_store_is_409_with_referrers() {
406 let state = test_state(
407 r#"
408stores:
409 - name: s1
410 type: redis
411 url: redis://127.0.0.1:6379
412plugin_configs:
413 - name: shared-lc
414 type: limit-count
415 config: { count: 1, time_window: 60, policy: redis, store: s1 }
416"#,
417 );
418 let (status, body) = send(
419 &state,
420 Request::delete("/api/stores/s1")
421 .body(Body::empty())
422 .unwrap(),
423 )
424 .await;
425 assert_eq!(status, StatusCode::CONFLICT);
426 assert_eq!(body["error"], "in_use");
427 let refs: Vec<String> = body["referrers"]
428 .as_array()
429 .unwrap()
430 .iter()
431 .map(|v| v.as_str().unwrap().to_string())
432 .collect();
433 assert!(
434 refs.contains(&"plugin_config 'shared-lc'".to_string()),
435 "{refs:?}"
436 );
437 }
438
439 #[tokio::test]
440 async fn test_delete_referenced_by_workflow_action_is_409_with_referrers() {
441 let state = test_state(
442 r#"
443stores:
444 - name: s1
445 type: redis
446 url: redis://127.0.0.1:6379
447plugin_configs:
448 - name: shared-wf
449 type: workflow
450 config:
451 rules:
452 - case: [["uri", "==", "/x"]]
453 actions:
454 - ["limit-count", { count: 1, time_window: 1, policy: redis, store: s1 }]
455"#,
456 );
457 let (status, body) = send(
458 &state,
459 Request::delete("/api/stores/s1")
460 .body(Body::empty())
461 .unwrap(),
462 )
463 .await;
464 assert_eq!(status, StatusCode::CONFLICT);
465 assert_eq!(body["error"], "in_use");
466 let refs: Vec<String> = body["referrers"]
467 .as_array()
468 .unwrap()
469 .iter()
470 .map(|v| v.as_str().unwrap().to_string())
471 .collect();
472 assert!(
473 refs.contains(&"plugin_config 'shared-wf'".to_string()),
474 "{refs:?}"
475 );
476 }
477
478 #[tokio::test]
483 async fn test_delete_store_referenced_by_flat_session_store_is_409() {
484 let state = test_state(
485 r#"
486stores:
487 - name: s1
488 type: redis
489 url: redis://127.0.0.1:6379
490plugin_configs:
491 - name: ui-oidc
492 type: openid-connect
493 config: { session_storage: redis, session_store: s1 }
494"#,
495 );
496 let (status, body) = send(
497 &state,
498 Request::delete("/api/stores/s1")
499 .body(Body::empty())
500 .unwrap(),
501 )
502 .await;
503 assert_eq!(status, StatusCode::CONFLICT, "{body}");
504 assert_eq!(body["error"], "in_use");
505 let refs: Vec<String> = body["referrers"]
506 .as_array()
507 .unwrap()
508 .iter()
509 .map(|v| v.as_str().unwrap().to_string())
510 .collect();
511 assert!(
512 refs.contains(&"plugin_config 'ui-oidc'".to_string()),
513 "{refs:?}"
514 );
515 }
516
517 #[tokio::test]
518 #[cfg(feature = "redis-store")]
519 async fn test_delete_store_used_by_acme_storage_is_409() {
520 let system: SystemConfig = serde_yaml::from_str(
521 "acme:\n terms_of_service_agreed: true\n storage:\n type: store\n store: s1\n encryption_key: k\n",
522 )
523 .unwrap();
524 let gateway: GatewayConfig = serde_yaml::from_str(
525 "stores:\n - name: s1\n type: redis\n url: redis://127.0.0.1:6379\n",
526 )
527 .unwrap();
528 let state = Arc::new(
529 SharedState::new(
530 system,
531 gateway,
532 None,
533 Arc::new(FileConfigStore::new("gateway.yaml".into())),
534 )
535 .unwrap(),
536 );
537 let req = Request::builder()
538 .method("DELETE")
539 .uri("/api/stores/s1")
540 .body(Body::empty())
541 .unwrap();
542 let resp = app(state).oneshot(req).await.unwrap();
543 assert_eq!(resp.status(), StatusCode::CONFLICT);
544 let body: serde_json::Value = serde_json::from_slice(
545 &axum::body::to_bytes(resp.into_body(), usize::MAX)
546 .await
547 .unwrap(),
548 )
549 .unwrap();
550 assert_eq!(body["error"], "in_use");
551 assert!(body["referrers"]
552 .as_array()
553 .unwrap()
554 .iter()
555 .any(|r| r.as_str().unwrap().contains("acme.storage")));
556 }
557
558 #[tokio::test]
561 #[cfg(feature = "redis-store")]
562 async fn test_ping_unknown_and_unreachable() {
563 let state = test_state(
565 "stores:\n - name: dead\n type: redis\n url: redis://127.0.0.1:1\n connect_timeout_ms: 300\n",
566 );
567 let (status, _) = send(
568 &state,
569 Request::post("/api/stores/nope/ping")
570 .body(Body::empty())
571 .unwrap(),
572 )
573 .await;
574 assert_eq!(status, StatusCode::NOT_FOUND);
575
576 let (status, body) = send(
577 &state,
578 Request::post("/api/stores/dead/ping")
579 .body(Body::empty())
580 .unwrap(),
581 )
582 .await;
583 assert!(
584 status == StatusCode::BAD_GATEWAY || status == StatusCode::GATEWAY_TIMEOUT,
585 "{status} {body}"
586 );
587 }
588
589 #[tokio::test]
591 #[cfg(feature = "redis-store")]
592 async fn test_ping_live() {
593 let Ok(url) = std::env::var("FEATHERBIT_TEST_REDIS_URL") else {
594 eprintln!("skipping test_ping_live: FEATHERBIT_TEST_REDIS_URL not set");
595 return;
596 };
597 let state = test_state(&format!(
598 "stores:\n - name: live\n type: redis\n url: {url}\n"
599 ));
600 let (status, body) = send(
601 &state,
602 Request::post("/api/stores/live/ping")
603 .body(Body::empty())
604 .unwrap(),
605 )
606 .await;
607 assert_eq!(status, StatusCode::OK, "{body}");
608 assert_eq!(body["status"], "ok");
609 assert!(body["latency_ms"].is_u64(), "{body}");
610 assert!(body["version"].is_string(), "{body}");
611 }
612}