use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("invalid value {value:?} for `{key}`: {reason}")]
InvalidValue {
key: String,
value: String,
reason: String,
},
#[error("failed to read configuration file `{path}`: {source}")]
Read {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse configuration file `{path}`: {message}")]
Parse {
path: String,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub workers: Option<usize>,
#[serde(with = "secs::option")]
pub request_timeout: Option<Duration>,
pub max_body_size: usize,
pub max_connections: Option<usize>,
pub max_concurrent_requests: Option<usize>,
#[serde(with = "secs")]
pub shutdown_timeout: Duration,
#[serde(with = "secs")]
pub header_read_timeout: Duration,
pub tcp_nodelay: bool,
pub connect_info: bool,
}
pub const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_owned(),
port: 3000,
workers: None,
request_timeout: None,
max_body_size: DEFAULT_MAX_BODY_SIZE,
max_connections: None,
max_concurrent_requests: None,
shutdown_timeout: Duration::from_secs(30),
header_read_timeout: Duration::from_secs(30),
tcp_nodelay: true,
connect_info: true,
}
}
}
impl ServerConfig {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
#[must_use]
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
#[must_use]
pub fn workers(mut self, workers: usize) -> Self {
self.workers = Some(workers);
self
}
#[must_use]
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = Some(timeout);
self
}
#[must_use]
pub fn max_body_size(mut self, bytes: usize) -> Self {
self.max_body_size = bytes;
self
}
#[must_use]
pub fn max_connections(mut self, max: usize) -> Self {
self.max_connections = Some(max);
self
}
#[must_use]
pub fn max_concurrent_requests(mut self, max: usize) -> Self {
self.max_concurrent_requests = Some(max);
self
}
#[must_use]
pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
self.shutdown_timeout = timeout;
self
}
#[must_use]
pub fn header_read_timeout(mut self, timeout: Duration) -> Self {
self.header_read_timeout = timeout;
self
}
#[must_use]
pub fn tcp_nodelay(mut self, enabled: bool) -> Self {
self.tcp_nodelay = enabled;
self
}
#[must_use]
pub fn connect_info(mut self, enabled: bool) -> Self {
self.connect_info = enabled;
self
}
pub fn address(&self) -> String {
if self.host.contains(':') && !self.host.starts_with('[') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
pub fn from_env() -> Result<Self, ConfigError> {
Self::default().apply_env()
}
pub fn apply_env(self) -> Result<Self, ConfigError> {
self.apply_env_vars(std::env::vars())
}
pub fn apply_env_vars<I, K, V>(mut self, vars: I) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
for (key, value) in vars {
let (key, value) = (key.as_ref(), value.as_ref().trim());
let Some(name) = key.strip_prefix("KERNIQ_") else {
continue;
};
match name {
"HOST" => self.host = value.to_owned(),
"PORT" => self.port = parse(key, value)?,
"WORKERS" => self.workers = Some(parse(key, value)?),
"REQUEST_TIMEOUT" => self.request_timeout = Some(parse_secs(key, value)?),
"MAX_BODY_SIZE" => self.max_body_size = parse(key, value)?,
"MAX_CONNECTIONS" => self.max_connections = Some(parse(key, value)?),
"MAX_CONCURRENT_REQUESTS" => {
self.max_concurrent_requests = Some(parse(key, value)?)
}
"SHUTDOWN_TIMEOUT" => self.shutdown_timeout = parse_secs(key, value)?,
"HEADER_READ_TIMEOUT" => self.header_read_timeout = parse_secs(key, value)?,
"TCP_NODELAY" => self.tcp_nodelay = parse_bool(key, value)?,
"CONNECT_INFO" => self.connect_info = parse_bool(key, value)?,
_ => {}
}
}
self.validate()?;
Ok(self)
}
#[cfg(feature = "config-file")]
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
let display = path.display().to_string();
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: display.clone(),
source,
})?;
Self::from_toml_str(&text).map_err(|err| match err {
ConfigError::Parse { message, .. } => ConfigError::Parse {
path: display,
message,
},
other => other,
})
}
#[cfg(feature = "config-file")]
pub fn from_toml_str(text: &str) -> Result<Self, ConfigError> {
let config: Self = toml::from_str(text).map_err(|err| ConfigError::Parse {
path: "<string>".to_owned(),
message: err.message().to_owned(),
})?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
fn non_zero(key: &str, value: Option<usize>) -> Result<(), ConfigError> {
match value {
Some(0) => Err(ConfigError::InvalidValue {
key: key.to_owned(),
value: "0".to_owned(),
reason: "must be greater than zero".to_owned(),
}),
_ => Ok(()),
}
}
if self.host.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "host".to_owned(),
value: self.host.clone(),
reason: "must not be empty".to_owned(),
});
}
non_zero("workers", self.workers)?;
non_zero("max_connections", self.max_connections)?;
non_zero("max_concurrent_requests", self.max_concurrent_requests)?;
if self.request_timeout == Some(Duration::ZERO) {
return Err(ConfigError::InvalidValue {
key: "request_timeout".to_owned(),
value: "0".to_owned(),
reason: "must be greater than zero".to_owned(),
});
}
Ok(())
}
}
fn parse<T>(key: &str, value: &str) -> Result<T, ConfigError>
where
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
value
.parse()
.map_err(|err: T::Err| ConfigError::InvalidValue {
key: key.to_owned(),
value: value.to_owned(),
reason: err.to_string(),
})
}
fn parse_secs(key: &str, value: &str) -> Result<Duration, ConfigError> {
let secs: f64 = parse(key, value)?;
Duration::try_from_secs_f64(secs).map_err(|err| ConfigError::InvalidValue {
key: key.to_owned(),
value: value.to_owned(),
reason: err.to_string(),
})
}
fn parse_bool(key: &str, value: &str) -> Result<bool, ConfigError> {
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(ConfigError::InvalidValue {
key: key.to_owned(),
value: value.to_owned(),
reason: "expected a boolean (true/false/1/0)".to_owned(),
}),
}
}
mod secs {
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_f64(d.as_secs_f64())
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let secs = f64::deserialize(d)?;
Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)
}
pub(super) mod option {
use super::*;
pub(crate) fn serialize<S: Serializer>(
d: &Option<Duration>,
s: S,
) -> Result<S::Ok, S::Error> {
match d {
Some(d) => s.serialize_some(&d.as_secs_f64()),
None => s.serialize_none(),
}
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
d: D,
) -> Result<Option<Duration>, D::Error> {
Option::<f64>::deserialize(d)?
.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))
.transpose()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_sets_fields() {
let c = ServerConfig::new()
.host("0.0.0.0")
.port(8080)
.workers(4)
.request_timeout(Duration::from_secs(30))
.max_body_size(1024)
.max_connections(10)
.max_concurrent_requests(5)
.shutdown_timeout(Duration::from_secs(5));
assert_eq!(c.address(), "0.0.0.0:8080");
assert_eq!(c.workers, Some(4));
assert_eq!(c.request_timeout, Some(Duration::from_secs(30)));
assert_eq!(c.max_body_size, 1024);
assert_eq!(c.max_connections, Some(10));
assert_eq!(c.max_concurrent_requests, Some(5));
assert_eq!(c.shutdown_timeout, Duration::from_secs(5));
c.validate().unwrap();
}
#[test]
fn ipv6_address_is_bracketed() {
assert_eq!(
ServerConfig::new().host("::1").port(80).address(),
"[::1]:80"
);
}
#[test]
fn env_overrides() {
let c = ServerConfig::default()
.apply_env_vars([
("KERNIQ_HOST", "0.0.0.0"),
("KERNIQ_PORT", "9000"),
("KERNIQ_REQUEST_TIMEOUT", "2.5"),
("KERNIQ_TCP_NODELAY", "false"),
("KERNIQ_MAX_CONCURRENT_REQUESTS", "64"),
("PATH", "/usr/bin"),
("KERNIQ_UNKNOWN", "ignored"),
])
.unwrap();
assert_eq!(c.address(), "0.0.0.0:9000");
assert_eq!(c.request_timeout, Some(Duration::from_millis(2500)));
assert!(!c.tcp_nodelay);
assert_eq!(c.max_concurrent_requests, Some(64));
}
#[test]
fn env_rejects_bad_values() {
let err = ServerConfig::default()
.apply_env_vars([("KERNIQ_PORT", "seventy")])
.unwrap_err();
assert!(err.to_string().contains("KERNIQ_PORT"));
let err = ServerConfig::default()
.apply_env_vars([("KERNIQ_WORKERS", "0")])
.unwrap_err();
assert!(err.to_string().contains("workers"));
}
#[cfg(feature = "config-file")]
#[test]
fn toml_parsing() {
let c = ServerConfig::from_toml_str(
"host = \"0.0.0.0\"\nport = 8080\nrequest_timeout = 30\nmax_connections = 100\n",
)
.unwrap();
assert_eq!(c.port, 8080);
assert_eq!(c.request_timeout, Some(Duration::from_secs(30)));
assert_eq!(c.max_connections, Some(100));
assert_eq!(c.shutdown_timeout, Duration::from_secs(30));
let err = ServerConfig::from_toml_str("prot = 1").unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
}