Skip to main content

kasl_server/
config.rs

1use std::net::SocketAddr;
2
3use anyhow::{Context, Result};
4
5/// Default bind address when `KASL_SERVER_ADDR` is not set.
6const DEFAULT_ADDR: &str = "0.0.0.0:8080";
7
8/// Days one batch may carry. A month of backfill in a single request, which is
9/// generous for the case it exists for and still bounded.
10const DEFAULT_MAX_BATCH_DAYS: usize = 31;
11
12/// Largest upload body accepted, in bytes. A day of dense activity is a few
13/// kilobytes; a month of them, with room to spare, is well under this.
14const DEFAULT_MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
15
16/// Runtime configuration, read from the environment.
17#[derive(Debug, Clone)]
18pub struct Config {
19    /// Address the HTTP server binds to (`KASL_SERVER_ADDR`).
20    pub addr: SocketAddr,
21    /// PostgreSQL connection string (`DATABASE_URL`).
22    pub database_url: String,
23    /// Agents to provision on startup (`KASL_AGENTS`), as `email:token` pairs.
24    /// The bootstrap way in until the admin UI issues tokens.
25    pub agents: String,
26    /// Days one batch upload may carry (`KASL_MAX_BATCH_DAYS`).
27    pub max_batch_days: usize,
28    /// Largest request body accepted (`KASL_MAX_BODY_BYTES`).
29    pub max_body_bytes: usize,
30}
31
32impl Config {
33    pub fn from_env() -> Result<Self> {
34        Self::from_lookup(|key| std::env::var(key).ok())
35    }
36
37    /// A configuration with every limit at its default. Used where the limits
38    /// are not the subject - the router built for tests, for instance - so the
39    /// defaults live in one place rather than being restated.
40    pub fn defaults_for_database(database_url: String) -> Self {
41        Self {
42            addr: DEFAULT_ADDR.parse().expect("the default address is valid"),
43            database_url,
44            agents: String::new(),
45            max_batch_days: DEFAULT_MAX_BATCH_DAYS,
46            max_body_bytes: DEFAULT_MAX_BODY_BYTES,
47        }
48    }
49
50    /// The environment is passed in as a lookup so tests can supply their own.
51    fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
52        let addr = lookup("KASL_SERVER_ADDR").unwrap_or_else(|| DEFAULT_ADDR.to_string());
53        let addr = addr
54            .parse()
55            .with_context(|| format!("KASL_SERVER_ADDR is not a valid socket address: {addr}"))?;
56        let database_url = lookup("DATABASE_URL").context("DATABASE_URL is not set (e.g. postgres://kasl:kasl@localhost:5432/kasl)")?;
57        let agents = lookup("KASL_AGENTS").unwrap_or_default();
58        let max_batch_days = positive("KASL_MAX_BATCH_DAYS", &lookup, DEFAULT_MAX_BATCH_DAYS)?;
59        let max_body_bytes = positive("KASL_MAX_BODY_BYTES", &lookup, DEFAULT_MAX_BODY_BYTES)?;
60        Ok(Self {
61            addr,
62            database_url,
63            agents,
64            max_batch_days,
65            max_body_bytes,
66        })
67    }
68}
69
70/// Reads a limit, refusing zero: a limit of nothing accepts nothing, and a
71/// server that silently rejects every upload is worse than one that will not
72/// start.
73fn positive(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: usize) -> Result<usize> {
74    let Some(raw) = lookup(key) else { return Ok(default) };
75    let value: usize = raw.parse().with_context(|| format!("{key} is not a positive whole number: {raw}"))?;
76    if value == 0 {
77        anyhow::bail!("{key} must be greater than zero");
78    }
79    Ok(value)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
87        move |key| pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
88    }
89
90    #[test]
91    fn defaults_the_bind_address() {
92        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).expect("config should build with only DATABASE_URL set");
93        assert_eq!(config.addr, DEFAULT_ADDR.parse().unwrap());
94        assert_eq!(config.database_url, "postgres://localhost/kasl");
95    }
96
97    #[test]
98    fn reads_the_bind_address_override() {
99        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "127.0.0.1:9090")]))
100            .expect("config should accept a valid override");
101        assert_eq!(config.addr, "127.0.0.1:9090".parse().unwrap());
102    }
103
104    #[test]
105    fn requires_database_url() {
106        let error = Config::from_lookup(env(&[])).unwrap_err();
107        assert!(error.to_string().contains("DATABASE_URL"));
108    }
109
110    #[test]
111    fn limits_have_defaults_and_can_be_overridden() {
112        let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
113        assert_eq!(config.max_batch_days, DEFAULT_MAX_BATCH_DAYS);
114        assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
115
116        let config = Config::from_lookup(env(&[
117            ("DATABASE_URL", "postgres://localhost/kasl"),
118            ("KASL_MAX_BATCH_DAYS", "7"),
119            ("KASL_MAX_BODY_BYTES", "1048576"),
120        ]))
121        .unwrap();
122        assert_eq!(config.max_batch_days, 7);
123        assert_eq!(config.max_body_bytes, 1048576);
124    }
125
126    #[test]
127    fn a_limit_of_zero_is_refused() {
128        // Zero accepts nothing. A server that silently rejects every upload is
129        // worse than one that refuses to start and says why.
130        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BATCH_DAYS", "0")])).unwrap_err();
131        assert!(error.to_string().contains("KASL_MAX_BATCH_DAYS"), "{error}");
132
133        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BODY_BYTES", "nope")])).unwrap_err();
134        assert!(error.to_string().contains("KASL_MAX_BODY_BYTES"), "{error}");
135    }
136
137    #[test]
138    fn rejects_a_malformed_bind_address() {
139        let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "not-an-address")])).unwrap_err();
140        assert!(error.to_string().contains("KASL_SERVER_ADDR"));
141    }
142}