feat(config): optional TOML config loading behind config-file feature
A file-based way to set tuning knobs without recompiling. urus is a library, so it never presumes a config path or reads the environment — the binary hands the text in: - Config::with_toml_str(&str) -> Result<Config, ConfigError>: sparse overlay onto an existing Config (built with the addr the binary chose). Only keys present are applied; durations are integer seconds; unknown keys are a hard error (deny_unknown_fields) so a typo is loud, not a silent no-op. - Scope: the four slowloris knobs (head_timeout_secs, body_timeout_secs, body_burst_bytes, body_stall_timeout_secs). Migrating the rest of Config into the file is a separate, additive job — TomlOverrides just grows. - Feature `config-file = ["dep:serde", "dep:toml"]`; the optional serde dep gains the derive feature. The default build is unchanged (deps + code are all gated). - examples/serve_toml.rs (required-features = ["config-file"]): a `--config PATH` demo with no presumed default location. plain_serve and its env vars are left untouched. Tests (feature-gated): empty keeps defaults, partial overrides only named, full overrides all, unknown key errors, malformed errors. 84 lib with the feature / 79 without; clippy --lib clean both ways; e2e smoke serves 200 from a file and rejects an unknown key loudly.
This commit is contained in:
+8
-1
@@ -14,14 +14,17 @@ sha1_smol = "1"
|
||||
|
||||
# dep #4, ratified 2026-06-12: serde/serde_json behind the opt-in
|
||||
# "phoenix" feature only — the "channels" core stays dependency-free.
|
||||
serde = { version = "1", optional = true }
|
||||
serde = { version = "1", optional = true, features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
# config-file feature: TOML loader for tuning knobs (dep #5, 2026-08-12)
|
||||
toml = { version = "0.8", optional = true }
|
||||
|
||||
[features]
|
||||
smarm-trace = ["smarm/smarm-trace"]
|
||||
smarm-causal = ["smarm/smarm-causal"]
|
||||
channels = []
|
||||
phoenix = ["channels", "dep:serde", "dep:serde_json"]
|
||||
config-file = ["dep:serde", "dep:toml"]
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -47,3 +50,7 @@ path = "examples/crud.rs"
|
||||
name = "channels_chat"
|
||||
path = "examples/channels_chat.rs"
|
||||
required-features = ["phoenix"]
|
||||
|
||||
[[example]]
|
||||
name = "serve_toml"
|
||||
required-features = ["config-file"]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Serve with an optional TOML config overlay.
|
||||
//!
|
||||
//! urus is a library and never presumes a config path or reads the
|
||||
//! environment for one — the binary decides where the file lives and hands
|
||||
//! the text to `Config::with_toml_str`. Here that's a `--config PATH` flag;
|
||||
//! with no flag, the compiled defaults are used unchanged.
|
||||
//!
|
||||
//! Requires the `config-file` feature:
|
||||
//! cargo run --example serve_toml --features config-file -- --config urus.toml
|
||||
//!
|
||||
//! Example urus.toml (all keys optional, sparse override; seconds):
|
||||
//! head_timeout_secs = 15
|
||||
//! body_timeout_secs = 300
|
||||
//! body_burst_bytes = 4096
|
||||
//! body_stall_timeout_secs = 20
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use urus::{serve_with, Config, Conn, Next, Pipeline, Router};
|
||||
|
||||
fn json_id(conn: Conn, _next: Next) -> Conn {
|
||||
let id: u64 = conn.params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
conn.put_status(200)
|
||||
.put_header("content-type", "application/json")
|
||||
.put_body(format!("{{\"id\":{id}}}"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let addr: SocketAddr = "0.0.0.0:8080".parse().expect("addr");
|
||||
let mut cfg = Config::new(addr);
|
||||
|
||||
// Minimal flag scan: `--config PATH`. No presumed default location.
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--config" {
|
||||
let path = args.next().expect("--config needs a PATH");
|
||||
let toml = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("reading config {path}: {e}"));
|
||||
cfg = cfg
|
||||
.with_toml_str(&toml)
|
||||
.unwrap_or_else(|e| panic!("invalid config {path}: {e}"));
|
||||
eprintln!("serve_toml: loaded config from {path}");
|
||||
}
|
||||
}
|
||||
|
||||
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
|
||||
serve_with(cfg, pipe).expect("serve");
|
||||
}
|
||||
@@ -44,3 +44,5 @@ pub use ws::{Message, WsClosed, WsHandler, WsSender};
|
||||
pub use serve::{
|
||||
serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal,
|
||||
};
|
||||
#[cfg(feature = "config-file")]
|
||||
pub use serve::ConfigError;
|
||||
|
||||
+131
@@ -135,6 +135,79 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// config-file: TOML overlay for tuning knobs
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// urus is a library, so it never presumes a config-file path or reads the
|
||||
// environment — the embedding binary decides where a file lives and hands
|
||||
// the text here. This overlays a sparse TOML document onto an existing
|
||||
// `Config` (built with an addr the binary chose): only the keys present are
|
||||
// applied, everything else keeps the compiled default. Durations are
|
||||
// integer seconds. Unknown keys are a hard error so a typo is loud, not a
|
||||
// silent no-op.
|
||||
//
|
||||
// Scope for now: the slowloris-tuning knobs only. Migrating the rest of the
|
||||
// Config surface into the file is a separate, additive job (the loader
|
||||
// mechanism is general — it just extends `TomlOverrides`).
|
||||
|
||||
/// Error from [`Config::with_toml_str`]: the TOML failed to parse or carried
|
||||
/// an unknown/mistyped key.
|
||||
#[cfg(feature = "config-file")]
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Toml(String),
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-file")]
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConfigError::Toml(m) => write!(f, "config TOML error: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-file")]
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
#[cfg(feature = "config-file")]
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TomlOverrides {
|
||||
head_timeout_secs: Option<u64>,
|
||||
body_timeout_secs: Option<u64>,
|
||||
body_burst_bytes: Option<usize>,
|
||||
body_stall_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-file")]
|
||||
impl Config {
|
||||
/// Overlay a TOML document of tuning knobs onto this config (sparse:
|
||||
/// only the keys present are applied). Durations are integer seconds.
|
||||
///
|
||||
/// Recognized keys: `head_timeout_secs`, `body_timeout_secs`,
|
||||
/// `body_burst_bytes`, `body_stall_timeout_secs`. Unknown keys error.
|
||||
/// Other `Config` knobs are not yet file-configurable.
|
||||
pub fn with_toml_str(mut self, s: &str) -> Result<Self, ConfigError> {
|
||||
let o: TomlOverrides =
|
||||
toml::from_str(s).map_err(|e| ConfigError::Toml(e.to_string()))?;
|
||||
if let Some(v) = o.head_timeout_secs {
|
||||
self.head_timeout = Duration::from_secs(v);
|
||||
}
|
||||
if let Some(v) = o.body_timeout_secs {
|
||||
self.body_timeout = Duration::from_secs(v);
|
||||
}
|
||||
if let Some(v) = o.body_burst_bytes {
|
||||
self.body_burst_bytes = v;
|
||||
}
|
||||
if let Some(v) = o.body_stall_timeout_secs {
|
||||
self.body_stall_timeout = Duration::from_secs(v);
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dup helper
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -470,3 +543,61 @@ pub fn serve(addr: impl ToSocketAddrs, pipeline: Pipeline) -> io::Result<()> {
|
||||
.ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "no addresses resolved"))?;
|
||||
serve_with(Config::new(addr), pipeline)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "config-file"))]
|
||||
mod config_file_tests {
|
||||
use super::*;
|
||||
|
||||
fn base() -> Config {
|
||||
Config::new("127.0.0.1:0".parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_empty_keeps_defaults() {
|
||||
let d = base();
|
||||
let c = base().with_toml_str("").unwrap();
|
||||
assert_eq!(c.head_timeout, d.head_timeout);
|
||||
assert_eq!(c.body_timeout, d.body_timeout);
|
||||
assert_eq!(c.body_burst_bytes, d.body_burst_bytes);
|
||||
assert_eq!(c.body_stall_timeout, d.body_stall_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_partial_overrides_only_named() {
|
||||
let d = base();
|
||||
let c = base().with_toml_str("head_timeout_secs = 5").unwrap();
|
||||
assert_eq!(c.head_timeout, Duration::from_secs(5)); // overridden
|
||||
assert_eq!(c.body_timeout, d.body_timeout); // default kept
|
||||
assert_eq!(c.body_burst_bytes, d.body_burst_bytes); // default kept
|
||||
assert_eq!(c.body_stall_timeout, d.body_stall_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_full_overrides_all() {
|
||||
let c = base()
|
||||
.with_toml_str(
|
||||
"head_timeout_secs = 10\n\
|
||||
body_timeout_secs = 120\n\
|
||||
body_burst_bytes = 8192\n\
|
||||
body_stall_timeout_secs = 15\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.head_timeout, Duration::from_secs(10));
|
||||
assert_eq!(c.body_timeout, Duration::from_secs(120));
|
||||
assert_eq!(c.body_burst_bytes, 8192);
|
||||
assert_eq!(c.body_stall_timeout, Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_unknown_key_errors() {
|
||||
// A mistyped/unknown key is a hard error, not a silent no-op.
|
||||
let e = base().with_toml_str("body_timeout_sec = 120"); // typo: missing 's'
|
||||
assert!(e.is_err(), "unknown key should error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_malformed_errors() {
|
||||
let e = base().with_toml_str("this is not = valid = toml");
|
||||
assert!(e.is_err(), "malformed TOML should error");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user