Skip to main content

featherbit/admin/
cache.rs

1//! `DELETE /api/cache/{id}` -- purge everything a `proxy-cache` pair cached.
2//!
3//! A runtime action, like `DELETE /api/debug/traces` and
4//! `POST /api/stores/{name}/ping`: it changes no configuration.
5
6use 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/// Successful response body: the purged id and what each backend reported.
24#[derive(serde::Serialize)]
25struct PurgeResponse {
26    id: String,
27    purged: Vec<PurgeOutcome>,
28}
29
30/// Purges the pair `id` on every backend that holds it.
31///
32/// `404` when no compiled policy has a pair with that id: a typo must not
33/// read as a successful flush of nothing. `502` when a backend could not be
34/// reached, naming it and listing what did succeed first.
35async 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    /// The same `local` pair fixture the MCP `purge_cache` tests use, so
83    /// this handler and that tool are proven to find the same backend.
84    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    /// A typo'd id must not read as a successful flush of nothing.
121    #[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    /// Seeding directly into the backend the compiled pair actually uses
138    /// (found the same way the handler finds it), then purging by id,
139    /// reports exactly what was removed on the `local` backend and omits
140    /// `store` -- there is no named store for a `local` entry to report.
141    #[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}