Skip to main content

hyperi_rustlib/http_server/
config.rs

1// Project:   hyperi-rustlib
2// File:      src/http_server/config.rs
3// Purpose:   HTTP server configuration
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! HTTP server configuration.
10
11use serde::{Deserialize, Serialize};
12use std::time::Duration;
13
14/// HTTP server configuration.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[allow(clippy::struct_excessive_bools)]
17pub struct HttpServerConfig {
18    /// Address to bind to (e.g., "0.0.0.0:8080").
19    pub bind_address: String,
20
21    /// Request timeout in milliseconds.
22    /// Defaults to 30 seconds.
23    #[serde(default = "default_request_timeout_ms")]
24    pub request_timeout_ms: u64,
25
26    /// Keep-alive timeout in ms. Default 75 s.
27    ///
28    /// **Not wired.** axum 0.8 `serve()` doesn't surface keep-alive;
29    /// needs hyper builder. Follow-up.
30    #[serde(default = "default_keep_alive_timeout_ms")]
31    pub keep_alive_timeout_ms: u64,
32
33    /// In-flight request cap. Default 10,000. Enforced via
34    /// `tower::limit::ConcurrencyLimitLayer`. Excess requests queue.
35    #[serde(default = "default_max_connections")]
36    pub max_connections: usize,
37
38    /// Mount /health/live + /health/ready. Default true.
39    #[serde(default = "default_true")]
40    pub enable_health_endpoints: bool,
41
42    /// Mount /metrics.
43    ///
44    /// **Not wired here.** MetricsManager owns its own /metrics
45    /// listener (often a separate admin port). Forward-compat for
46    /// framework-managed wiring.
47    #[serde(default)]
48    pub enable_metrics_endpoint: bool,
49
50    /// Mount /config (redacted effective config). Default false.
51    #[serde(default)]
52    pub enable_config_endpoint: bool,
53
54    /// HTTP/2 (also required for gRPC).
55    ///
56    /// **Not wired.** axum 0.8 `serve()` always negotiates HTTP/1
57    /// and HTTP/2 on cleartext. Disabling needs hyper builder.
58    #[serde(default = "default_true")]
59    pub enable_http2: bool,
60
61    /// TLS cert path (PEM).
62    ///
63    /// **Not wired.** Needs `axum_server::tls_rustls` (not a dep).
64    /// K8s pattern: terminate TLS at Ingress / Service Mesh,
65    /// cleartext in-pod.
66    #[serde(default)]
67    pub tls_cert_path: Option<String>,
68
69    /// TLS key path (PEM). See `tls_cert_path` -- not wired.
70    #[serde(default)]
71    pub tls_key_path: Option<String>,
72
73    /// Graceful drain budget in ms. Default 30 s. Caps drain time
74    /// to fit under K8s `terminationGracePeriodSeconds`.
75    #[serde(default = "default_shutdown_timeout_ms")]
76    pub shutdown_timeout_ms: u64,
77}
78
79fn default_request_timeout_ms() -> u64 {
80    30_000
81}
82
83fn default_keep_alive_timeout_ms() -> u64 {
84    75_000
85}
86
87fn default_max_connections() -> usize {
88    10_000
89}
90
91fn default_shutdown_timeout_ms() -> u64 {
92    30_000
93}
94
95fn default_true() -> bool {
96    true
97}
98
99impl Default for HttpServerConfig {
100    fn default() -> Self {
101        Self {
102            bind_address: "0.0.0.0:8080".to_string(),
103            request_timeout_ms: default_request_timeout_ms(),
104            keep_alive_timeout_ms: default_keep_alive_timeout_ms(),
105            max_connections: default_max_connections(),
106            enable_health_endpoints: true,
107            enable_metrics_endpoint: false,
108            enable_config_endpoint: false,
109            enable_http2: true,
110            tls_cert_path: None,
111            tls_key_path: None,
112            shutdown_timeout_ms: default_shutdown_timeout_ms(),
113        }
114    }
115}
116
117impl HttpServerConfig {
118    /// Create a new config with the given bind address.
119    #[must_use]
120    pub fn new(bind_address: impl Into<String>) -> Self {
121        Self {
122            bind_address: bind_address.into(),
123            ..Default::default()
124        }
125    }
126
127    /// Get request timeout as Duration.
128    #[must_use]
129    pub fn request_timeout(&self) -> Duration {
130        Duration::from_millis(self.request_timeout_ms)
131    }
132
133    /// Get keep-alive timeout as Duration.
134    #[must_use]
135    pub fn keep_alive_timeout(&self) -> Duration {
136        Duration::from_millis(self.keep_alive_timeout_ms)
137    }
138
139    /// Get shutdown timeout as Duration.
140    #[must_use]
141    pub fn shutdown_timeout(&self) -> Duration {
142        Duration::from_millis(self.shutdown_timeout_ms)
143    }
144
145    /// Whether TLS cert+key paths are present in config.
146    ///
147    /// NOTE: this only reports that the fields are *set* -- in-process TLS is
148    /// NOT terminated by this server (see `tls_cert_path`). Use
149    /// [`validate`](Self::validate) at startup so a config that *expects*
150    /// in-pod TLS fails loudly instead of silently serving cleartext.
151    #[must_use]
152    pub fn is_tls_enabled(&self) -> bool {
153        self.tls_cert_path.is_some() && self.tls_key_path.is_some()
154    }
155
156    /// Validate the server config (finding 10: `is_tls_enabled` must not lie).
157    ///
158    /// In-process TLS termination is not supported -- the K8s pattern is to
159    /// terminate TLS at the ingress / service mesh and run cleartext in-pod.
160    /// If `tls_cert_path`/`tls_key_path` are set, the operator expects in-pod
161    /// TLS that will not happen, so this errors rather than letting the server
162    /// bind cleartext while the config claims TLS. Call at startup.
163    ///
164    /// # Errors
165    ///
166    /// Returns `Err` if either TLS path is set.
167    pub fn validate(&self) -> Result<(), String> {
168        if self.tls_cert_path.is_some() || self.tls_key_path.is_some() {
169            return Err(
170                "http_server: in-process TLS is not supported (tls_cert_path / \
171                 tls_key_path set) -- terminate TLS at the ingress / service mesh \
172                 and leave these unset, or front the service with a TLS sidecar"
173                    .to_string(),
174            );
175        }
176        Ok(())
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_default_config() {
186        let config = HttpServerConfig::default();
187        assert_eq!(config.bind_address, "0.0.0.0:8080");
188        assert_eq!(config.request_timeout_ms, 30_000);
189        assert_eq!(config.keep_alive_timeout_ms, 75_000);
190        assert_eq!(config.max_connections, 10_000);
191        assert!(config.enable_health_endpoints);
192        assert!(!config.enable_metrics_endpoint);
193        assert!(config.enable_http2);
194        assert!(!config.is_tls_enabled());
195    }
196
197    #[test]
198    fn test_new_with_address() {
199        let config = HttpServerConfig::new("127.0.0.1:3000");
200        assert_eq!(config.bind_address, "127.0.0.1:3000");
201    }
202
203    #[test]
204    fn test_tls_enabled() {
205        let mut config = HttpServerConfig::default();
206        assert!(!config.is_tls_enabled());
207
208        config.tls_cert_path = Some("/path/to/cert.pem".to_string());
209        assert!(!config.is_tls_enabled());
210
211        config.tls_key_path = Some("/path/to/key.pem".to_string());
212        assert!(config.is_tls_enabled());
213    }
214
215    #[test]
216    fn validate_rejects_unsupported_in_process_tls() {
217        // Default (no TLS paths) validates.
218        assert!(HttpServerConfig::default().validate().is_ok());
219
220        // Setting either TLS path is rejected -- the server can't terminate
221        // TLS, so a config expecting in-pod TLS must fail loudly, not serve
222        // cleartext while is_tls_enabled() reports true.
223        let mut config = HttpServerConfig::default();
224        config.tls_cert_path = Some("/path/to/cert.pem".to_string());
225        assert!(!config.is_tls_enabled()); // only one path set
226        assert!(
227            config.validate().is_err(),
228            "a set TLS path must be rejected"
229        );
230
231        config.tls_key_path = Some("/path/to/key.pem".to_string());
232        assert!(config.is_tls_enabled());
233        assert!(
234            config.validate().is_err(),
235            "is_tls_enabled() true but in-process TLS unsupported -> reject"
236        );
237    }
238
239    #[test]
240    fn test_duration_conversions() {
241        let config = HttpServerConfig::default();
242        assert_eq!(config.request_timeout(), Duration::from_secs(30));
243        assert_eq!(config.keep_alive_timeout(), Duration::from_secs(75));
244        assert_eq!(config.shutdown_timeout(), Duration::from_secs(30));
245    }
246}