tower_http_cache/admin/
routes.rs

1//! Admin API route handlers.
2//!
3//! This module provides the HTTP endpoint handlers for the admin API.
4//! Note: Full Axum integration would be feature-gated. This provides
5//! the core logic that can be wrapped by any HTTP framework.
6
7use crate::admin::{AdminConfig, AdminState};
8use crate::backend::CacheBackend;
9use serde::{Deserialize, Serialize};
10
11/// Health check response.
12#[derive(Debug, Serialize, Deserialize)]
13pub struct HealthResponse {
14    pub status: String,
15    pub timestamp: String,
16}
17
18/// Statistics response.
19#[derive(Debug, Serialize, Deserialize)]
20pub struct StatsResponse {
21    pub cache: CacheStats,
22}
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct CacheStats {
26    pub backend: String,
27    pub total_requests: u64,
28    pub hits: u64,
29    pub misses: u64,
30    pub stale_hits: u64,
31    pub stores: u64,
32    pub invalidations: u64,
33    pub hit_rate: f64,
34    pub miss_rate: f64,
35    pub uptime_seconds: u64,
36}
37
38/// Hot keys response.
39#[derive(Debug, Serialize, Deserialize)]
40pub struct HotKeysResponse {
41    pub hot_keys: Vec<HotKeyEntry>,
42}
43
44#[derive(Debug, Serialize, Deserialize)]
45pub struct HotKeyEntry {
46    pub key: String,
47    pub hits: u64,
48    pub last_accessed: String,
49}
50
51/// Tags list response.
52#[derive(Debug, Serialize, Deserialize)]
53pub struct TagsResponse {
54    pub tags: Vec<String>,
55    pub count: usize,
56}
57
58/// Invalidation request.
59#[derive(Debug, Serialize, Deserialize)]
60pub struct InvalidationRequest {
61    pub key: Option<String>,
62    pub tag: Option<String>,
63    pub tags: Option<Vec<String>>,
64}
65
66/// Invalidation response.
67#[derive(Debug, Serialize, Deserialize)]
68pub struct InvalidationResponse {
69    pub success: bool,
70    pub keys_invalidated: usize,
71    pub message: String,
72}
73
74/// Error response.
75#[derive(Debug, Serialize, Deserialize)]
76pub struct ErrorResponse {
77    pub error: String,
78    pub status: u16,
79}
80
81/// Handles health check requests.
82pub fn handle_health() -> HealthResponse {
83    HealthResponse {
84        status: "healthy".to_string(),
85        timestamp: chrono::Utc::now().to_rfc3339(),
86    }
87}
88
89/// Handles statistics requests.
90pub fn handle_stats<B: CacheBackend>(state: &AdminState<B>) -> StatsResponse {
91    let snapshot = state.stats.snapshot();
92
93    StatsResponse {
94        cache: CacheStats {
95            backend: "cache".to_string(),
96            total_requests: snapshot.total_requests,
97            hits: snapshot.hits,
98            misses: snapshot.misses,
99            stale_hits: snapshot.stale_hits,
100            stores: snapshot.stores,
101            invalidations: snapshot.invalidations,
102            hit_rate: snapshot.hit_rate,
103            miss_rate: snapshot.miss_rate,
104            uptime_seconds: snapshot.uptime_seconds,
105        },
106    }
107}
108
109/// Handles hot keys requests.
110pub fn handle_hot_keys<B: CacheBackend>(
111    state: &AdminState<B>,
112    limit: Option<usize>,
113) -> HotKeysResponse {
114    let limit = limit.unwrap_or(state.config.max_hot_keys).min(100);
115    let hot_keys = state.stats.hot_keys(limit);
116
117    HotKeysResponse {
118        hot_keys: hot_keys
119            .into_iter()
120            .map(|k| HotKeyEntry {
121                key: k.key,
122                hits: k.hits,
123                last_accessed: chrono::DateTime::<chrono::Utc>::from(k.last_accessed).to_rfc3339(),
124            })
125            .collect(),
126    }
127}
128
129/// Handles tag listing requests.
130pub async fn handle_list_tags<B: CacheBackend>(
131    state: &AdminState<B>,
132) -> Result<TagsResponse, String> {
133    let tags = state
134        .backend
135        .list_tags()
136        .await
137        .map_err(|e| format!("Failed to list tags: {}", e))?;
138
139    let count = tags.len();
140
141    Ok(TagsResponse { tags, count })
142}
143
144/// Handles invalidation requests.
145pub async fn handle_invalidate<B: CacheBackend>(
146    state: &AdminState<B>,
147    request: InvalidationRequest,
148) -> Result<InvalidationResponse, String> {
149    if let Some(key) = request.key {
150        // Invalidate single key
151        state
152            .backend
153            .invalidate(&key)
154            .await
155            .map_err(|e| format!("Failed to invalidate key: {}", e))?;
156
157        state.stats.record_invalidation();
158
159        Ok(InvalidationResponse {
160            success: true,
161            keys_invalidated: 1,
162            message: format!("Invalidated key: {}", key),
163        })
164    } else if let Some(tag) = request.tag {
165        // Invalidate by single tag
166        let count = state
167            .backend
168            .invalidate_by_tag(&tag)
169            .await
170            .map_err(|e| format!("Failed to invalidate by tag: {}", e))?;
171
172        for _ in 0..count {
173            state.stats.record_invalidation();
174        }
175
176        Ok(InvalidationResponse {
177            success: true,
178            keys_invalidated: count,
179            message: format!("Invalidated {} keys with tag: {}", count, tag),
180        })
181    } else if let Some(tags) = request.tags {
182        // Invalidate by multiple tags
183        let count = state
184            .backend
185            .invalidate_by_tags(&tags)
186            .await
187            .map_err(|e| format!("Failed to invalidate by tags: {}", e))?;
188
189        for _ in 0..count {
190            state.stats.record_invalidation();
191        }
192
193        Ok(InvalidationResponse {
194            success: true,
195            keys_invalidated: count,
196            message: format!("Invalidated {} keys", count),
197        })
198    } else {
199        Err("Must provide key, tag, or tags".to_string())
200    }
201}
202
203/// Validates authentication for admin requests.
204pub fn validate_auth(config: &AdminConfig, auth_header: Option<&str>) -> bool {
205    let token = auth_header.and_then(|h| {
206        // Extract token from "Bearer <token>"
207        h.strip_prefix("Bearer ").or(Some(h))
208    });
209
210    config.validate_token(token)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    use crate::backend::memory::InMemoryBackend;
218
219    fn test_state() -> AdminState<InMemoryBackend> {
220        let backend = InMemoryBackend::new(100);
221        let config = AdminConfig::new();
222        AdminState::new(backend, config)
223    }
224
225    #[test]
226    fn handle_health_returns_healthy() {
227        let response = handle_health();
228        assert_eq!(response.status, "healthy");
229        assert!(!response.timestamp.is_empty());
230    }
231
232    #[test]
233    fn handle_stats_returns_snapshot() {
234        let state = test_state();
235        state.stats.record_hit("key1");
236        state.stats.record_miss("key2");
237
238        let response = handle_stats(&state);
239        assert_eq!(response.cache.total_requests, 2);
240        assert_eq!(response.cache.hits, 1);
241        assert_eq!(response.cache.misses, 1);
242    }
243
244    #[test]
245    fn handle_hot_keys_returns_sorted_keys() {
246        let state = test_state();
247
248        for _ in 0..10 {
249            state.stats.record_hit("hot");
250        }
251        for _ in 0..5 {
252            state.stats.record_hit("warm");
253        }
254        state.stats.record_hit("cold");
255
256        let response = handle_hot_keys(&state, Some(2));
257        assert_eq!(response.hot_keys.len(), 2);
258        assert_eq!(response.hot_keys[0].key, "hot");
259        assert_eq!(response.hot_keys[0].hits, 10);
260    }
261
262    #[test]
263    fn handle_hot_keys_respects_limit() {
264        let state = test_state();
265
266        for i in 0..50 {
267            state.stats.record_hit(&format!("key{}", i));
268        }
269
270        let response = handle_hot_keys(&state, Some(10));
271        assert_eq!(response.hot_keys.len(), 10);
272
273        // Should cap at 100 even if requested higher
274        let response = handle_hot_keys(&state, Some(200));
275        assert!(response.hot_keys.len() <= 100);
276    }
277
278    #[tokio::test]
279    async fn handle_list_tags_returns_tags() {
280        let state = test_state();
281
282        // No tags initially
283        let response = handle_list_tags(&state).await.unwrap();
284        assert_eq!(response.count, 0);
285    }
286
287    #[tokio::test]
288    async fn handle_invalidate_by_key() {
289        let state = test_state();
290
291        let request = InvalidationRequest {
292            key: Some("test_key".to_string()),
293            tag: None,
294            tags: None,
295        };
296
297        let response = handle_invalidate(&state, request).await.unwrap();
298        assert!(response.success);
299        assert_eq!(response.keys_invalidated, 1);
300    }
301
302    #[tokio::test]
303    async fn handle_invalidate_by_tag() {
304        let state = test_state();
305
306        let request = InvalidationRequest {
307            key: None,
308            tag: Some("user:123".to_string()),
309            tags: None,
310        };
311
312        let response = handle_invalidate(&state, request).await.unwrap();
313        assert!(response.success);
314    }
315
316    #[tokio::test]
317    async fn handle_invalidate_by_tags() {
318        let state = test_state();
319
320        let request = InvalidationRequest {
321            key: None,
322            tag: None,
323            tags: Some(vec!["tag1".to_string(), "tag2".to_string()]),
324        };
325
326        let response = handle_invalidate(&state, request).await.unwrap();
327        assert!(response.success);
328    }
329
330    #[tokio::test]
331    async fn handle_invalidate_requires_parameter() {
332        let state = test_state();
333
334        let request = InvalidationRequest {
335            key: None,
336            tag: None,
337            tags: None,
338        };
339
340        let result = handle_invalidate(&state, request).await;
341        assert!(result.is_err());
342    }
343
344    #[test]
345    fn validate_auth_with_bearer_token() {
346        let config = AdminConfig::new()
347            .with_auth_token("secret123")
348            .with_require_auth(true);
349
350        assert!(validate_auth(&config, Some("Bearer secret123")));
351        assert!(validate_auth(&config, Some("secret123")));
352        assert!(!validate_auth(&config, Some("Bearer wrong")));
353        assert!(!validate_auth(&config, None));
354    }
355
356    #[test]
357    fn validate_auth_no_auth_required() {
358        let config = AdminConfig::new().with_require_auth(false);
359
360        assert!(validate_auth(&config, None));
361        assert!(validate_auth(&config, Some("anything")));
362    }
363}