featherbit/admin/
cache.rs1use std::sync::Arc;
7
8use axum::{
9 extract::{Path, State},
10 http::StatusCode,
11 response::IntoResponse,
12 routing::delete,
13 Json, Router,
14};
15
16use crate::state::SharedState;
17use crate::traffic::{collect_targets, purge_targets, PurgeOutcome};
18
19pub fn router() -> Router<Arc<SharedState>> {
20 Router::new().route("/api/cache/{id}", delete(purge_cache))
21}
22
23#[derive(serde::Serialize)]
25struct PurgeResponse {
26 id: String,
27 purged: Vec<PurgeOutcome>,
28}
29
30async fn purge_cache(
36 State(state): State<Arc<SharedState>>,
37 Path(id): Path<String>,
38) -> impl IntoResponse {
39 let graphs: Vec<_> = state
40 .routes
41 .read()
42 .await
43 .iter()
44 .map(|(_, g)| g.clone())
45 .collect();
46 let targets = collect_targets(&graphs, &id);
47
48 if targets.is_empty() {
49 return (
50 StatusCode::NOT_FOUND,
51 Json(serde_json::json!({
52 "error": "not_found",
53 "message": format!("no proxy-cache pair with id '{id}' in any policy"),
54 })),
55 )
56 .into_response();
57 }
58
59 match purge_targets(&targets).await {
60 Ok(purged) => Json(PurgeResponse { id, purged }).into_response(),
61 Err((purged, e)) => (
62 StatusCode::BAD_GATEWAY,
63 Json(serde_json::json!({
64 "error": "cache_purge_failed",
65 "message": e.to_string(),
66 "id": id,
67 "purged": purged,
68 })),
69 )
70 .into_response(),
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use crate::mcp::tools::test_support::state;
78 use axum::body::Body;
79 use axum::http::Request;
80 use tower::ServiceExt;
81
82 const LOCAL_PAIR_GATEWAY: &str = r#"
85routes:
86 - name: products
87 match: { path: /products }
88 policy: products-policy
89policies:
90 - name: products-policy
91 nodes:
92 - { id: listener, type: listener, config: {} }
93 - { id: look, type: proxy-cache, config: { phase: lookup, id: products, policy: local } }
94 - { id: up, type: upstream, config: { targets: [{ host: h, port: 80 }] } }
95 - { id: keep, type: proxy-cache, config: { phase: store, id: products, policy: local } }
96 - { id: client, type: client, config: {} }
97 edges:
98 - { from: listener.out, to: look.in }
99 - { from: look.success, to: up.in }
100 - { from: look.hit, to: client.in }
101 - { from: up.success, to: keep.in }
102 - { from: keep.success, to: client.in }
103 - { from: keep.hit, to: client.in }
104"#;
105
106 fn app(s: Arc<SharedState>) -> Router {
107 router().with_state(s)
108 }
109
110 async fn send(state: &Arc<SharedState>, req: Request<Body>) -> (StatusCode, serde_json::Value) {
111 let resp = app(state.clone()).oneshot(req).await.unwrap();
112 let status = resp.status();
113 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
114 .await
115 .unwrap();
116 let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
117 (status, v)
118 }
119
120 #[tokio::test]
122 async fn test_purge_unknown_id_is_404_not_found() {
123 let s = state("{}", LOCAL_PAIR_GATEWAY);
124
125 let (status, body) = send(
126 &s,
127 Request::delete("/api/cache/no-such-pair")
128 .body(Body::empty())
129 .unwrap(),
130 )
131 .await;
132
133 assert_eq!(status, StatusCode::NOT_FOUND);
134 assert_eq!(body["error"], "not_found");
135 }
136
137 #[tokio::test]
142 async fn test_purge_known_id_removes_and_reports_local_backend() {
143 let s = state("{}", LOCAL_PAIR_GATEWAY);
144 let graphs: Vec<_> = s
145 .routes
146 .read()
147 .await
148 .iter()
149 .map(|(_, g)| g.clone())
150 .collect();
151 let backend = crate::traffic::collect_targets(&graphs, "products")
152 .remove(0)
153 .backend;
154 backend
155 .put(
156 "products\u{1}/x",
157 &crate::traffic::CachedResponse {
158 status: 200,
159 headers: Default::default(),
160 body: bytes::Bytes::from_static(b"x"),
161 },
162 std::time::Duration::from_secs(60),
163 )
164 .await
165 .unwrap();
166
167 let (status, body) = send(
168 &s,
169 Request::delete("/api/cache/products")
170 .body(Body::empty())
171 .unwrap(),
172 )
173 .await;
174
175 assert_eq!(status, StatusCode::OK);
176 assert_eq!(body["purged"][0]["removed"], 1);
177 assert_eq!(body["purged"][0]["backend"], "local");
178 assert!(
179 body["purged"][0].get("store").is_none(),
180 "a local backend entry must not carry a store key: {body}"
181 );
182 }
183}