Skip to main content

api_bones/
health.rs

1//! RFC 8458 health check response types.
2//!
3//! Implements the IETF Health Check Response Format for HTTP APIs
4//! ([draft-inadarei-api-health-check](https://datatracker.ietf.org/doc/html/draft-inadarei-api-health-check)).
5//!
6//! ## Wire format
7//!
8//! Content-Type: `application/health+json`
9//!
10//! ### Liveness (`GET /health`)
11//! ```json
12//! {"status": "pass", "version": "1.0.0", "serviceId": "my-service"}
13//! ```
14//!
15//! ### Readiness (`GET /health/ready`)
16//! ```json
17//! {
18//!   "status": "pass",
19//!   "version": "1.0.0",
20//!   "serviceId": "my-service",
21//!   "checks": {
22//!     "postgres:connection": [{"componentType": "datastore", "status": "pass"}]
23//!   }
24//! }
25//! ```
26//!
27//! ## HTTP status codes
28//!
29//! - `LivenessResponse` → always `200 OK`
30//! - `ReadinessResponse` → `200 OK` on `pass`/`warn`, `503 Service Unavailable` on `fail`
31
32#[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// ---------------------------------------------------------------------------
42// HealthStatus
43// ---------------------------------------------------------------------------
44
45/// RFC 8458 §3 health check status.
46///
47/// Serializes as lowercase `"pass"`, `"fail"`, or `"warn"`.
48///
49/// # Examples
50///
51/// ```
52/// use api_bones::health::HealthStatus;
53///
54/// let pass = HealthStatus::Pass;
55/// let fail = HealthStatus::Fail;
56/// let warn = HealthStatus::Warn;
57///
58/// assert_eq!(pass.http_status(), 200);
59/// assert_eq!(fail.http_status(), 503);
60/// assert_eq!(warn.http_status(), 200);
61/// ```
62#[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    /// The service is healthy and all checks pass.
71    Pass,
72    /// The service is unhealthy; callers should not route traffic here.
73    Fail,
74    /// The service is degraded but still operational.
75    Warn,
76}
77
78impl HealthStatus {
79    /// HTTP status code for a response carrying this health status.
80    ///
81    /// - `Pass` / `Warn` → `200 OK`
82    /// - `Fail` → `503 Service Unavailable`
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use api_bones::health::HealthStatus;
88    ///
89    /// assert_eq!(HealthStatus::Pass.http_status(), 200);
90    /// assert_eq!(HealthStatus::Warn.http_status(), 200);
91    /// assert_eq!(HealthStatus::Fail.http_status(), 503);
92    /// ```
93    #[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    /// Returns `true` if the status indicates healthy or degraded-but-operational.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use api_bones::health::HealthStatus;
107    ///
108    /// assert!(HealthStatus::Pass.is_available());
109    /// assert!(HealthStatus::Warn.is_available());
110    /// assert!(!HealthStatus::Fail.is_available());
111    /// ```
112    #[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// ---------------------------------------------------------------------------
129// HealthCheck
130// ---------------------------------------------------------------------------
131
132/// Individual component check result (RFC 8458 §4).
133///
134/// Used as values in [`ReadinessResponse::checks`].
135/// Map key format: `"<component>:<measurement>"`, e.g. `"postgres:connection"`.
136///
137/// Requires `std` or `alloc` (fields contain `String`).
138#[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    /// Component type, e.g. `"datastore"`, `"component"`, `"system"`.
147    #[cfg_attr(feature = "serde", serde(rename = "componentType"))]
148    pub component_type: String,
149    /// Check result status.
150    pub status: HealthStatus,
151    /// Human-readable output or error message. Omitted when absent.
152    #[cfg_attr(
153        feature = "serde",
154        serde(default, skip_serializing_if = "Option::is_none")
155    )]
156    pub output: Option<String>,
157    /// RFC 3339 timestamp of when this check was last performed. Omitted when absent.
158    #[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    /// Create a passing component check.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use api_bones::health::{HealthCheck, HealthStatus};
173    ///
174    /// let check = HealthCheck::pass("datastore");
175    /// assert_eq!(check.status, HealthStatus::Pass);
176    /// assert_eq!(check.component_type, "datastore");
177    /// assert!(check.output.is_none());
178    /// ```
179    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    /// Create a failing component check with an error message.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use api_bones::health::{HealthCheck, HealthStatus};
194    ///
195    /// let check = HealthCheck::fail("datastore", "connection timeout");
196    /// assert_eq!(check.status, HealthStatus::Fail);
197    /// assert_eq!(check.output.as_deref(), Some("connection timeout"));
198    /// ```
199    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    /// Create a warn-level component check with a message.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use api_bones::health::{HealthCheck, HealthStatus};
214    ///
215    /// let check = HealthCheck::warn("datastore", "high latency");
216    /// assert_eq!(check.status, HealthStatus::Warn);
217    /// assert_eq!(check.output.as_deref(), Some("high latency"));
218    /// ```
219    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    /// Attach an RFC 3339 timestamp to this check result.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use api_bones::health::HealthCheck;
234    ///
235    /// let check = HealthCheck::pass("datastore")
236    ///     .with_time("2026-01-01T00:00:00Z");
237    /// assert_eq!(check.time.as_deref(), Some("2026-01-01T00:00:00Z"));
238    /// ```
239    #[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    /// Return a typed builder for constructing a `HealthCheck`.
246    ///
247    /// Required fields (`component_type` and `status`) must be set before calling
248    /// [`HealthCheckBuilder::build`]; the compiler enforces this via typestate.
249    ///
250    /// # Example
251    /// ```rust
252    /// use api_bones::health::{HealthCheck, HealthStatus};
253    ///
254    /// let check = HealthCheck::builder()
255    ///     .component_type("datastore")
256    ///     .status(HealthStatus::Pass)
257    ///     .build();
258    /// assert_eq!(check.status, HealthStatus::Pass);
259    /// ```
260    #[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// ---------------------------------------------------------------------------
272// HealthCheck builder — typestate
273// ---------------------------------------------------------------------------
274
275/// Typestate builder for [`HealthCheck`].
276///
277/// Type parameters track whether required fields have been set:
278/// - `CT` — `String` once `.component_type()` is called, `()` otherwise
279/// - `ST` — `HealthStatus` once `.status()` is called, `()` otherwise
280///
281/// [`HealthCheckBuilder::build`] is only available when both are set.
282///
283/// Requires `std` or `alloc`.
284#[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    /// Set the component type, e.g. `"datastore"`, `"component"`, `"system"`.
295    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    /// Set the check result status.
311    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    /// Set a human-readable output or error message.
324    #[must_use]
325    pub fn output(mut self, output: impl Into<String>) -> Self {
326        self.output = Some(output.into());
327        self
328    }
329
330    /// Set an RFC 3339 timestamp of when this check was performed.
331    #[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    /// Build the [`HealthCheck`].
341    ///
342    /// Only available once both `component_type` and `status` have been set.
343    #[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// ---------------------------------------------------------------------------
355// LivenessResponse
356// ---------------------------------------------------------------------------
357
358/// Liveness probe response (`GET /health`) — RFC 8458.
359///
360/// Answers the question: "is this process alive?" No dependency checks.
361/// Always returns HTTP `200 OK`.
362///
363/// Requires `std` or `alloc` (fields contain `String`).
364#[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    /// Overall health status. Should always be [`HealthStatus::Pass`] for liveness.
373    pub status: HealthStatus,
374    /// Semantic version of the service, e.g. `"1.0.0"`.
375    pub version: String,
376    /// Unique identifier for this service instance, e.g. `"my-service"`.
377    #[cfg_attr(feature = "serde", serde(rename = "serviceId"))]
378    pub service_id: String,
379}
380
381#[cfg(any(feature = "std", feature = "alloc"))]
382impl LivenessResponse {
383    /// Create a passing liveness response.
384    ///
385    /// # Examples
386    ///
387    /// ```
388    /// use api_bones::health::{LivenessResponse, HealthStatus};
389    ///
390    /// let resp = LivenessResponse::pass("1.0.0", "my-service");
391    /// assert_eq!(resp.status, HealthStatus::Pass);
392    /// assert_eq!(resp.version, "1.0.0");
393    /// assert_eq!(resp.service_id, "my-service");
394    /// ```
395    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// ---------------------------------------------------------------------------
405// ReadinessResponse (requires std for HashMap)
406// ---------------------------------------------------------------------------
407
408/// Readiness/startup probe response (`GET /health/ready`, `GET /health/startup`) — RFC 8458.
409///
410/// Answers the question: "is this service ready to handle traffic?"
411/// Includes dependency checks (database, cache, upstream services).
412///
413/// HTTP status: `200 OK` on `pass`/`warn`, `503 Service Unavailable` on `fail`.
414///
415/// Requires the `std` feature (uses `HashMap` for dependency checks).
416#[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    /// Overall health status, derived from the worst check result.
425    pub status: HealthStatus,
426    /// Semantic version of the service, e.g. `"1.0.0"`.
427    pub version: String,
428    /// Unique identifier for this service instance, e.g. `"my-service"`.
429    #[cfg_attr(feature = "serde", serde(rename = "serviceId"))]
430    pub service_id: String,
431    /// Component check results. Key format: `"<component>:<measurement>"`.
432    /// Example: `"postgres:connection"`, `"redis:latency"`.
433    pub checks: HashMap<String, Vec<HealthCheck>>,
434}
435
436#[cfg(feature = "std")]
437impl ReadinessResponse {
438    /// Create a new readiness response, computing overall status from checks.
439    ///
440    /// Status is the worst of all check statuses: `fail` > `warn` > `pass`.
441    ///
442    /// # Examples
443    ///
444    /// ```
445    /// use std::collections::HashMap;
446    /// use api_bones::health::{ReadinessResponse, HealthCheck, HealthStatus};
447    ///
448    /// let mut checks = HashMap::new();
449    /// checks.insert(
450    ///     "postgres:connection".to_string(),
451    ///     vec![HealthCheck::pass("datastore")],
452    /// );
453    /// checks.insert(
454    ///     "redis:ping".to_string(),
455    ///     vec![HealthCheck::fail("datastore", "timeout")],
456    /// );
457    /// let resp = ReadinessResponse::new("1.0.0", "my-service", checks);
458    /// assert_eq!(resp.status, HealthStatus::Fail);
459    /// assert_eq!(resp.http_status(), 503);
460    /// ```
461    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    /// HTTP status code for this response.
476    #[must_use]
477    pub fn http_status(&self) -> u16 {
478        self.status.http_status()
479    }
480
481    /// Return a typed builder for constructing a `ReadinessResponse`.
482    ///
483    /// Required fields (`version` and `service_id`) must be set before calling
484    /// [`ReadinessResponseBuilder::build`]; the compiler enforces this via typestate.
485    /// Use [`ReadinessResponseBuilder::add_check`] to accumulate component checks.
486    ///
487    /// # Example
488    /// ```rust
489    /// use api_bones::health::{HealthCheck, ReadinessResponse};
490    ///
491    /// let resp = ReadinessResponse::builder()
492    ///     .version("1.0.0")
493    ///     .service_id("my-service")
494    ///     .add_check("postgres:connection", HealthCheck::pass("datastore"))
495    ///     .build();
496    /// assert!(resp.checks.contains_key("postgres:connection"));
497    /// ```
498    #[must_use]
499    pub fn builder() -> ReadinessResponseBuilder<(), ()> {
500        ReadinessResponseBuilder {
501            version: (),
502            service_id: (),
503            checks: HashMap::new(),
504        }
505    }
506
507    /// Compute aggregate status from all checks: worst of pass/warn/fail wins.
508    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// ---------------------------------------------------------------------------
529// ReadinessResponse builder — typestate
530// ---------------------------------------------------------------------------
531
532/// Typestate builder for [`ReadinessResponse`].
533///
534/// Type parameters track whether required fields have been set:
535/// - `V` — `String` once `.version()` is called, `()` otherwise
536/// - `S` — `String` once `.service_id()` is called, `()` otherwise
537///
538/// [`ReadinessResponseBuilder::build`] is only available when both are set.
539///
540/// Requires the `std` feature (uses `HashMap` internally).
541#[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    /// Set the semantic version of the service, e.g. `"1.0.0"`.
551    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    /// Set the unique service instance identifier, e.g. `"my-service"`.
563    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    /// Add a single component check under the given key.
575    ///
576    /// Key format: `"<component>:<measurement>"`, e.g. `"postgres:connection"`.
577    /// Multiple checks under the same key are appended.
578    #[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    /// Build the [`ReadinessResponse`], computing the aggregate status from all checks.
588    ///
589    /// Only available once both `version` and `service_id` have been set.
590    #[must_use]
591    pub fn build(self) -> ReadinessResponse {
592        ReadinessResponse::new(self.version, self.service_id, self.checks)
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Tests
598// ---------------------------------------------------------------------------
599
600#[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    // -----------------------------------------------------------------------
736    // HealthCheck builder tests
737    // -----------------------------------------------------------------------
738
739    #[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        // Typestate allows setting status before component_type
780        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    // -----------------------------------------------------------------------
789    // ReadinessResponse builder tests
790    // -----------------------------------------------------------------------
791
792    #[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}