1use 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 sentinel_config::{BuiltinHandler, Config};
17
18use crate::cache::{CacheManager, HttpCacheStats};
19
20pub struct BuiltinHandlerState {
22 start_time: Instant,
24 version: String,
26 instance_id: String,
28}
29
30impl BuiltinHandlerState {
31 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 pub fn uptime(&self) -> Duration {
42 self.start_time.elapsed()
43 }
44
45 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#[derive(Debug, Serialize)]
68pub struct StatusResponse {
69 pub status: &'static str,
71 pub version: String,
73 pub uptime: String,
75 pub uptime_secs: u64,
77 pub instance_id: String,
79 pub timestamp: String,
81}
82
83#[derive(Debug, Serialize)]
85pub struct HealthResponse {
86 pub status: &'static str,
88 pub timestamp: String,
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct UpstreamHealthSnapshot {
95 pub upstreams: HashMap<String, UpstreamStatus>,
97}
98
99#[derive(Debug, Clone, Serialize)]
101pub struct UpstreamStatus {
102 pub id: String,
104 pub load_balancing: String,
106 pub targets: Vec<TargetStatus>,
108}
109
110#[derive(Debug, Clone, Serialize)]
112pub struct TargetStatus {
113 pub address: String,
115 pub weight: u32,
117 pub status: TargetHealthStatus,
119 pub failure_rate: Option<f64>,
121 pub last_error: Option<String>,
123}
124
125#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
127#[serde(rename_all = "lowercase")]
128pub enum TargetHealthStatus {
129 Healthy,
131 Unhealthy,
133 Unknown,
135}
136
137#[derive(Debug, Clone)]
139pub struct CachePurgeRequest {
140 pub pattern: String,
142 pub wildcard: bool,
144}
145
146pub 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
184fn 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
213fn 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
232fn metrics_handler(
234 request_id: &str,
235 cache_stats: Option<&Arc<HttpCacheStats>>,
236) -> Response<Full<Bytes>> {
237 use prometheus::{Encoder, TextEncoder};
238
239 let encoder = TextEncoder::new();
241
242 let metric_families = prometheus::gather();
244
245 let mut buffer = Vec::new();
247 match encoder.encode(&metric_families, &mut buffer) {
248 Ok(()) => {
249 let extra_metrics = format!(
251 "# HELP sentinel_up Sentinel proxy is up and running\n\
252 # TYPE sentinel_up gauge\n\
253 sentinel_up 1\n\
254 # HELP sentinel_build_info Build information\n\
255 # TYPE sentinel_build_info gauge\n\
256 sentinel_build_info{{version=\"{}\"}} 1\n",
257 env!("CARGO_PKG_VERSION")
258 );
259 buffer.extend_from_slice(extra_metrics.as_bytes());
260
261 if let Some(stats) = cache_stats {
263 let cache_metrics = format!(
264 "# HELP sentinel_cache_hits_total Total number of cache hits\n\
265 # TYPE sentinel_cache_hits_total counter\n\
266 sentinel_cache_hits_total {}\n\
267 # HELP sentinel_cache_misses_total Total number of cache misses\n\
268 # TYPE sentinel_cache_misses_total counter\n\
269 sentinel_cache_misses_total {}\n\
270 # HELP sentinel_cache_stores_total Total number of cache stores\n\
271 # TYPE sentinel_cache_stores_total counter\n\
272 sentinel_cache_stores_total {}\n\
273 # HELP sentinel_cache_hit_ratio Cache hit ratio (0.0 to 1.0)\n\
274 # TYPE sentinel_cache_hit_ratio gauge\n\
275 sentinel_cache_hit_ratio {:.4}\n",
276 stats.hits(),
277 stats.misses(),
278 stats.stores(),
279 stats.hit_ratio()
280 );
281 buffer.extend_from_slice(cache_metrics.as_bytes());
282 }
283
284 Response::builder()
285 .status(StatusCode::OK)
286 .header("Content-Type", encoder.format_type())
287 .header("X-Request-Id", request_id)
288 .body(Full::new(Bytes::from(buffer)))
289 .expect("static response builder with valid headers cannot fail")
290 }
291 Err(e) => {
292 tracing::error!(error = %e, "Failed to encode Prometheus metrics");
293 let error_body = format!("# ERROR: Failed to encode metrics: {}\n", e);
294 Response::builder()
295 .status(StatusCode::INTERNAL_SERVER_ERROR)
296 .header("Content-Type", "text/plain; charset=utf-8")
297 .header("X-Request-Id", request_id)
298 .body(Full::new(Bytes::from(error_body)))
299 .expect("static response builder with valid headers cannot fail")
300 }
301 }
302}
303
304fn not_found_handler(request_id: &str) -> Response<Full<Bytes>> {
306 let body = serde_json::json!({
307 "error": "Not Found",
308 "status": 404,
309 "message": "The requested resource could not be found.",
310 "request_id": request_id,
311 "timestamp": chrono::Utc::now().to_rfc3339(),
312 });
313
314 let body_bytes = serde_json::to_vec_pretty(&body)
315 .unwrap_or_else(|_| b"{\"error\":\"Not Found\",\"status\":404}".to_vec());
316
317 Response::builder()
318 .status(StatusCode::NOT_FOUND)
319 .header("Content-Type", "application/json; charset=utf-8")
320 .header("X-Request-Id", request_id)
321 .body(Full::new(Bytes::from(body_bytes)))
322 .expect("static response builder with valid headers cannot fail")
323}
324
325fn config_handler(config: Option<Arc<Config>>, request_id: &str) -> Response<Full<Bytes>> {
330 let body = match &config {
331 Some(cfg) => {
332 let response = serde_json::json!({
336 "timestamp": chrono::Utc::now().to_rfc3339(),
337 "request_id": request_id,
338 "config": {
339 "server": &cfg.server,
340 "listeners": cfg.listeners.iter().map(|l| {
341 serde_json::json!({
342 "id": l.id,
343 "address": l.address,
344 "protocol": l.protocol,
345 "default_route": l.default_route,
346 "request_timeout_secs": l.request_timeout_secs,
347 "keepalive_timeout_secs": l.keepalive_timeout_secs,
348 "tls_enabled": l.tls.is_some(),
350 })
351 }).collect::<Vec<_>>(),
352 "routes": cfg.routes.iter().map(|r| {
353 serde_json::json!({
354 "id": r.id,
355 "priority": r.priority,
356 "matches": r.matches,
357 "upstream": r.upstream,
358 "service_type": r.service_type,
359 "builtin_handler": r.builtin_handler,
360 "filters": r.filters,
361 "waf_enabled": r.waf_enabled,
362 })
363 }).collect::<Vec<_>>(),
364 "upstreams": cfg.upstreams.iter().map(|(id, u)| {
365 serde_json::json!({
366 "id": id,
367 "targets": u.targets.iter().map(|t| {
368 serde_json::json!({
369 "address": t.address,
370 "weight": t.weight,
371 })
372 }).collect::<Vec<_>>(),
373 "load_balancing": u.load_balancing,
374 "health_check": u.health_check.as_ref().map(|h| {
375 serde_json::json!({
376 "interval_secs": h.interval_secs,
377 "timeout_secs": h.timeout_secs,
378 "healthy_threshold": h.healthy_threshold,
379 "unhealthy_threshold": h.unhealthy_threshold,
380 })
381 }),
382 "tls_enabled": u.tls.is_some(),
384 })
385 }).collect::<Vec<_>>(),
386 "agents": cfg.agents.iter().map(|a| {
387 serde_json::json!({
388 "id": a.id,
389 "agent_type": a.agent_type,
390 "timeout_ms": a.timeout_ms,
391 })
392 }).collect::<Vec<_>>(),
393 "filters": cfg.filters.keys().collect::<Vec<_>>(),
394 "waf": cfg.waf.as_ref().map(|w| {
395 serde_json::json!({
396 "mode": w.mode,
397 "engine": w.engine,
398 "audit_log": w.audit_log,
399 })
400 }),
401 "limits": &cfg.limits,
402 }
403 });
404
405 serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
406 serde_json::to_vec(&serde_json::json!({
407 "error": "Failed to serialize config",
408 "message": e.to_string(),
409 }))
410 .unwrap_or_default()
411 })
412 }
413 None => serde_json::to_vec_pretty(&serde_json::json!({
414 "error": "Configuration unavailable",
415 "status": 503,
416 "message": "Config manager not available",
417 "request_id": request_id,
418 "timestamp": chrono::Utc::now().to_rfc3339(),
419 }))
420 .unwrap_or_default(),
421 };
422
423 let status = if config.is_some() {
424 StatusCode::OK
425 } else {
426 StatusCode::SERVICE_UNAVAILABLE
427 };
428
429 Response::builder()
430 .status(status)
431 .header("Content-Type", "application/json; charset=utf-8")
432 .header("X-Request-Id", request_id)
433 .header("Cache-Control", "no-cache, no-store, must-revalidate")
434 .body(Full::new(Bytes::from(body)))
435 .expect("static response builder with valid headers cannot fail")
436}
437
438fn upstreams_handler(
442 snapshot: Option<UpstreamHealthSnapshot>,
443 request_id: &str,
444) -> Response<Full<Bytes>> {
445 let body = match snapshot {
446 Some(data) => {
447 let mut total_healthy = 0;
449 let mut total_unhealthy = 0;
450 let mut total_unknown = 0;
451
452 for upstream in data.upstreams.values() {
453 for target in &upstream.targets {
454 match target.status {
455 TargetHealthStatus::Healthy => total_healthy += 1,
456 TargetHealthStatus::Unhealthy => total_unhealthy += 1,
457 TargetHealthStatus::Unknown => total_unknown += 1,
458 }
459 }
460 }
461
462 let response = serde_json::json!({
463 "timestamp": chrono::Utc::now().to_rfc3339(),
464 "request_id": request_id,
465 "summary": {
466 "total_upstreams": data.upstreams.len(),
467 "total_targets": total_healthy + total_unhealthy + total_unknown,
468 "healthy": total_healthy,
469 "unhealthy": total_unhealthy,
470 "unknown": total_unknown,
471 },
472 "upstreams": data.upstreams.values().collect::<Vec<_>>(),
473 });
474
475 serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
476 serde_json::to_vec(&serde_json::json!({
477 "error": "Failed to serialize upstreams",
478 "message": e.to_string(),
479 }))
480 .unwrap_or_default()
481 })
482 }
483 None => {
484 serde_json::to_vec_pretty(&serde_json::json!({
486 "timestamp": chrono::Utc::now().to_rfc3339(),
487 "request_id": request_id,
488 "summary": {
489 "total_upstreams": 0,
490 "total_targets": 0,
491 "healthy": 0,
492 "unhealthy": 0,
493 "unknown": 0,
494 },
495 "upstreams": [],
496 "message": "No upstreams configured",
497 }))
498 .unwrap_or_default()
499 }
500 };
501
502 Response::builder()
503 .status(StatusCode::OK)
504 .header("Content-Type", "application/json; charset=utf-8")
505 .header("X-Request-Id", request_id)
506 .header("Cache-Control", "no-cache, no-store, must-revalidate")
507 .body(Full::new(Bytes::from(body)))
508 .expect("static response builder with valid headers cannot fail")
509}
510
511fn cache_purge_handler(
516 purge_request: Option<CachePurgeRequest>,
517 cache_manager: Option<&Arc<CacheManager>>,
518 request_id: &str,
519) -> Response<Full<Bytes>> {
520 let body = match (&purge_request, cache_manager) {
521 (Some(request), Some(manager)) => {
522 info!(
523 pattern = %request.pattern,
524 wildcard = request.wildcard,
525 request_id = %request_id,
526 "Processing cache purge request"
527 );
528
529 let purged_count = if request.wildcard {
531 manager.purge_wildcard(&request.pattern)
533 } else {
534 manager.purge(&request.pattern)
536 };
537
538 info!(
539 pattern = %request.pattern,
540 wildcard = request.wildcard,
541 purged_count = purged_count,
542 request_id = %request_id,
543 "Cache purge completed"
544 );
545
546 serde_json::to_vec_pretty(&serde_json::json!({
547 "status": "ok",
548 "message": "Cache purge request processed",
549 "pattern": request.pattern,
550 "wildcard": request.wildcard,
551 "purged_entries": purged_count,
552 "active_purges": manager.active_purge_count(),
553 "request_id": request_id,
554 "timestamp": chrono::Utc::now().to_rfc3339(),
555 }))
556 .unwrap_or_default()
557 }
558 (Some(request), None) => {
559 tracing::warn!(
561 pattern = %request.pattern,
562 request_id = %request_id,
563 "Cache purge requested but cache manager not available"
564 );
565
566 serde_json::to_vec_pretty(&serde_json::json!({
567 "status": "warning",
568 "message": "Cache purge acknowledged but cache manager unavailable",
569 "pattern": request.pattern,
570 "wildcard": request.wildcard,
571 "purged_entries": 0,
572 "request_id": request_id,
573 "timestamp": chrono::Utc::now().to_rfc3339(),
574 }))
575 .unwrap_or_default()
576 }
577 (None, _) => {
578 serde_json::to_vec_pretty(&serde_json::json!({
580 "error": "Bad Request",
581 "status": 400,
582 "message": "Cache purge requires a pattern. Use PURGE /path or X-Purge-Pattern header.",
583 "request_id": request_id,
584 "timestamp": chrono::Utc::now().to_rfc3339(),
585 })).unwrap_or_default()
586 }
587 };
588
589 let status = if purge_request.is_some() {
590 StatusCode::OK
591 } else {
592 StatusCode::BAD_REQUEST
593 };
594
595 Response::builder()
596 .status(status)
597 .header("Content-Type", "application/json; charset=utf-8")
598 .header("X-Request-Id", request_id)
599 .header("Cache-Control", "no-cache, no-store, must-revalidate")
600 .body(Full::new(Bytes::from(body)))
601 .expect("static response builder with valid headers cannot fail")
602}
603
604#[derive(Debug, Serialize)]
606struct CacheStatsResponse {
607 hits: u64,
609 misses: u64,
611 stores: u64,
613 evictions: u64,
615 hit_ratio: f64,
617 request_id: String,
619 timestamp: String,
621}
622
623fn cache_stats_handler(
627 cache_stats: Option<Arc<HttpCacheStats>>,
628 request_id: &str,
629) -> Response<Full<Bytes>> {
630 let body = match cache_stats {
631 Some(stats) => {
632 let response = CacheStatsResponse {
633 hits: stats.hits(),
634 misses: stats.misses(),
635 stores: stats.stores(),
636 evictions: stats.evictions(),
637 hit_ratio: stats.hit_ratio(),
638 request_id: request_id.to_string(),
639 timestamp: chrono::Utc::now().to_rfc3339(),
640 };
641
642 serde_json::to_vec_pretty(&response)
643 .unwrap_or_else(|_| b"{\"error\":\"Failed to serialize stats\"}".to_vec())
644 }
645 None => serde_json::to_vec_pretty(&serde_json::json!({
646 "hits": 0,
647 "misses": 0,
648 "stores": 0,
649 "evictions": 0,
650 "hit_ratio": 0.0,
651 "message": "Cache statistics not available",
652 "request_id": request_id,
653 "timestamp": chrono::Utc::now().to_rfc3339(),
654 }))
655 .unwrap_or_default(),
656 };
657
658 Response::builder()
659 .status(StatusCode::OK)
660 .header("Content-Type", "application/json; charset=utf-8")
661 .header("X-Request-Id", request_id)
662 .header("Cache-Control", "no-cache, no-store, must-revalidate")
663 .body(Full::new(Bytes::from(body)))
664 .expect("static response builder with valid headers cannot fail")
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn test_status_handler() {
673 let state = BuiltinHandlerState::new("0.1.0".to_string(), "test-instance".to_string());
674
675 let response = status_handler(&state, "test-request-id");
676 assert_eq!(response.status(), StatusCode::OK);
677
678 let content_type = response.headers().get("Content-Type").unwrap();
679 assert_eq!(content_type, "application/json; charset=utf-8");
680 }
681
682 #[test]
683 fn test_health_handler() {
684 let response = health_handler("test-request-id");
685 assert_eq!(response.status(), StatusCode::OK);
686 }
687
688 #[test]
689 fn test_metrics_handler() {
690 let response = metrics_handler("test-request-id", None);
691 assert_eq!(response.status(), StatusCode::OK);
692
693 let content_type = response.headers().get("Content-Type").unwrap();
694 assert!(content_type.to_str().unwrap().contains("text/plain"));
695 }
696
697 #[test]
698 fn test_metrics_handler_with_cache_stats() {
699 let stats = Arc::new(HttpCacheStats::default());
700 stats.record_hit();
701 stats.record_miss();
702 stats.record_store();
703
704 let response = metrics_handler("test-request-id", Some(&stats));
705 assert_eq!(response.status(), StatusCode::OK);
706 }
707
708 #[test]
709 fn test_cache_purge_handler_with_request() {
710 let cache_manager = Arc::new(CacheManager::new());
711 let request = CachePurgeRequest {
712 pattern: "/api/users/*".to_string(),
713 wildcard: true,
714 };
715 let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
716 assert_eq!(response.status(), StatusCode::OK);
717
718 assert!(cache_manager.active_purge_count() > 0);
720 }
721
722 #[test]
723 fn test_cache_purge_handler_single_entry() {
724 let cache_manager = Arc::new(CacheManager::new());
725 let request = CachePurgeRequest {
726 pattern: "/api/users/123".to_string(),
727 wildcard: false,
728 };
729 let response = cache_purge_handler(Some(request), Some(&cache_manager), "test-request-id");
730 assert_eq!(response.status(), StatusCode::OK);
731
732 assert!(cache_manager.should_invalidate("/api/users/123"));
734 }
735
736 #[test]
737 fn test_cache_purge_handler_without_request() {
738 let cache_manager = Arc::new(CacheManager::new());
739 let response = cache_purge_handler(None, Some(&cache_manager), "test-request-id");
740 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
741 }
742
743 #[test]
744 fn test_cache_purge_handler_without_manager() {
745 let request = CachePurgeRequest {
746 pattern: "/api/users/*".to_string(),
747 wildcard: true,
748 };
749 let response = cache_purge_handler(Some(request), None, "test-request-id");
751 assert_eq!(response.status(), StatusCode::OK);
752 }
753
754 #[test]
755 fn test_cache_stats_handler_with_stats() {
756 let stats = Arc::new(HttpCacheStats::default());
757 stats.record_hit();
758 stats.record_hit();
759 stats.record_miss();
760
761 let response = cache_stats_handler(Some(stats), "test-request-id");
762 assert_eq!(response.status(), StatusCode::OK);
763
764 let content_type = response.headers().get("Content-Type").unwrap();
765 assert_eq!(content_type, "application/json; charset=utf-8");
766 }
767
768 #[test]
769 fn test_cache_stats_handler_without_stats() {
770 let response = cache_stats_handler(None, "test-request-id");
771 assert_eq!(response.status(), StatusCode::OK);
772 }
773
774 #[test]
775 fn test_not_found_handler() {
776 let response = not_found_handler("test-request-id");
777 assert_eq!(response.status(), StatusCode::NOT_FOUND);
778 }
779
780 #[test]
781 fn test_config_handler_with_config() {
782 let config = Arc::new(Config::default_for_testing());
783 let response = config_handler(Some(config), "test-request-id");
784 assert_eq!(response.status(), StatusCode::OK);
785
786 let content_type = response.headers().get("Content-Type").unwrap();
787 assert_eq!(content_type, "application/json; charset=utf-8");
788 }
789
790 #[test]
791 fn test_config_handler_without_config() {
792 let response = config_handler(None, "test-request-id");
793 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
794 }
795
796 #[test]
797 fn test_upstreams_handler_with_data() {
798 let mut upstreams = HashMap::new();
799 upstreams.insert(
800 "backend".to_string(),
801 UpstreamStatus {
802 id: "backend".to_string(),
803 load_balancing: "round_robin".to_string(),
804 targets: vec![
805 TargetStatus {
806 address: "10.0.0.1:8080".to_string(),
807 weight: 1,
808 status: TargetHealthStatus::Healthy,
809 failure_rate: Some(0.0),
810 last_error: None,
811 },
812 TargetStatus {
813 address: "10.0.0.2:8080".to_string(),
814 weight: 1,
815 status: TargetHealthStatus::Unhealthy,
816 failure_rate: Some(0.8),
817 last_error: Some("connection refused".to_string()),
818 },
819 ],
820 },
821 );
822
823 let snapshot = UpstreamHealthSnapshot { upstreams };
824 let response = upstreams_handler(Some(snapshot), "test-request-id");
825 assert_eq!(response.status(), StatusCode::OK);
826
827 let content_type = response.headers().get("Content-Type").unwrap();
828 assert_eq!(content_type, "application/json; charset=utf-8");
829 }
830
831 #[test]
832 fn test_upstreams_handler_no_upstreams() {
833 let response = upstreams_handler(None, "test-request-id");
834 assert_eq!(response.status(), StatusCode::OK);
835 }
836
837 #[test]
838 fn test_uptime_formatting() {
839 let state = BuiltinHandlerState::new("0.1.0".to_string(), "test".to_string());
840
841 let uptime = state.uptime_string();
843 assert!(!uptime.is_empty());
844 }
845}