Skip to main content

grapsus_proxy/
builtin_handlers.rs

1//! Built-in handlers for Grapsus proxy
2//!
3//! These handlers provide default responses for common endpoints like
4//! status pages, health checks, and metrics. They are used when routes
5//! are configured with `service-type: builtin`.
6
7use bytes::Bytes;
8use http::{Response, StatusCode};
9use http_body_util::Full;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tracing::{debug, info, trace};
15
16use grapsus_config::{BuiltinHandler, Config};
17
18use crate::cache::{CacheManager, HttpCacheStats};
19
20/// Application state for builtin handlers
21pub struct BuiltinHandlerState {
22    /// Application start time
23    start_time: Instant,
24    /// Application version
25    version: String,
26    /// Instance ID
27    instance_id: String,
28}
29
30impl BuiltinHandlerState {
31    /// Create new handler state
32    pub fn new(version: String, instance_id: String) -> Self {
33        Self {
34            start_time: Instant::now(),
35            version,
36            instance_id,
37        }
38    }
39
40    /// Get uptime as a Duration
41    pub fn uptime(&self) -> Duration {
42        self.start_time.elapsed()
43    }
44
45    /// Format uptime as human-readable string
46    pub fn uptime_string(&self) -> String {
47        let uptime = self.uptime();
48        let secs = uptime.as_secs();
49        let days = secs / 86400;
50        let hours = (secs % 86400) / 3600;
51        let mins = (secs % 3600) / 60;
52        let secs = secs % 60;
53
54        if days > 0 {
55            format!("{}d {}h {}m {}s", days, hours, mins, secs)
56        } else if hours > 0 {
57            format!("{}h {}m {}s", hours, mins, secs)
58        } else if mins > 0 {
59            format!("{}m {}s", mins, secs)
60        } else {
61            format!("{}s", secs)
62        }
63    }
64}
65
66/// Status response payload
67#[derive(Debug, Serialize)]
68pub struct StatusResponse {
69    /// Service status
70    pub status: &'static str,
71    /// Service version
72    pub version: String,
73    /// Service uptime
74    pub uptime: String,
75    /// Uptime in seconds
76    pub uptime_secs: u64,
77    /// Instance identifier
78    pub instance_id: String,
79    /// Timestamp
80    pub timestamp: String,
81}
82
83/// Health check response
84#[derive(Debug, Serialize)]
85pub struct HealthResponse {
86    /// Health status
87    pub status: &'static str,
88    /// Timestamp
89    pub timestamp: String,
90}
91
92/// Upstream health snapshot for the upstreams handler
93#[derive(Debug, Clone, Default)]
94pub struct UpstreamHealthSnapshot {
95    /// Health status per upstream, keyed by upstream ID
96    pub upstreams: HashMap<String, UpstreamStatus>,
97}
98
99/// Status of a single upstream
100#[derive(Debug, Clone, Serialize)]
101pub struct UpstreamStatus {
102    /// Upstream ID
103    pub id: String,
104    /// Load balancing algorithm
105    pub load_balancing: String,
106    /// Target statuses
107    pub targets: Vec<TargetStatus>,
108}
109
110/// Status of a single target within an upstream
111#[derive(Debug, Clone, Serialize)]
112pub struct TargetStatus {
113    /// Target address
114    pub address: String,
115    /// Weight
116    pub weight: u32,
117    /// Health status
118    pub status: TargetHealthStatus,
119    /// Failure rate (0.0 - 1.0)
120    pub failure_rate: Option<f64>,
121    /// Last error message if unhealthy
122    pub last_error: Option<String>,
123}
124
125/// Health status of a target
126#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
127#[serde(rename_all = "lowercase")]
128pub enum TargetHealthStatus {
129    /// Target is healthy
130    Healthy,
131    /// Target is unhealthy
132    Unhealthy,
133    /// Health status unknown (no checks yet)
134    Unknown,
135}
136
137/// Cache purge request details
138#[derive(Debug, Clone)]
139pub struct CachePurgeRequest {
140    /// Pattern to purge (URL path or wildcard pattern)
141    pub pattern: String,
142    /// Whether this is a wildcard purge (purge all matching pattern)
143    pub wildcard: bool,
144}
145
146/// Execute a builtin handler
147pub fn execute_handler(
148    handler: BuiltinHandler,
149    state: &BuiltinHandlerState,
150    request_id: &str,
151    config: Option<Arc<Config>>,
152    upstreams: Option<UpstreamHealthSnapshot>,
153    cache_stats: Option<Arc<HttpCacheStats>>,
154    cache_purge: Option<CachePurgeRequest>,
155    cache_manager: Option<&Arc<CacheManager>>,
156) -> Response<Full<Bytes>> {
157    trace!(
158        handler = ?handler,
159        request_id = %request_id,
160        "Executing builtin handler"
161    );
162
163    let response = match handler {
164        BuiltinHandler::Status => status_handler(state, request_id),
165        BuiltinHandler::Health => health_handler(request_id),
166        BuiltinHandler::Metrics => metrics_handler(request_id, cache_stats.as_ref()),
167        BuiltinHandler::NotFound => not_found_handler(request_id),
168        BuiltinHandler::Config => config_handler(config, request_id),
169        BuiltinHandler::Upstreams => upstreams_handler(upstreams, request_id),
170        BuiltinHandler::CachePurge => cache_purge_handler(cache_purge, cache_manager, request_id),
171        BuiltinHandler::CacheStats => cache_stats_handler(cache_stats, request_id),
172    };
173
174    debug!(
175        handler = ?handler,
176        request_id = %request_id,
177        status = response.status().as_u16(),
178        "Builtin handler completed"
179    );
180
181    response
182}
183
184/// JSON status page handler
185fn status_handler(state: &BuiltinHandlerState, request_id: &str) -> Response<Full<Bytes>> {
186    trace!(
187        request_id = %request_id,
188        uptime_secs = state.uptime().as_secs(),
189        "Generating status response"
190    );
191
192    let response = StatusResponse {
193        status: "ok",
194        version: state.version.clone(),
195        uptime: state.uptime_string(),
196        uptime_secs: state.uptime().as_secs(),
197        instance_id: state.instance_id.clone(),
198        timestamp: chrono::Utc::now().to_rfc3339(),
199    };
200
201    let body =
202        serde_json::to_vec_pretty(&response).unwrap_or_else(|_| b"{\"status\":\"ok\"}".to_vec());
203
204    Response::builder()
205        .status(StatusCode::OK)
206        .header("Content-Type", "application/json; charset=utf-8")
207        .header("X-Request-Id", request_id)
208        .header("Cache-Control", "no-cache, no-store, must-revalidate")
209        .body(Full::new(Bytes::from(body)))
210        .expect("static response builder with valid headers cannot fail")
211}
212
213/// Health check handler
214fn health_handler(request_id: &str) -> Response<Full<Bytes>> {
215    let response = HealthResponse {
216        status: "healthy",
217        timestamp: chrono::Utc::now().to_rfc3339(),
218    };
219
220    let body =
221        serde_json::to_vec(&response).unwrap_or_else(|_| b"{\"status\":\"healthy\"}".to_vec());
222
223    Response::builder()
224        .status(StatusCode::OK)
225        .header("Content-Type", "application/json; charset=utf-8")
226        .header("X-Request-Id", request_id)
227        .header("Cache-Control", "no-cache, no-store, must-revalidate")
228        .body(Full::new(Bytes::from(body)))
229        .expect("static response builder with valid headers cannot fail")
230}
231
232/// Prometheus metrics handler
233fn metrics_handler(
234    request_id: &str,
235    cache_stats: Option<&Arc<HttpCacheStats>>,
236) -> Response<Full<Bytes>> {
237    use prometheus::{Encoder, TextEncoder};
238
239    // Create encoder for Prometheus text format
240    let encoder = TextEncoder::new();
241
242    // Gather all metrics from the default registry
243    let metric_families = prometheus::gather();
244
245    // Encode metrics to text format
246    let mut buffer = Vec::new();
247    match encoder.encode(&metric_families, &mut buffer) {
248        Ok(()) => {
249            // Add grapsus_up and build_info metrics
250            let extra_metrics = format!(
251                "# HELP grapsus_up Grapsus proxy is up and running\n\
252                 # TYPE grapsus_up gauge\n\
253                 grapsus_up 1\n\
254                 # HELP grapsus_build_info Build information\n\
255                 # TYPE grapsus_build_info gauge\n\
256                 grapsus_build_info{{version=\"{}\"}} 1\n",
257                env!("CARGO_PKG_VERSION")
258            );
259            buffer.extend_from_slice(extra_metrics.as_bytes());
260
261            // Add HTTP cache metrics if available
262            if let Some(stats) = cache_stats {
263                let cache_metrics = format!(
264                    "# HELP grapsus_cache_hits_total Total number of cache hits\n\
265                     # TYPE grapsus_cache_hits_total counter\n\
266                     grapsus_cache_hits_total {}\n\
267                     # HELP grapsus_cache_misses_total Total number of cache misses\n\
268                     # TYPE grapsus_cache_misses_total counter\n\
269                     grapsus_cache_misses_total {}\n\
270                     # HELP grapsus_cache_stores_total Total number of cache stores\n\
271                     # TYPE grapsus_cache_stores_total counter\n\
272                     grapsus_cache_stores_total {}\n\
273                     # HELP grapsus_cache_hit_ratio Cache hit ratio (0.0 to 1.0)\n\
274                     # TYPE grapsus_cache_hit_ratio gauge\n\
275                     grapsus_cache_hit_ratio {:.4}\n\
276                     # HELP grapsus_cache_memory_hits_total Cache hits from memory tier\n\
277                     # TYPE grapsus_cache_memory_hits_total counter\n\
278                     grapsus_cache_memory_hits_total {}\n\
279                     # HELP grapsus_cache_disk_hits_total Cache hits from disk tier\n\
280                     # TYPE grapsus_cache_disk_hits_total counter\n\
281                     grapsus_cache_disk_hits_total {}\n",
282                    stats.hits(),
283                    stats.misses(),
284                    stats.stores(),
285                    stats.hit_ratio(),
286                    stats.memory_hits(),
287                    stats.disk_hits()
288                );
289                buffer.extend_from_slice(cache_metrics.as_bytes());
290            }
291
292            Response::builder()
293                .status(StatusCode::OK)
294                .header("Content-Type", encoder.format_type())
295                .header("X-Request-Id", request_id)
296                .body(Full::new(Bytes::from(buffer)))
297                .expect("static response builder with valid headers cannot fail")
298        }
299        Err(e) => {
300            tracing::error!(error = %e, "Failed to encode Prometheus metrics");
301            let error_body = format!("# ERROR: Failed to encode metrics: {}\n", e);
302            Response::builder()
303                .status(StatusCode::INTERNAL_SERVER_ERROR)
304                .header("Content-Type", "text/plain; charset=utf-8")
305                .header("X-Request-Id", request_id)
306                .body(Full::new(Bytes::from(error_body)))
307                .expect("static response builder with valid headers cannot fail")
308        }
309    }
310}
311
312/// 404 Not Found handler
313fn not_found_handler(request_id: &str) -> Response<Full<Bytes>> {
314    let body = serde_json::json!({
315        "error": "Not Found",
316        "status": 404,
317        "message": "The requested resource could not be found.",
318        "request_id": request_id,
319        "timestamp": chrono::Utc::now().to_rfc3339(),
320    });
321
322    let body_bytes = serde_json::to_vec_pretty(&body)
323        .unwrap_or_else(|_| b"{\"error\":\"Not Found\",\"status\":404}".to_vec());
324
325    Response::builder()
326        .status(StatusCode::NOT_FOUND)
327        .header("Content-Type", "application/json; charset=utf-8")
328        .header("X-Request-Id", request_id)
329        .body(Full::new(Bytes::from(body_bytes)))
330        .expect("static response builder with valid headers cannot fail")
331}
332
333/// Configuration dump handler
334///
335/// Returns the current running configuration as JSON. Sensitive fields like
336/// TLS private keys are redacted for security.
337fn config_handler(config: Option<Arc<Config>>, request_id: &str) -> Response<Full<Bytes>> {
338    let body = match &config {
339        Some(cfg) => {
340            // Build a response with configuration details
341            // The Config struct derives Serialize, so we can serialize directly
342            // Note: sensitive fields should be redacted in production
343            let response = serde_json::json!({
344                "timestamp": chrono::Utc::now().to_rfc3339(),
345                "request_id": request_id,
346                "config": {
347                    "server": &cfg.server,
348                    "listeners": cfg.listeners.iter().map(|l| {
349                        serde_json::json!({
350                            "id": l.id,
351                            "address": l.address,
352                            "protocol": l.protocol,
353                            "default_route": l.default_route,
354                            "request_timeout_secs": l.request_timeout_secs,
355                            "keepalive_timeout_secs": l.keepalive_timeout_secs,
356                            // TLS config is redacted - only show if enabled
357                            "tls_enabled": l.tls.is_some(),
358                        })
359                    }).collect::<Vec<_>>(),
360                    "routes": cfg.routes.iter().map(|r| {
361                        serde_json::json!({
362                            "id": r.id,
363                            "priority": r.priority,
364                            "matches": r.matches,
365                            "upstream": r.upstream,
366                            "service_type": r.service_type,
367                            "builtin_handler": r.builtin_handler,
368                            "filters": r.filters,
369                            "waf_enabled": r.waf_enabled,
370                        })
371                    }).collect::<Vec<_>>(),
372                    "upstreams": cfg.upstreams.iter().map(|(id, u)| {
373                        serde_json::json!({
374                            "id": id,
375                            "targets": u.targets.iter().map(|t| {
376                                serde_json::json!({
377                                    "address": t.address,
378                                    "weight": t.weight,
379                                })
380                            }).collect::<Vec<_>>(),
381                            "load_balancing": u.load_balancing,
382                            "health_check": u.health_check.as_ref().map(|h| {
383                                serde_json::json!({
384                                    "interval_secs": h.interval_secs,
385                                    "timeout_secs": h.timeout_secs,
386                                    "healthy_threshold": h.healthy_threshold,
387                                    "unhealthy_threshold": h.unhealthy_threshold,
388                                })
389                            }),
390                            // TLS config redacted
391                            "tls_enabled": u.tls.is_some(),
392                        })
393                    }).collect::<Vec<_>>(),
394                    "agents": cfg.agents.iter().map(|a| {
395                        serde_json::json!({
396                            "id": a.id,
397                            "agent_type": a.agent_type,
398                            "timeout_ms": a.timeout_ms,
399                        })
400                    }).collect::<Vec<_>>(),
401                    "filters": cfg.filters.keys().collect::<Vec<_>>(),
402                    "waf": cfg.waf.as_ref().map(|w| {
403                        serde_json::json!({
404                            "mode": w.mode,
405                            "engine": w.engine,
406                            "audit_log": w.audit_log,
407                        })
408                    }),
409                    "limits": &cfg.limits,
410                }
411            });
412
413            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
414                serde_json::to_vec(&serde_json::json!({
415                    "error": "Failed to serialize config",
416                    "message": e.to_string(),
417                }))
418                .unwrap_or_default()
419            })
420        }
421        None => serde_json::to_vec_pretty(&serde_json::json!({
422            "error": "Configuration unavailable",
423            "status": 503,
424            "message": "Config manager not available",
425            "request_id": request_id,
426            "timestamp": chrono::Utc::now().to_rfc3339(),
427        }))
428        .unwrap_or_default(),
429    };
430
431    let status = if config.is_some() {
432        StatusCode::OK
433    } else {
434        StatusCode::SERVICE_UNAVAILABLE
435    };
436
437    Response::builder()
438        .status(status)
439        .header("Content-Type", "application/json; charset=utf-8")
440        .header("X-Request-Id", request_id)
441        .header("Cache-Control", "no-cache, no-store, must-revalidate")
442        .body(Full::new(Bytes::from(body)))
443        .expect("static response builder with valid headers cannot fail")
444}
445
446/// Upstream health status handler
447///
448/// Returns the health status of all configured upstreams and their targets.
449fn upstreams_handler(
450    snapshot: Option<UpstreamHealthSnapshot>,
451    request_id: &str,
452) -> Response<Full<Bytes>> {
453    let body = match snapshot {
454        Some(data) => {
455            // Count healthy/unhealthy/unknown targets
456            let mut total_healthy = 0;
457            let mut total_unhealthy = 0;
458            let mut total_unknown = 0;
459
460            for upstream in data.upstreams.values() {
461                for target in &upstream.targets {
462                    match target.status {
463                        TargetHealthStatus::Healthy => total_healthy += 1,
464                        TargetHealthStatus::Unhealthy => total_unhealthy += 1,
465                        TargetHealthStatus::Unknown => total_unknown += 1,
466                    }
467                }
468            }
469
470            let response = serde_json::json!({
471                "timestamp": chrono::Utc::now().to_rfc3339(),
472                "request_id": request_id,
473                "summary": {
474                    "total_upstreams": data.upstreams.len(),
475                    "total_targets": total_healthy + total_unhealthy + total_unknown,
476                    "healthy": total_healthy,
477                    "unhealthy": total_unhealthy,
478                    "unknown": total_unknown,
479                },
480                "upstreams": data.upstreams.values().collect::<Vec<_>>(),
481            });
482
483            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
484                serde_json::to_vec(&serde_json::json!({
485                    "error": "Failed to serialize upstreams",
486                    "message": e.to_string(),
487                }))
488                .unwrap_or_default()
489            })
490        }
491        None => {
492            // No upstreams configured or data unavailable
493            serde_json::to_vec_pretty(&serde_json::json!({
494                "timestamp": chrono::Utc::now().to_rfc3339(),
495                "request_id": request_id,
496                "summary": {
497                    "total_upstreams": 0,
498                    "total_targets": 0,
499                    "healthy": 0,
500                    "unhealthy": 0,
501                    "unknown": 0,
502                },
503                "upstreams": [],
504                "message": "No upstreams configured",
505            }))
506            .unwrap_or_default()
507        }
508    };
509
510    Response::builder()
511        .status(StatusCode::OK)
512        .header("Content-Type", "application/json; charset=utf-8")
513        .header("X-Request-Id", request_id)
514        .header("Cache-Control", "no-cache, no-store, must-revalidate")
515        .body(Full::new(Bytes::from(body)))
516        .expect("static response builder with valid headers cannot fail")
517}
518
519/// Cache purge handler
520///
521/// Handles PURGE requests to invalidate cache entries. Accepts a pattern
522/// and optionally purges all matching entries if wildcard is enabled.
523fn cache_purge_handler(
524    purge_request: Option<CachePurgeRequest>,
525    cache_manager: Option<&Arc<CacheManager>>,
526    request_id: &str,
527) -> Response<Full<Bytes>> {
528    let body = match (&purge_request, cache_manager) {
529        (Some(request), Some(manager)) => {
530            info!(
531                pattern = %request.pattern,
532                wildcard = request.wildcard,
533                request_id = %request_id,
534                "Processing cache purge request"
535            );
536
537            // Execute the actual purge via CacheManager
538            let purged_count = if request.wildcard {
539                // Wildcard purge - register pattern for matching
540                manager.purge_wildcard(&request.pattern)
541            } else {
542                // Single entry purge
543                manager.purge(&request.pattern)
544            };
545
546            info!(
547                pattern = %request.pattern,
548                wildcard = request.wildcard,
549                purged_count = purged_count,
550                request_id = %request_id,
551                "Cache purge completed"
552            );
553
554            serde_json::to_vec_pretty(&serde_json::json!({
555                "status": "ok",
556                "message": "Cache purge request processed",
557                "pattern": request.pattern,
558                "wildcard": request.wildcard,
559                "purged_entries": purged_count,
560                "active_purges": manager.active_purge_count(),
561                "request_id": request_id,
562                "timestamp": chrono::Utc::now().to_rfc3339(),
563            }))
564            .unwrap_or_default()
565        }
566        (Some(request), None) => {
567            // Cache manager not available - log warning and acknowledge request
568            tracing::warn!(
569                pattern = %request.pattern,
570                request_id = %request_id,
571                "Cache purge requested but cache manager not available"
572            );
573
574            serde_json::to_vec_pretty(&serde_json::json!({
575                "status": "warning",
576                "message": "Cache purge acknowledged but cache manager unavailable",
577                "pattern": request.pattern,
578                "wildcard": request.wildcard,
579                "purged_entries": 0,
580                "request_id": request_id,
581                "timestamp": chrono::Utc::now().to_rfc3339(),
582            }))
583            .unwrap_or_default()
584        }
585        (None, _) => {
586            // No purge request provided - return error
587            serde_json::to_vec_pretty(&serde_json::json!({
588                "error": "Bad Request",
589                "status": 400,
590                "message": "Cache purge requires a pattern. Use PURGE /path or X-Purge-Pattern header.",
591                "request_id": request_id,
592                "timestamp": chrono::Utc::now().to_rfc3339(),
593            })).unwrap_or_default()
594        }
595    };
596
597    let status = if purge_request.is_some() {
598        StatusCode::OK
599    } else {
600        StatusCode::BAD_REQUEST
601    };
602
603    Response::builder()
604        .status(status)
605        .header("Content-Type", "application/json; charset=utf-8")
606        .header("X-Request-Id", request_id)
607        .header("Cache-Control", "no-cache, no-store, must-revalidate")
608        .body(Full::new(Bytes::from(body)))
609        .expect("static response builder with valid headers cannot fail")
610}
611
612/// Cache statistics response
613#[derive(Debug, Serialize)]
614struct CacheStatsResponse {
615    /// Total cache hits
616    hits: u64,
617    /// Total cache misses
618    misses: u64,
619    /// Total cache stores
620    stores: u64,
621    /// Total cache evictions
622    evictions: u64,
623    /// Cache hit ratio (0.0 to 1.0)
624    hit_ratio: f64,
625    /// Memory-tier hits (hybrid cache)
626    memory_hits: u64,
627    /// Disk-tier hits (hybrid cache)
628    disk_hits: u64,
629    /// Request ID
630    request_id: String,
631    /// Timestamp
632    timestamp: String,
633}
634
635/// Cache statistics handler
636///
637/// Returns current cache statistics including hits, misses, and hit ratio.
638fn cache_stats_handler(
639    cache_stats: Option<Arc<HttpCacheStats>>,
640    request_id: &str,
641) -> Response<Full<Bytes>> {
642    let body = match cache_stats {
643        Some(stats) => {
644            let response = CacheStatsResponse {
645                hits: stats.hits(),
646                misses: stats.misses(),
647                stores: stats.stores(),
648                evictions: stats.evictions(),
649                hit_ratio: stats.hit_ratio(),
650                memory_hits: stats.memory_hits(),
651                disk_hits: stats.disk_hits(),
652                request_id: request_id.to_string(),
653                timestamp: chrono::Utc::now().to_rfc3339(),
654            };
655
656            serde_json::to_vec_pretty(&response)
657                .unwrap_or_else(|_| b"{\"error\":\"Failed to serialize stats\"}".to_vec())
658        }
659        None => serde_json::to_vec_pretty(&serde_json::json!({
660            "hits": 0,
661            "misses": 0,
662            "stores": 0,
663            "evictions": 0,
664            "hit_ratio": 0.0,
665            "memory_hits": 0,
666            "disk_hits": 0,
667            "message": "Cache statistics not available",
668            "request_id": request_id,
669            "timestamp": chrono::Utc::now().to_rfc3339(),
670        }))
671        .unwrap_or_default(),
672    };
673
674    Response::builder()
675        .status(StatusCode::OK)
676        .header("Content-Type", "application/json; charset=utf-8")
677        .header("X-Request-Id", request_id)
678        .header("Cache-Control", "no-cache, no-store, must-revalidate")
679        .body(Full::new(Bytes::from(body)))
680        .expect("static response builder with valid headers cannot fail")
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn test_status_handler() {
689        let state = BuiltinHandlerState::new("0.1.0".to_string(), "test-instance".to_string());
690
691        let response = status_handler(&state, "test-request-id");
692        assert_eq!(response.status(), StatusCode::OK);
693
694        let content_type = response.headers().get("Content-Type").unwrap();
695        assert_eq!(content_type, "application/json; charset=utf-8");
696    }
697
698    #[test]
699    fn test_health_handler() {
700        let response = health_handler("test-request-id");
701        assert_eq!(response.status(), StatusCode::OK);
702    }
703
704    #[test]
705    fn test_metrics_handler() {
706        let response = metrics_handler("test-request-id", None);
707        assert_eq!(response.status(), StatusCode::OK);
708
709        let content_type = response.headers().get("Content-Type").unwrap();
710        assert!(content_type.to_str().unwrap().contains("text/plain"));
711    }
712
713    #[test]
714    fn test_metrics_handler_with_cache_stats() {
715        let stats = Arc::new(HttpCacheStats::default());
716        stats.record_hit();
717        stats.record_miss();
718        stats.record_store();
719
720        let response = metrics_handler("test-request-id", Some(&stats));
721        assert_eq!(response.status(), StatusCode::OK);
722    }
723
724    #[test]
725    fn test_cache_purge_handler_with_request() {
726        let cache_manager = Arc::new(CacheManager::new());
727        let request = CachePurgeRequest {
728            pattern: "/api/users/*".to_string(),
729            wildcard: true,
730        };
731        let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
732        assert_eq!(response.status(), StatusCode::OK);
733
734        // Verify the purge was actually registered
735        assert!(cache_manager.active_purge_count() > 0);
736    }
737
738    #[test]
739    fn test_cache_purge_handler_single_entry() {
740        let cache_manager = Arc::new(CacheManager::new());
741        let request = CachePurgeRequest {
742            pattern: "/api/users/123".to_string(),
743            wildcard: false,
744        };
745        let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
746        assert_eq!(response.status(), StatusCode::OK);
747
748        // Verify the purge was registered
749        assert!(cache_manager.should_invalidate("/api/users/123"));
750    }
751
752    #[test]
753    fn test_cache_purge_handler_without_request() {
754        let cache_manager = Arc::new(CacheManager::new());
755        let response = cache_purge_handler(None, Some(&cache_manager), "test-request-id");
756        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
757    }
758
759    #[test]
760    fn test_cache_purge_handler_without_manager() {
761        let request = CachePurgeRequest {
762            pattern: "/api/users/*".to_string(),
763            wildcard: true,
764        };
765        // Without cache manager, should still return OK but with warning
766        let response = cache_purge_handler(Some(request), None, "test-request-id");
767        assert_eq!(response.status(), StatusCode::OK);
768    }
769
770    #[test]
771    fn test_cache_stats_handler_with_stats() {
772        let stats = Arc::new(HttpCacheStats::default());
773        stats.record_hit();
774        stats.record_hit();
775        stats.record_miss();
776
777        let response = cache_stats_handler(Some(stats), "test-request-id");
778        assert_eq!(response.status(), StatusCode::OK);
779
780        let content_type = response.headers().get("Content-Type").unwrap();
781        assert_eq!(content_type, "application/json; charset=utf-8");
782    }
783
784    #[test]
785    fn test_cache_stats_handler_without_stats() {
786        let response = cache_stats_handler(None, "test-request-id");
787        assert_eq!(response.status(), StatusCode::OK);
788    }
789
790    #[test]
791    fn test_not_found_handler() {
792        let response = not_found_handler("test-request-id");
793        assert_eq!(response.status(), StatusCode::NOT_FOUND);
794    }
795
796    #[test]
797    fn test_config_handler_with_config() {
798        let config = Arc::new(Config::default_for_testing());
799        let response = config_handler(Some(config), "test-request-id");
800        assert_eq!(response.status(), StatusCode::OK);
801
802        let content_type = response.headers().get("Content-Type").unwrap();
803        assert_eq!(content_type, "application/json; charset=utf-8");
804    }
805
806    #[test]
807    fn test_config_handler_without_config() {
808        let response = config_handler(None, "test-request-id");
809        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
810    }
811
812    #[test]
813    fn test_upstreams_handler_with_data() {
814        let mut upstreams = HashMap::new();
815        upstreams.insert(
816            "backend".to_string(),
817            UpstreamStatus {
818                id: "backend".to_string(),
819                load_balancing: "round_robin".to_string(),
820                targets: vec![
821                    TargetStatus {
822                        address: "10.0.0.1:8080".to_string(),
823                        weight: 1,
824                        status: TargetHealthStatus::Healthy,
825                        failure_rate: Some(0.0),
826                        last_error: None,
827                    },
828                    TargetStatus {
829                        address: "10.0.0.2:8080".to_string(),
830                        weight: 1,
831                        status: TargetHealthStatus::Unhealthy,
832                        failure_rate: Some(0.8),
833                        last_error: Some("connection refused".to_string()),
834                    },
835                ],
836            },
837        );
838
839        let snapshot = UpstreamHealthSnapshot { upstreams };
840        let response = upstreams_handler(Some(snapshot), "test-request-id");
841        assert_eq!(response.status(), StatusCode::OK);
842
843        let content_type = response.headers().get("Content-Type").unwrap();
844        assert_eq!(content_type, "application/json; charset=utf-8");
845    }
846
847    #[test]
848    fn test_upstreams_handler_no_upstreams() {
849        let response = upstreams_handler(None, "test-request-id");
850        assert_eq!(response.status(), StatusCode::OK);
851    }
852
853    #[test]
854    fn test_uptime_formatting() {
855        let state = BuiltinHandlerState::new("0.1.0".to_string(), "test".to_string());
856
857        // Just verify it doesn't panic and returns a string
858        let uptime = state.uptime_string();
859        assert!(!uptime.is_empty());
860    }
861}