Skip to main content

zentinel_proxy/
builtin_handlers.rs

1//! Built-in handlers for Zentinel 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 zentinel_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/// Content type for the Prometheus text exposition format.
233pub(crate) const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
234
235/// Render the Prometheus exposition body.
236///
237/// Shared by the builtin `/metrics` route handler and the standalone metrics
238/// server (see [`crate::metrics_server`]) so both expose identical output.
239///
240/// # Errors
241///
242/// Returns an error if the Prometheus encoder fails to serialize the gathered
243/// metric families (in practice this does not happen for the text encoder).
244pub(crate) fn render_prometheus_metrics(
245    cache_stats: Option<&Arc<HttpCacheStats>>,
246) -> Result<Vec<u8>, prometheus::Error> {
247    use prometheus::{Encoder, TextEncoder};
248
249    let encoder = TextEncoder::new();
250    let metric_families = prometheus::gather();
251
252    let mut buffer = Vec::new();
253    encoder.encode(&metric_families, &mut buffer)?;
254
255    // Add zentinel_up and build_info metrics
256    let extra_metrics = format!(
257        "# HELP zentinel_up Zentinel proxy is up and running\n\
258         # TYPE zentinel_up gauge\n\
259         zentinel_up 1\n\
260         # HELP zentinel_build_info Build information\n\
261         # TYPE zentinel_build_info gauge\n\
262         zentinel_build_info{{version=\"{}\"}} 1\n",
263        env!("CARGO_PKG_VERSION")
264    );
265    buffer.extend_from_slice(extra_metrics.as_bytes());
266
267    // Add HTTP cache metrics if available
268    if let Some(stats) = cache_stats {
269        let cache_metrics = format!(
270            "# HELP zentinel_cache_hits_total Total number of cache hits\n\
271             # TYPE zentinel_cache_hits_total counter\n\
272             zentinel_cache_hits_total {}\n\
273             # HELP zentinel_cache_misses_total Total number of cache misses\n\
274             # TYPE zentinel_cache_misses_total counter\n\
275             zentinel_cache_misses_total {}\n\
276             # HELP zentinel_cache_stores_total Total number of cache stores\n\
277             # TYPE zentinel_cache_stores_total counter\n\
278             zentinel_cache_stores_total {}\n\
279             # HELP zentinel_cache_hit_ratio Cache hit ratio (0.0 to 1.0)\n\
280             # TYPE zentinel_cache_hit_ratio gauge\n\
281             zentinel_cache_hit_ratio {:.4}\n\
282             # HELP zentinel_cache_memory_hits_total Cache hits from memory tier\n\
283             # TYPE zentinel_cache_memory_hits_total counter\n\
284             zentinel_cache_memory_hits_total {}\n\
285             # HELP zentinel_cache_disk_hits_total Cache hits from disk tier\n\
286             # TYPE zentinel_cache_disk_hits_total counter\n\
287             zentinel_cache_disk_hits_total {}\n",
288            stats.hits(),
289            stats.misses(),
290            stats.stores(),
291            stats.hit_ratio(),
292            stats.memory_hits(),
293            stats.disk_hits()
294        );
295        buffer.extend_from_slice(cache_metrics.as_bytes());
296    }
297
298    Ok(buffer)
299}
300
301/// Prometheus metrics handler
302fn metrics_handler(
303    request_id: &str,
304    cache_stats: Option<&Arc<HttpCacheStats>>,
305) -> Response<Full<Bytes>> {
306    match render_prometheus_metrics(cache_stats) {
307        Ok(buffer) => Response::builder()
308            .status(StatusCode::OK)
309            .header("Content-Type", PROMETHEUS_CONTENT_TYPE)
310            .header("X-Request-Id", request_id)
311            .body(Full::new(Bytes::from(buffer)))
312            .expect("static response builder with valid headers cannot fail"),
313        Err(e) => {
314            tracing::error!(error = %e, "Failed to encode Prometheus metrics");
315            let error_body = format!("# ERROR: Failed to encode metrics: {}\n", e);
316            Response::builder()
317                .status(StatusCode::INTERNAL_SERVER_ERROR)
318                .header("Content-Type", "text/plain; charset=utf-8")
319                .header("X-Request-Id", request_id)
320                .body(Full::new(Bytes::from(error_body)))
321                .expect("static response builder with valid headers cannot fail")
322        }
323    }
324}
325
326/// 404 Not Found handler
327fn not_found_handler(request_id: &str) -> Response<Full<Bytes>> {
328    let body = serde_json::json!({
329        "error": "Not Found",
330        "status": 404,
331        "message": "The requested resource could not be found.",
332        "request_id": request_id,
333        "timestamp": chrono::Utc::now().to_rfc3339(),
334    });
335
336    let body_bytes = serde_json::to_vec_pretty(&body)
337        .unwrap_or_else(|_| b"{\"error\":\"Not Found\",\"status\":404}".to_vec());
338
339    Response::builder()
340        .status(StatusCode::NOT_FOUND)
341        .header("Content-Type", "application/json; charset=utf-8")
342        .header("X-Request-Id", request_id)
343        .body(Full::new(Bytes::from(body_bytes)))
344        .expect("static response builder with valid headers cannot fail")
345}
346
347/// Configuration dump handler
348///
349/// Returns the current running configuration as JSON. Sensitive fields like
350/// TLS private keys are redacted for security.
351fn config_handler(config: Option<Arc<Config>>, request_id: &str) -> Response<Full<Bytes>> {
352    let body = match &config {
353        Some(cfg) => {
354            // Build a response with configuration details
355            // The Config struct derives Serialize, so we can serialize directly
356            // Note: sensitive fields should be redacted in production
357            let response = serde_json::json!({
358                "timestamp": chrono::Utc::now().to_rfc3339(),
359                "request_id": request_id,
360                "config": {
361                    "server": &cfg.server,
362                    "listeners": cfg.listeners.iter().map(|l| {
363                        serde_json::json!({
364                            "id": l.id,
365                            "address": l.address,
366                            "protocol": l.protocol,
367                            "default_route": l.default_route,
368                            "request_timeout_secs": l.request_timeout_secs,
369                            "keepalive_timeout_secs": l.keepalive_timeout_secs,
370                            // TLS config is redacted - only show if enabled
371                            "tls_enabled": l.tls.is_some(),
372                        })
373                    }).collect::<Vec<_>>(),
374                    "routes": cfg.routes.iter().map(|r| {
375                        serde_json::json!({
376                            "id": r.id,
377                            "priority": r.priority,
378                            "matches": r.matches,
379                            "upstream": r.upstream,
380                            "service_type": r.service_type,
381                            "builtin_handler": r.builtin_handler,
382                            "filters": r.filters,
383                            "waf_enabled": r.waf_enabled,
384                        })
385                    }).collect::<Vec<_>>(),
386                    "upstreams": cfg.upstreams.iter().map(|(id, u)| {
387                        serde_json::json!({
388                            "id": id,
389                            "targets": u.targets.iter().map(|t| {
390                                serde_json::json!({
391                                    "address": t.address,
392                                    "weight": t.weight,
393                                })
394                            }).collect::<Vec<_>>(),
395                            "load_balancing": u.load_balancing,
396                            "health_check": u.health_check.as_ref().map(|h| {
397                                serde_json::json!({
398                                    "interval_secs": h.interval_secs,
399                                    "timeout_secs": h.timeout_secs,
400                                    "healthy_threshold": h.healthy_threshold,
401                                    "unhealthy_threshold": h.unhealthy_threshold,
402                                })
403                            }),
404                            // TLS config redacted
405                            "tls_enabled": u.tls.is_some(),
406                        })
407                    }).collect::<Vec<_>>(),
408                    "agents": cfg.agents.iter().map(|a| {
409                        serde_json::json!({
410                            "id": a.id,
411                            "agent_type": a.agent_type,
412                            "timeout_ms": a.timeout_ms,
413                        })
414                    }).collect::<Vec<_>>(),
415                    "filters": cfg.filters.keys().collect::<Vec<_>>(),
416                    "waf": cfg.waf.as_ref().map(|w| {
417                        serde_json::json!({
418                            "mode": w.mode,
419                            "engine": w.engine,
420                            "audit_log": w.audit_log,
421                        })
422                    }),
423                    "limits": &cfg.limits,
424                }
425            });
426
427            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
428                serde_json::to_vec(&serde_json::json!({
429                    "error": "Failed to serialize config",
430                    "message": e.to_string(),
431                }))
432                .unwrap_or_default()
433            })
434        }
435        None => serde_json::to_vec_pretty(&serde_json::json!({
436            "error": "Configuration unavailable",
437            "status": 503,
438            "message": "Config manager not available",
439            "request_id": request_id,
440            "timestamp": chrono::Utc::now().to_rfc3339(),
441        }))
442        .unwrap_or_default(),
443    };
444
445    let status = if config.is_some() {
446        StatusCode::OK
447    } else {
448        StatusCode::SERVICE_UNAVAILABLE
449    };
450
451    Response::builder()
452        .status(status)
453        .header("Content-Type", "application/json; charset=utf-8")
454        .header("X-Request-Id", request_id)
455        .header("Cache-Control", "no-cache, no-store, must-revalidate")
456        .body(Full::new(Bytes::from(body)))
457        .expect("static response builder with valid headers cannot fail")
458}
459
460/// Upstream health status handler
461///
462/// Returns the health status of all configured upstreams and their targets.
463fn upstreams_handler(
464    snapshot: Option<UpstreamHealthSnapshot>,
465    request_id: &str,
466) -> Response<Full<Bytes>> {
467    let body = match snapshot {
468        Some(data) => {
469            // Count healthy/unhealthy/unknown targets
470            let mut total_healthy = 0;
471            let mut total_unhealthy = 0;
472            let mut total_unknown = 0;
473
474            for upstream in data.upstreams.values() {
475                for target in &upstream.targets {
476                    match target.status {
477                        TargetHealthStatus::Healthy => total_healthy += 1,
478                        TargetHealthStatus::Unhealthy => total_unhealthy += 1,
479                        TargetHealthStatus::Unknown => total_unknown += 1,
480                    }
481                }
482            }
483
484            let response = serde_json::json!({
485                "timestamp": chrono::Utc::now().to_rfc3339(),
486                "request_id": request_id,
487                "summary": {
488                    "total_upstreams": data.upstreams.len(),
489                    "total_targets": total_healthy + total_unhealthy + total_unknown,
490                    "healthy": total_healthy,
491                    "unhealthy": total_unhealthy,
492                    "unknown": total_unknown,
493                },
494                "upstreams": data.upstreams.values().collect::<Vec<_>>(),
495            });
496
497            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
498                serde_json::to_vec(&serde_json::json!({
499                    "error": "Failed to serialize upstreams",
500                    "message": e.to_string(),
501                }))
502                .unwrap_or_default()
503            })
504        }
505        None => {
506            // No upstreams configured or data unavailable
507            serde_json::to_vec_pretty(&serde_json::json!({
508                "timestamp": chrono::Utc::now().to_rfc3339(),
509                "request_id": request_id,
510                "summary": {
511                    "total_upstreams": 0,
512                    "total_targets": 0,
513                    "healthy": 0,
514                    "unhealthy": 0,
515                    "unknown": 0,
516                },
517                "upstreams": [],
518                "message": "No upstreams configured",
519            }))
520            .unwrap_or_default()
521        }
522    };
523
524    Response::builder()
525        .status(StatusCode::OK)
526        .header("Content-Type", "application/json; charset=utf-8")
527        .header("X-Request-Id", request_id)
528        .header("Cache-Control", "no-cache, no-store, must-revalidate")
529        .body(Full::new(Bytes::from(body)))
530        .expect("static response builder with valid headers cannot fail")
531}
532
533/// Cache purge handler
534///
535/// Handles PURGE requests to invalidate cache entries. Accepts a pattern
536/// and optionally purges all matching entries if wildcard is enabled.
537fn cache_purge_handler(
538    purge_request: Option<CachePurgeRequest>,
539    cache_manager: Option<&Arc<CacheManager>>,
540    request_id: &str,
541) -> Response<Full<Bytes>> {
542    let body = match (&purge_request, cache_manager) {
543        (Some(request), Some(manager)) => {
544            info!(
545                pattern = %request.pattern,
546                wildcard = request.wildcard,
547                request_id = %request_id,
548                "Processing cache purge request"
549            );
550
551            // Execute the actual purge via CacheManager
552            let purged_count = if request.wildcard {
553                // Wildcard purge - register pattern for matching
554                manager.purge_wildcard(&request.pattern)
555            } else {
556                // Single entry purge
557                manager.purge(&request.pattern)
558            };
559
560            info!(
561                pattern = %request.pattern,
562                wildcard = request.wildcard,
563                purged_count = purged_count,
564                request_id = %request_id,
565                "Cache purge completed"
566            );
567
568            serde_json::to_vec_pretty(&serde_json::json!({
569                "status": "ok",
570                "message": "Cache purge request processed",
571                "pattern": request.pattern,
572                "wildcard": request.wildcard,
573                "purged_entries": purged_count,
574                "active_purges": manager.active_purge_count(),
575                "request_id": request_id,
576                "timestamp": chrono::Utc::now().to_rfc3339(),
577            }))
578            .unwrap_or_default()
579        }
580        (Some(request), None) => {
581            // Cache manager not available - log warning and acknowledge request
582            tracing::warn!(
583                pattern = %request.pattern,
584                request_id = %request_id,
585                "Cache purge requested but cache manager not available"
586            );
587
588            serde_json::to_vec_pretty(&serde_json::json!({
589                "status": "warning",
590                "message": "Cache purge acknowledged but cache manager unavailable",
591                "pattern": request.pattern,
592                "wildcard": request.wildcard,
593                "purged_entries": 0,
594                "request_id": request_id,
595                "timestamp": chrono::Utc::now().to_rfc3339(),
596            }))
597            .unwrap_or_default()
598        }
599        (None, _) => {
600            // No purge request provided - return error
601            serde_json::to_vec_pretty(&serde_json::json!({
602                "error": "Bad Request",
603                "status": 400,
604                "message": "Cache purge requires a pattern. Use PURGE /path or X-Purge-Pattern header.",
605                "request_id": request_id,
606                "timestamp": chrono::Utc::now().to_rfc3339(),
607            })).unwrap_or_default()
608        }
609    };
610
611    let status = if purge_request.is_some() {
612        StatusCode::OK
613    } else {
614        StatusCode::BAD_REQUEST
615    };
616
617    Response::builder()
618        .status(status)
619        .header("Content-Type", "application/json; charset=utf-8")
620        .header("X-Request-Id", request_id)
621        .header("Cache-Control", "no-cache, no-store, must-revalidate")
622        .body(Full::new(Bytes::from(body)))
623        .expect("static response builder with valid headers cannot fail")
624}
625
626/// Cache statistics response
627#[derive(Debug, Serialize)]
628struct CacheStatsResponse {
629    /// Total cache hits
630    hits: u64,
631    /// Total cache misses
632    misses: u64,
633    /// Total cache stores
634    stores: u64,
635    /// Total cache evictions
636    evictions: u64,
637    /// Cache hit ratio (0.0 to 1.0)
638    hit_ratio: f64,
639    /// Memory-tier hits (hybrid cache)
640    memory_hits: u64,
641    /// Disk-tier hits (hybrid cache)
642    disk_hits: u64,
643    /// Request ID
644    request_id: String,
645    /// Timestamp
646    timestamp: String,
647}
648
649/// Cache statistics handler
650///
651/// Returns current cache statistics including hits, misses, and hit ratio.
652fn cache_stats_handler(
653    cache_stats: Option<Arc<HttpCacheStats>>,
654    request_id: &str,
655) -> Response<Full<Bytes>> {
656    let body = match cache_stats {
657        Some(stats) => {
658            let response = CacheStatsResponse {
659                hits: stats.hits(),
660                misses: stats.misses(),
661                stores: stats.stores(),
662                evictions: stats.evictions(),
663                hit_ratio: stats.hit_ratio(),
664                memory_hits: stats.memory_hits(),
665                disk_hits: stats.disk_hits(),
666                request_id: request_id.to_string(),
667                timestamp: chrono::Utc::now().to_rfc3339(),
668            };
669
670            serde_json::to_vec_pretty(&response)
671                .unwrap_or_else(|_| b"{\"error\":\"Failed to serialize stats\"}".to_vec())
672        }
673        None => serde_json::to_vec_pretty(&serde_json::json!({
674            "hits": 0,
675            "misses": 0,
676            "stores": 0,
677            "evictions": 0,
678            "hit_ratio": 0.0,
679            "memory_hits": 0,
680            "disk_hits": 0,
681            "message": "Cache statistics not available",
682            "request_id": request_id,
683            "timestamp": chrono::Utc::now().to_rfc3339(),
684        }))
685        .unwrap_or_default(),
686    };
687
688    Response::builder()
689        .status(StatusCode::OK)
690        .header("Content-Type", "application/json; charset=utf-8")
691        .header("X-Request-Id", request_id)
692        .header("Cache-Control", "no-cache, no-store, must-revalidate")
693        .body(Full::new(Bytes::from(body)))
694        .expect("static response builder with valid headers cannot fail")
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_status_handler() {
703        let state = BuiltinHandlerState::new("0.1.0".to_string(), "test-instance".to_string());
704
705        let response = status_handler(&state, "test-request-id");
706        assert_eq!(response.status(), StatusCode::OK);
707
708        let content_type = response.headers().get("Content-Type").unwrap();
709        assert_eq!(content_type, "application/json; charset=utf-8");
710    }
711
712    #[test]
713    fn test_health_handler() {
714        let response = health_handler("test-request-id");
715        assert_eq!(response.status(), StatusCode::OK);
716    }
717
718    #[test]
719    fn test_metrics_handler() {
720        let response = metrics_handler("test-request-id", None);
721        assert_eq!(response.status(), StatusCode::OK);
722
723        let content_type = response.headers().get("Content-Type").unwrap();
724        assert!(content_type.to_str().unwrap().contains("text/plain"));
725    }
726
727    #[test]
728    fn test_metrics_handler_with_cache_stats() {
729        let stats = Arc::new(HttpCacheStats::default());
730        stats.record_hit();
731        stats.record_miss();
732        stats.record_store();
733
734        let response = metrics_handler("test-request-id", Some(&stats));
735        assert_eq!(response.status(), StatusCode::OK);
736    }
737
738    #[test]
739    fn test_cache_purge_handler_with_request() {
740        let cache_manager = Arc::new(CacheManager::new());
741        let request = CachePurgeRequest {
742            pattern: "/api/users/*".to_string(),
743            wildcard: true,
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 actually registered
749        assert!(cache_manager.active_purge_count() > 0);
750    }
751
752    #[test]
753    fn test_cache_purge_handler_single_entry() {
754        let cache_manager = Arc::new(CacheManager::new());
755        let request = CachePurgeRequest {
756            pattern: "/api/users/123".to_string(),
757            wildcard: false,
758        };
759        let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
760        assert_eq!(response.status(), StatusCode::OK);
761
762        // Verify the purge was registered
763        assert!(cache_manager.should_invalidate("/api/users/123"));
764    }
765
766    #[test]
767    fn test_cache_purge_handler_without_request() {
768        let cache_manager = Arc::new(CacheManager::new());
769        let response = cache_purge_handler(None, Some(&cache_manager), "test-request-id");
770        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
771    }
772
773    #[test]
774    fn test_cache_purge_handler_without_manager() {
775        let request = CachePurgeRequest {
776            pattern: "/api/users/*".to_string(),
777            wildcard: true,
778        };
779        // Without cache manager, should still return OK but with warning
780        let response = cache_purge_handler(Some(request), None, "test-request-id");
781        assert_eq!(response.status(), StatusCode::OK);
782    }
783
784    #[test]
785    fn test_cache_stats_handler_with_stats() {
786        let stats = Arc::new(HttpCacheStats::default());
787        stats.record_hit();
788        stats.record_hit();
789        stats.record_miss();
790
791        let response = cache_stats_handler(Some(stats), "test-request-id");
792        assert_eq!(response.status(), StatusCode::OK);
793
794        let content_type = response.headers().get("Content-Type").unwrap();
795        assert_eq!(content_type, "application/json; charset=utf-8");
796    }
797
798    #[test]
799    fn test_cache_stats_handler_without_stats() {
800        let response = cache_stats_handler(None, "test-request-id");
801        assert_eq!(response.status(), StatusCode::OK);
802    }
803
804    #[test]
805    fn test_not_found_handler() {
806        let response = not_found_handler("test-request-id");
807        assert_eq!(response.status(), StatusCode::NOT_FOUND);
808    }
809
810    #[test]
811    fn test_config_handler_with_config() {
812        let config = Arc::new(Config::default_for_testing());
813        let response = config_handler(Some(config), "test-request-id");
814        assert_eq!(response.status(), StatusCode::OK);
815
816        let content_type = response.headers().get("Content-Type").unwrap();
817        assert_eq!(content_type, "application/json; charset=utf-8");
818    }
819
820    #[test]
821    fn test_config_handler_without_config() {
822        let response = config_handler(None, "test-request-id");
823        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
824    }
825
826    #[test]
827    fn test_upstreams_handler_with_data() {
828        let mut upstreams = HashMap::new();
829        upstreams.insert(
830            "backend".to_string(),
831            UpstreamStatus {
832                id: "backend".to_string(),
833                load_balancing: "round_robin".to_string(),
834                targets: vec![
835                    TargetStatus {
836                        address: "10.0.0.1:8080".to_string(),
837                        weight: 1,
838                        status: TargetHealthStatus::Healthy,
839                        failure_rate: Some(0.0),
840                        last_error: None,
841                    },
842                    TargetStatus {
843                        address: "10.0.0.2:8080".to_string(),
844                        weight: 1,
845                        status: TargetHealthStatus::Unhealthy,
846                        failure_rate: Some(0.8),
847                        last_error: Some("connection refused".to_string()),
848                    },
849                ],
850            },
851        );
852
853        let snapshot = UpstreamHealthSnapshot { upstreams };
854        let response = upstreams_handler(Some(snapshot), "test-request-id");
855        assert_eq!(response.status(), StatusCode::OK);
856
857        let content_type = response.headers().get("Content-Type").unwrap();
858        assert_eq!(content_type, "application/json; charset=utf-8");
859    }
860
861    #[test]
862    fn test_upstreams_handler_no_upstreams() {
863        let response = upstreams_handler(None, "test-request-id");
864        assert_eq!(response.status(), StatusCode::OK);
865    }
866
867    #[test]
868    fn test_uptime_formatting() {
869        let state = BuiltinHandlerState::new("0.1.0".to_string(), "test".to_string());
870
871        // Just verify it doesn't panic and returns a string
872        let uptime = state.uptime_string();
873        assert!(!uptime.is_empty());
874    }
875}