1#[cfg(all(not(feature = "std"), feature = "alloc"))]
33use alloc::string::String;
34use core::fmt;
35#[cfg(feature = "std")]
36use std::collections::HashMap;
37
38#[cfg(feature = "serde")]
39use serde::{Deserialize, Serialize};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
63#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
64#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
65#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
66#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
67#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
68#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
69pub enum HealthStatus {
70 Pass,
72 Fail,
74 Warn,
76}
77
78impl HealthStatus {
79 #[must_use]
94 pub const fn http_status(&self) -> u16 {
95 match self {
96 Self::Pass | Self::Warn => 200,
97 Self::Fail => 503,
98 }
99 }
100
101 #[must_use]
113 pub const fn is_available(&self) -> bool {
114 matches!(self, Self::Pass | Self::Warn)
115 }
116}
117
118impl fmt::Display for HealthStatus {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 Self::Pass => f.write_str("pass"),
122 Self::Fail => f.write_str("fail"),
123 Self::Warn => f.write_str("warn"),
124 }
125 }
126}
127
128#[cfg(any(feature = "std", feature = "alloc"))]
139#[derive(Debug, Clone)]
140#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
141#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
143#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
144#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
145pub struct HealthCheck {
146 #[cfg_attr(feature = "serde", serde(rename = "componentType"))]
148 pub component_type: String,
149 pub status: HealthStatus,
151 #[cfg_attr(
153 feature = "serde",
154 serde(default, skip_serializing_if = "Option::is_none")
155 )]
156 pub output: Option<String>,
157 #[cfg_attr(
159 feature = "serde",
160 serde(default, skip_serializing_if = "Option::is_none")
161 )]
162 pub time: Option<String>,
163}
164
165#[cfg(any(feature = "std", feature = "alloc"))]
166impl HealthCheck {
167 pub fn pass(component_type: impl Into<String>) -> Self {
180 Self {
181 component_type: component_type.into(),
182 status: HealthStatus::Pass,
183 output: None,
184 time: None,
185 }
186 }
187
188 pub fn fail(component_type: impl Into<String>, output: impl Into<String>) -> Self {
200 Self {
201 component_type: component_type.into(),
202 status: HealthStatus::Fail,
203 output: Some(output.into()),
204 time: None,
205 }
206 }
207
208 pub fn warn(component_type: impl Into<String>, output: impl Into<String>) -> Self {
220 Self {
221 component_type: component_type.into(),
222 status: HealthStatus::Warn,
223 output: Some(output.into()),
224 time: None,
225 }
226 }
227
228 #[must_use]
240 pub fn with_time(mut self, time: impl Into<String>) -> Self {
241 self.time = Some(time.into());
242 self
243 }
244
245 #[must_use]
261 pub fn builder() -> HealthCheckBuilder<(), ()> {
262 HealthCheckBuilder {
263 component_type: (),
264 status: (),
265 output: None,
266 time: None,
267 }
268 }
269}
270
271#[cfg(any(feature = "std", feature = "alloc"))]
285pub struct HealthCheckBuilder<CT, ST> {
286 component_type: CT,
287 status: ST,
288 output: Option<String>,
289 time: Option<String>,
290}
291
292#[cfg(any(feature = "std", feature = "alloc"))]
293impl<ST> HealthCheckBuilder<(), ST> {
294 pub fn component_type(
296 self,
297 component_type: impl Into<String>,
298 ) -> HealthCheckBuilder<String, ST> {
299 HealthCheckBuilder {
300 component_type: component_type.into(),
301 status: self.status,
302 output: self.output,
303 time: self.time,
304 }
305 }
306}
307
308#[cfg(any(feature = "std", feature = "alloc"))]
309impl<CT> HealthCheckBuilder<CT, ()> {
310 pub fn status(self, status: HealthStatus) -> HealthCheckBuilder<CT, HealthStatus> {
312 HealthCheckBuilder {
313 component_type: self.component_type,
314 status,
315 output: self.output,
316 time: self.time,
317 }
318 }
319}
320
321#[cfg(any(feature = "std", feature = "alloc"))]
322impl<CT, ST> HealthCheckBuilder<CT, ST> {
323 #[must_use]
325 pub fn output(mut self, output: impl Into<String>) -> Self {
326 self.output = Some(output.into());
327 self
328 }
329
330 #[must_use]
332 pub fn time(mut self, time: impl Into<String>) -> Self {
333 self.time = Some(time.into());
334 self
335 }
336}
337
338#[cfg(any(feature = "std", feature = "alloc"))]
339impl HealthCheckBuilder<String, HealthStatus> {
340 #[must_use]
344 pub fn build(self) -> HealthCheck {
345 HealthCheck {
346 component_type: self.component_type,
347 status: self.status,
348 output: self.output,
349 time: self.time,
350 }
351 }
352}
353
354#[cfg(any(feature = "std", feature = "alloc"))]
365#[derive(Debug, Clone)]
366#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
367#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
368#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
369#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
370#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
371pub struct LivenessResponse {
372 pub status: HealthStatus,
374 pub version: String,
376 #[cfg_attr(feature = "serde", serde(rename = "serviceId"))]
378 pub service_id: String,
379}
380
381#[cfg(any(feature = "std", feature = "alloc"))]
382impl LivenessResponse {
383 pub fn pass(version: impl Into<String>, service_id: impl Into<String>) -> Self {
396 Self {
397 status: HealthStatus::Pass,
398 version: version.into(),
399 service_id: service_id.into(),
400 }
401 }
402}
403
404#[cfg(feature = "std")]
417#[derive(Debug, Clone)]
418#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
419#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
421#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
422#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
423pub struct ReadinessResponse {
424 pub status: HealthStatus,
426 pub version: String,
428 #[cfg_attr(feature = "serde", serde(rename = "serviceId"))]
430 pub service_id: String,
431 pub checks: HashMap<String, Vec<HealthCheck>>,
434}
435
436#[cfg(feature = "std")]
437impl ReadinessResponse {
438 pub fn new(
462 version: impl Into<String>,
463 service_id: impl Into<String>,
464 checks: HashMap<String, Vec<HealthCheck>>,
465 ) -> Self {
466 let status = Self::aggregate_status(&checks);
467 Self {
468 status,
469 version: version.into(),
470 service_id: service_id.into(),
471 checks,
472 }
473 }
474
475 #[must_use]
477 pub fn http_status(&self) -> u16 {
478 self.status.http_status()
479 }
480
481 #[must_use]
499 pub fn builder() -> ReadinessResponseBuilder<(), ()> {
500 ReadinessResponseBuilder {
501 version: (),
502 service_id: (),
503 checks: HashMap::new(),
504 }
505 }
506
507 fn aggregate_status(checks: &HashMap<String, Vec<HealthCheck>>) -> HealthStatus {
509 let mut has_warn = false;
510 for check_list in checks.values() {
511 for check in check_list {
512 if check.status == HealthStatus::Fail {
513 return HealthStatus::Fail;
514 }
515 if check.status == HealthStatus::Warn {
516 has_warn = true;
517 }
518 }
519 }
520 if has_warn {
521 HealthStatus::Warn
522 } else {
523 HealthStatus::Pass
524 }
525 }
526}
527
528#[cfg(feature = "std")]
542pub struct ReadinessResponseBuilder<V, S> {
543 version: V,
544 service_id: S,
545 checks: HashMap<String, Vec<HealthCheck>>,
546}
547
548#[cfg(feature = "std")]
549impl<S> ReadinessResponseBuilder<(), S> {
550 pub fn version(self, version: impl Into<String>) -> ReadinessResponseBuilder<String, S> {
552 ReadinessResponseBuilder {
553 version: version.into(),
554 service_id: self.service_id,
555 checks: self.checks,
556 }
557 }
558}
559
560#[cfg(feature = "std")]
561impl<V> ReadinessResponseBuilder<V, ()> {
562 pub fn service_id(self, service_id: impl Into<String>) -> ReadinessResponseBuilder<V, String> {
564 ReadinessResponseBuilder {
565 version: self.version,
566 service_id: service_id.into(),
567 checks: self.checks,
568 }
569 }
570}
571
572#[cfg(feature = "std")]
573impl<V, S> ReadinessResponseBuilder<V, S> {
574 #[must_use]
579 pub fn add_check(mut self, key: impl Into<String>, check: HealthCheck) -> Self {
580 self.checks.entry(key.into()).or_default().push(check);
581 self
582 }
583}
584
585#[cfg(feature = "std")]
586impl ReadinessResponseBuilder<String, String> {
587 #[must_use]
591 pub fn build(self) -> ReadinessResponse {
592 ReadinessResponse::new(self.version, self.service_id, self.checks)
593 }
594}
595
596#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn health_status_http_codes() {
606 assert_eq!(HealthStatus::Pass.http_status(), 200);
607 assert_eq!(HealthStatus::Warn.http_status(), 200);
608 assert_eq!(HealthStatus::Fail.http_status(), 503);
609 }
610
611 #[test]
612 fn health_status_is_available() {
613 assert!(HealthStatus::Pass.is_available());
614 assert!(HealthStatus::Warn.is_available());
615 assert!(!HealthStatus::Fail.is_available());
616 }
617
618 #[test]
619 fn health_status_display() {
620 assert_eq!(HealthStatus::Pass.to_string(), "pass");
621 assert_eq!(HealthStatus::Fail.to_string(), "fail");
622 assert_eq!(HealthStatus::Warn.to_string(), "warn");
623 }
624
625 #[test]
626 fn readiness_aggregate_pass() {
627 let mut checks = HashMap::new();
628 checks.insert(
629 "postgres:connection".into(),
630 vec![HealthCheck::pass("datastore")],
631 );
632 let r = ReadinessResponse::new("1.0.0", "svc", checks);
633 assert_eq!(r.status, HealthStatus::Pass);
634 assert_eq!(r.http_status(), 200);
635 }
636
637 #[test]
638 fn readiness_aggregate_fail_wins() {
639 let mut checks = HashMap::new();
640 checks.insert(
641 "postgres:connection".into(),
642 vec![HealthCheck::pass("datastore")],
643 );
644 checks.insert(
645 "redis:ping".into(),
646 vec![HealthCheck::fail("datastore", "timeout")],
647 );
648 let r = ReadinessResponse::new("1.0.0", "svc", checks);
649 assert_eq!(r.status, HealthStatus::Fail);
650 assert_eq!(r.http_status(), 503);
651 }
652
653 #[test]
654 fn readiness_aggregate_warn() {
655 let mut checks = HashMap::new();
656 checks.insert(
657 "postgres:connection".into(),
658 vec![HealthCheck::pass("datastore")],
659 );
660 checks.insert(
661 "redis:latency".into(),
662 vec![HealthCheck::warn("datastore", "slow")],
663 );
664 let r = ReadinessResponse::new("1.0.0", "svc", checks);
665 assert_eq!(r.status, HealthStatus::Warn);
666 assert_eq!(r.http_status(), 200);
667 }
668
669 #[cfg(feature = "serde")]
670 #[test]
671 fn health_status_serializes_lowercase() {
672 assert_eq!(serde_json::to_value(HealthStatus::Pass).unwrap(), "pass");
673 assert_eq!(serde_json::to_value(HealthStatus::Fail).unwrap(), "fail");
674 assert_eq!(serde_json::to_value(HealthStatus::Warn).unwrap(), "warn");
675 }
676
677 #[cfg(feature = "serde")]
678 #[test]
679 fn liveness_wire_format() {
680 let r = LivenessResponse::pass("1.0.0", "my-service");
681 let json = serde_json::to_value(&r).unwrap();
682 assert_eq!(json["status"], "pass");
683 assert_eq!(json["version"], "1.0.0");
684 assert_eq!(json["serviceId"], "my-service");
685 }
686
687 #[cfg(feature = "serde")]
688 #[test]
689 fn readiness_wire_format() {
690 let mut checks = HashMap::new();
691 checks.insert(
692 "postgres:connection".into(),
693 vec![HealthCheck::pass("datastore")],
694 );
695 let r = ReadinessResponse::new("1.0.0", "my-service", checks);
696 let json = serde_json::to_value(&r).unwrap();
697 assert_eq!(json["status"], "pass");
698 assert_eq!(json["serviceId"], "my-service");
699 assert!(json["checks"]["postgres:connection"].is_array());
700 assert_eq!(
701 json["checks"]["postgres:connection"][0]["componentType"],
702 "datastore"
703 );
704 assert_eq!(json["checks"]["postgres:connection"][0]["status"], "pass");
705 }
706
707 #[cfg(feature = "serde")]
708 #[test]
709 fn health_check_omits_optional_fields() {
710 let c = HealthCheck::pass("datastore");
711 let json = serde_json::to_value(&c).unwrap();
712 assert!(json.get("output").is_none());
713 assert!(json.get("time").is_none());
714 }
715
716 #[cfg(feature = "serde")]
717 #[test]
718 fn health_check_with_time() {
719 let c = HealthCheck::pass("datastore").with_time("2026-03-09T21:00:00Z");
720 let json = serde_json::to_value(&c).unwrap();
721 assert_eq!(json["time"], "2026-03-09T21:00:00Z");
722 }
723
724 #[cfg(feature = "serde")]
725 #[test]
726 fn serde_roundtrip_liveness() {
727 let r = LivenessResponse::pass("1.0.0", "my-service");
728 let json = serde_json::to_value(&r).unwrap();
729 let back: LivenessResponse = serde_json::from_value(json).unwrap();
730 assert_eq!(back.status, HealthStatus::Pass);
731 assert_eq!(back.version, "1.0.0");
732 assert_eq!(back.service_id, "my-service");
733 }
734
735 #[test]
740 fn health_check_builder_basic() {
741 let check = HealthCheck::builder()
742 .component_type("datastore")
743 .status(HealthStatus::Pass)
744 .build();
745 assert_eq!(check.component_type, "datastore");
746 assert_eq!(check.status, HealthStatus::Pass);
747 assert!(check.output.is_none());
748 assert!(check.time.is_none());
749 }
750
751 #[test]
752 fn health_check_builder_equivalence_with_pass() {
753 let via_factory = HealthCheck::pass("datastore");
754 let via_builder = HealthCheck::builder()
755 .component_type("datastore")
756 .status(HealthStatus::Pass)
757 .build();
758 assert_eq!(via_factory.component_type, via_builder.component_type);
759 assert_eq!(via_factory.status, via_builder.status);
760 assert_eq!(via_factory.output, via_builder.output);
761 assert_eq!(via_factory.time, via_builder.time);
762 }
763
764 #[test]
765 fn health_check_builder_chaining_optionals() {
766 let check = HealthCheck::builder()
767 .component_type("system")
768 .status(HealthStatus::Warn)
769 .output("high latency")
770 .time("2026-04-06T00:00:00Z")
771 .build();
772 assert_eq!(check.status, HealthStatus::Warn);
773 assert_eq!(check.output.as_deref(), Some("high latency"));
774 assert_eq!(check.time.as_deref(), Some("2026-04-06T00:00:00Z"));
775 }
776
777 #[test]
778 fn health_check_builder_status_before_component_type() {
779 let check = HealthCheck::builder()
781 .status(HealthStatus::Fail)
782 .component_type("component")
783 .build();
784 assert_eq!(check.status, HealthStatus::Fail);
785 assert_eq!(check.component_type, "component");
786 }
787
788 #[test]
793 fn readiness_builder_empty_checks_is_pass() {
794 let resp = ReadinessResponse::builder()
795 .version("1.0.0")
796 .service_id("my-service")
797 .build();
798 assert_eq!(resp.version, "1.0.0");
799 assert_eq!(resp.service_id, "my-service");
800 assert_eq!(resp.status, HealthStatus::Pass);
801 assert!(resp.checks.is_empty());
802 }
803
804 #[test]
805 fn readiness_builder_add_check() {
806 let resp = ReadinessResponse::builder()
807 .version("1.0.0")
808 .service_id("svc")
809 .add_check("postgres:connection", HealthCheck::pass("datastore"))
810 .build();
811 assert!(resp.checks.contains_key("postgres:connection"));
812 assert_eq!(resp.status, HealthStatus::Pass);
813 }
814
815 #[test]
816 fn readiness_builder_add_multiple_checks_same_key() {
817 let resp = ReadinessResponse::builder()
818 .version("1.0.0")
819 .service_id("svc")
820 .add_check("db:ping", HealthCheck::pass("datastore"))
821 .add_check("db:ping", HealthCheck::warn("datastore", "slow"))
822 .build();
823 assert_eq!(resp.checks["db:ping"].len(), 2);
824 assert_eq!(resp.status, HealthStatus::Warn);
825 }
826
827 #[test]
828 fn readiness_builder_aggregate_fail() {
829 let resp = ReadinessResponse::builder()
830 .version("1.0.0")
831 .service_id("svc")
832 .add_check("redis:ping", HealthCheck::fail("datastore", "timeout"))
833 .build();
834 assert_eq!(resp.status, HealthStatus::Fail);
835 assert_eq!(resp.http_status(), 503);
836 }
837
838 #[test]
839 fn readiness_builder_equivalence_with_new() {
840 let mut checks = HashMap::new();
841 checks.insert(
842 "postgres:connection".into(),
843 vec![HealthCheck::pass("datastore")],
844 );
845 let via_new = ReadinessResponse::new("1.0.0", "svc", checks);
846 let via_builder = ReadinessResponse::builder()
847 .version("1.0.0")
848 .service_id("svc")
849 .add_check("postgres:connection", HealthCheck::pass("datastore"))
850 .build();
851 assert_eq!(via_new.status, via_builder.status);
852 assert_eq!(via_new.version, via_builder.version);
853 assert_eq!(via_new.service_id, via_builder.service_id);
854 assert_eq!(via_new.checks.len(), via_builder.checks.len());
855 }
856
857 #[cfg(feature = "schemars")]
858 #[test]
859 fn health_status_schema_is_valid() {
860 let schema = schemars::schema_for!(HealthStatus);
861 let json = serde_json::to_value(&schema).expect("schema serializable");
862 assert!(json.is_object());
863 }
864
865 #[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
866 #[test]
867 fn liveness_response_schema_is_valid() {
868 let schema = schemars::schema_for!(LivenessResponse);
869 let json = serde_json::to_value(&schema).expect("schema serializable");
870 assert!(json.is_object());
871 }
872}