indexbus_platform_http/
server.rs

1//! Server-side integration points.
2//!
3//! This module provides small, framework-agnostic primitives for exposing standard operational
4//! endpoints.
5//!
6//! ## Feature requirements
7//!
8//! - The core types ([`Readiness`](crate::server::Readiness),
9//!   [`HttpServer`](crate::server::HttpServer)) require `std`.
10//! - `axum` integration helpers are available behind the `axum` feature.
11//!
12//! ## Contracts
13//!
14//! - `Readiness` is cooperative state: application code must update it.
15//! - Readiness is intended for load balancers and orchestrators; it is not a substitute for
16//!   correctness checks.
17
18use crate::errors::Result;
19
20use std::sync::atomic::{AtomicU8, Ordering};
21use std::sync::Arc;
22
23/// Default route path for the liveness endpoint.
24pub const DEFAULT_HEALTHZ_PATH: &str = "/healthz";
25
26/// Default route path for the readiness endpoint.
27pub const DEFAULT_READYZ_PATH: &str = "/readyz";
28
29/// Readiness state used by [`Readiness`].
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum ReadinessState {
32    /// The application is not ready to receive traffic.
33    NotReady = 0,
34    /// The application is ready to receive traffic.
35    Ready = 1,
36    /// The application is shutting down and should stop receiving traffic.
37    ShuttingDown = 2,
38}
39
40impl ReadinessState {
41    fn from_u8(value: u8) -> Self {
42        match value {
43            1 => Self::Ready,
44            2 => Self::ShuttingDown,
45            _ => Self::NotReady,
46        }
47    }
48}
49
50/// Shareable readiness latch for exposing a `/readyz` endpoint.
51///
52/// Defaults to `NotReady`. Application code should call `mark_ready()` once
53/// initialization completes.
54///
55/// ## Thread-safety
56///
57/// This type is cheaply clonable and can be shared across threads.
58#[derive(Clone, Debug, Default)]
59pub struct Readiness {
60    state: Arc<AtomicU8>,
61}
62
63impl Readiness {
64    /// Create a new latch in the default state.
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Get the current readiness state.
70    pub fn state(&self) -> ReadinessState {
71        ReadinessState::from_u8(self.state.load(Ordering::Acquire))
72    }
73
74    /// Returns `true` if the state is [`ReadinessState::Ready`].
75    ///
76    /// **Note:** [`ReadinessState::ShuttingDown`] is treated as "not ready".
77    pub fn is_ready(&self) -> bool {
78        self.state() == ReadinessState::Ready
79    }
80
81    /// Mark the service as not ready.
82    pub fn mark_not_ready(&self) {
83        self.state
84            .store(ReadinessState::NotReady as u8, Ordering::Release);
85    }
86
87    /// Mark the service as ready.
88    pub fn mark_ready(&self) {
89        self.state
90            .store(ReadinessState::Ready as u8, Ordering::Release);
91    }
92
93    /// Mark the service as shutting down.
94    pub fn mark_shutting_down(&self) {
95        self.state
96            .store(ReadinessState::ShuttingDown as u8, Ordering::Release);
97    }
98}
99
100/// Minimal server-side shape for standard platform health and readiness endpoints.
101///
102/// This type is intentionally thin: it carries shared readiness state and provides
103/// optional integration with HTTP server frameworks via feature-gated adapters.
104///
105/// ## Contracts
106///
107/// - The readiness latch returned by [`HttpServer::readiness`] is the single source of truth.
108/// - Paths are stored as `&'static str` to keep the type lightweight and allocation-free.
109#[derive(Clone, Debug, Default)]
110pub struct HttpServer {
111    readiness: Readiness,
112    healthz_path: &'static str,
113    readyz_path: &'static str,
114}
115
116impl HttpServer {
117    /// Create a new server helper with default paths.
118    pub fn new() -> Self {
119        Self {
120            readiness: Readiness::new(),
121            healthz_path: DEFAULT_HEALTHZ_PATH,
122            readyz_path: DEFAULT_READYZ_PATH,
123        }
124    }
125
126    /// Get a clone of the shared readiness latch.
127    pub fn readiness(&self) -> Readiness {
128        self.readiness.clone()
129    }
130
131    /// Get the configured path for the health endpoint.
132    pub fn healthz_path(&self) -> &'static str {
133        self.healthz_path
134    }
135
136    /// Get the configured path for the readiness endpoint.
137    pub fn readyz_path(&self) -> &'static str {
138        self.readyz_path
139    }
140
141    /// Small smoke-check hook that always succeeds for now.
142    ///
143    /// Intended to evolve into optional trait-based checks, while keeping the
144    /// default lightweight.
145    ///
146    /// **Errors:** reserved for future expansions; currently always returns `Ok(())`.
147    pub fn healthcheck(&self) -> Result<()> {
148        Ok(())
149    }
150}
151
152#[cfg(feature = "axum")]
153impl HttpServer {
154    /// Build an `axum::Router` exposing `/healthz` and `/readyz`.
155    pub fn axum_router(&self) -> axum::Router {
156        axum_router(self.readiness.clone(), self.healthz_path, self.readyz_path)
157    }
158}
159
160#[cfg(feature = "axum")]
161/// Build an `axum::Router` exposing the given health and readiness paths.
162///
163/// ## Contracts
164///
165/// - The `/healthz` handler always returns `200 OK` with body `"ok"`.
166/// - The `/readyz` handler returns `200 OK` iff [`Readiness::is_ready`] is true, otherwise
167///   `503 SERVICE_UNAVAILABLE`.
168pub fn axum_router(
169    readiness: Readiness,
170    healthz_path: &'static str,
171    readyz_path: &'static str,
172) -> axum::Router {
173    use axum::routing::get;
174
175    axum::Router::new()
176        .route(healthz_path, get(axum_healthz))
177        .route(readyz_path, get(axum_readyz))
178        .with_state(readiness)
179}
180
181#[cfg(feature = "axum")]
182async fn axum_healthz() -> (http::StatusCode, &'static str) {
183    (http::StatusCode::OK, "ok")
184}
185
186#[cfg(feature = "axum")]
187async fn axum_readyz(
188    axum::extract::State(readiness): axum::extract::State<Readiness>,
189) -> (http::StatusCode, &'static str) {
190    if readiness.is_ready() {
191        (http::StatusCode::OK, "ready")
192    } else {
193        (http::StatusCode::SERVICE_UNAVAILABLE, "not_ready")
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[cfg(feature = "axum")]
202    fn block_on<T>(mut fut: impl std::future::Future<Output = T>) -> T {
203        use std::pin::Pin;
204        use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
205
206        fn no_op(_: *const ()) {}
207        fn clone(_: *const ()) -> RawWaker {
208            RawWaker::new(std::ptr::null(), &VTABLE)
209        }
210        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
211
212        let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
213        let mut cx = Context::from_waker(&waker);
214
215        // SAFETY: we never move `fut` after pinning.
216        // This is a tiny local executor for unit tests only.
217        let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
218        loop {
219            match fut.as_mut().poll(&mut cx) {
220                Poll::Ready(val) => return val,
221                Poll::Pending => std::thread::yield_now(),
222            }
223        }
224    }
225
226    #[test]
227    fn readiness_defaults_to_not_ready() {
228        let readiness = Readiness::new();
229        assert_eq!(readiness.state(), ReadinessState::NotReady);
230        assert!(!readiness.is_ready());
231    }
232
233    #[test]
234    fn readiness_transitions() {
235        let readiness = Readiness::new();
236
237        readiness.mark_ready();
238        assert_eq!(readiness.state(), ReadinessState::Ready);
239        assert!(readiness.is_ready());
240
241        readiness.mark_shutting_down();
242        assert_eq!(readiness.state(), ReadinessState::ShuttingDown);
243        assert!(!readiness.is_ready());
244
245        readiness.mark_not_ready();
246        assert_eq!(readiness.state(), ReadinessState::NotReady);
247        assert!(!readiness.is_ready());
248    }
249
250    #[test]
251    fn http_server_exposes_readiness() {
252        let server = HttpServer::new();
253        let readiness = server.readiness();
254        readiness.mark_ready();
255        assert!(server.readiness().is_ready());
256    }
257
258    #[cfg(feature = "axum")]
259    #[test]
260    fn axum_routes_expose_healthz_and_readyz() {
261        use axum::body::Body;
262        use http::Request;
263        use tower::ServiceExt;
264
265        let server = HttpServer::new();
266        let app = server.axum_router();
267
268        let res = block_on(
269            app.clone().oneshot(
270                Request::builder()
271                    .method("GET")
272                    .uri(server.healthz_path())
273                    .body(Body::empty())
274                    .unwrap(),
275            ),
276        )
277        .unwrap();
278        assert_eq!(res.status(), http::StatusCode::OK);
279
280        let res = block_on(
281            app.clone().oneshot(
282                Request::builder()
283                    .method("GET")
284                    .uri(server.readyz_path())
285                    .body(Body::empty())
286                    .unwrap(),
287            ),
288        )
289        .unwrap();
290        assert_eq!(res.status(), http::StatusCode::SERVICE_UNAVAILABLE);
291
292        server.readiness().mark_ready();
293        let res = block_on(
294            app.oneshot(
295                Request::builder()
296                    .method("GET")
297                    .uri(server.readyz_path())
298                    .body(Body::empty())
299                    .unwrap(),
300            ),
301        )
302        .unwrap();
303        assert_eq!(res.status(), http::StatusCode::OK);
304    }
305
306    #[cfg(feature = "axum")]
307    #[test]
308    fn axum_router_allows_custom_paths() {
309        use axum::body::Body;
310        use http::Request;
311        use tower::ServiceExt;
312
313        let readiness = Readiness::new();
314        let app = axum_router(readiness.clone(), "/_h", "/_r");
315
316        let res = block_on(
317            app.clone().oneshot(
318                Request::builder()
319                    .method("GET")
320                    .uri("/_h")
321                    .body(Body::empty())
322                    .unwrap(),
323            ),
324        )
325        .unwrap();
326        assert_eq!(res.status(), http::StatusCode::OK);
327
328        readiness.mark_ready();
329        let res = block_on(
330            app.oneshot(
331                Request::builder()
332                    .method("GET")
333                    .uri("/_r")
334                    .body(Body::empty())
335                    .unwrap(),
336            ),
337        )
338        .unwrap();
339        assert_eq!(res.status(), http::StatusCode::OK);
340    }
341}