Skip to main content

doido_cable/
config.rs

1//! Config-driven pub/sub selection for the cable server, read from the `cable`
2//! section of `config/<env>.yml`. Mirrors the `config` modules in
3//! `doido-jobs`/`doido-cache`: callers only ever see an `Arc<dyn PubSub>`.
4//!
5//! ```yaml
6//! cable:
7//!   type: redis           # memory (default) | redis | db
8//!   ping_interval: 3      # heartbeat seconds
9//!   mount_path: /cable
10//!   redis:
11//!     url: redis://127.0.0.1:6379
12//! ```
13
14use crate::pubsub::PubSub;
15use doido_core::{anyhow::anyhow, Environment, Result};
16use serde::Deserialize;
17use std::sync::Arc;
18use std::time::Duration;
19
20/// Which pub/sub backend broadcasts reach subscribers through. YAML key `type`.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Backend {
24    #[default]
25    Memory,
26    Redis,
27    Db,
28}
29
30/// Runtime cable configuration.
31#[derive(Clone, Debug)]
32pub struct CableConfig {
33    pub backend: Backend,
34    /// How often the server sends an ActionCable `ping`.
35    pub ping_interval: Duration,
36    /// Path the cable endpoint is mounted at.
37    pub mount_path: String,
38    pub redis_url: Option<String>,
39}
40
41impl Default for CableConfig {
42    fn default() -> Self {
43        Self {
44            backend: Backend::Memory,
45            ping_interval: Duration::from_secs(3),
46            mount_path: "/cable".to_string(),
47            redis_url: None,
48        }
49    }
50}
51
52/// Build the configured pub/sub backend. The `db` backend needs a live database
53/// connection, so it is built via [`build_configured_pubsub`] instead.
54pub async fn build_pubsub(config: &CableConfig) -> Result<Arc<dyn PubSub>> {
55    match config.backend {
56        Backend::Memory => Ok(Arc::new(crate::pubsub::MemoryPubSub::new())),
57        Backend::Redis => build_redis_pubsub(config).await,
58        Backend::Db => Err(anyhow!(
59            "the `db` cable backend must be built with a database connection via \
60             build_configured_pubsub()"
61        )),
62    }
63}
64
65/// Build the configured pub/sub, connecting the database for the `db` backend
66/// (which [`build_pubsub`] cannot construct on its own). Memory/redis delegate.
67pub async fn build_configured_pubsub(config: &CableConfig) -> Result<Arc<dyn PubSub>> {
68    match config.backend {
69        Backend::Db => build_db_pubsub().await,
70        _ => build_pubsub(config).await,
71    }
72}
73
74#[cfg(feature = "cable-redis")]
75async fn build_redis_pubsub(config: &CableConfig) -> Result<Arc<dyn PubSub>> {
76    let url = config
77        .redis_url
78        .as_deref()
79        .ok_or_else(|| anyhow!("redis cable backend selected but [cable.redis] url is not set"))?;
80    Ok(Arc::new(
81        crate::redis_pubsub::RedisPubSub::connect(url).await?,
82    ))
83}
84
85#[cfg(not(feature = "cable-redis"))]
86async fn build_redis_pubsub(_config: &CableConfig) -> Result<Arc<dyn PubSub>> {
87    Err(anyhow!(
88        "redis cable backend requires building doido-cable with the `cable-redis` feature"
89    ))
90}
91
92#[cfg(feature = "cable-db")]
93async fn build_db_pubsub() -> Result<Arc<dyn PubSub>> {
94    let conn = match doido_model::pool::try_pool() {
95        Some(pool) => pool.clone(),
96        None => doido_model::pool::connect()
97            .await
98            .map_err(|e| anyhow!("cable db backend: could not connect to the database: {e}"))?,
99    };
100    Ok(Arc::new(crate::db_pubsub::DbPubSub::connect(conn).await?))
101}
102
103#[cfg(not(feature = "cable-db"))]
104async fn build_db_pubsub() -> Result<Arc<dyn PubSub>> {
105    Err(anyhow!(
106        "db cable backend requires building doido-cable with the `cable-db` feature"
107    ))
108}
109
110// ── Config-file loading (mirrors `doido-jobs`'s `config`) ────────────────────
111
112#[derive(Debug, Clone, Default, Deserialize)]
113pub struct RedisSettings {
114    #[serde(default)]
115    pub url: Option<String>,
116}
117
118/// Parsed `cable` section, before defaults are applied.
119#[derive(Debug, Clone, Default, Deserialize)]
120pub struct CableSettings {
121    #[serde(default, rename = "type")]
122    pub backend: Backend,
123    #[serde(default)]
124    pub ping_interval: Option<u64>,
125    #[serde(default)]
126    pub mount_path: Option<String>,
127    #[serde(default)]
128    pub redis: Option<RedisSettings>,
129}
130
131impl CableSettings {
132    pub fn into_config(self) -> CableConfig {
133        let d = CableConfig::default();
134        CableConfig {
135            backend: self.backend,
136            ping_interval: self
137                .ping_interval
138                .filter(|s| *s > 0)
139                .map(Duration::from_secs)
140                .unwrap_or(d.ping_interval),
141            mount_path: self.mount_path.unwrap_or(d.mount_path),
142            redis_url: self.redis.and_then(|r| r.url).or(d.redis_url),
143        }
144    }
145}
146
147/// File config deserialized from `config/<env>.yml`; only `cable` is read.
148#[derive(Debug, Clone, Default, Deserialize)]
149pub struct CableFileConfig {
150    #[serde(default)]
151    pub cable: CableSettings,
152}
153
154impl CableFileConfig {
155    pub fn load() -> std::io::Result<Self> {
156        Self::load_env(Environment::get_env())
157    }
158
159    pub fn load_env(env: Environment) -> std::io::Result<Self> {
160        let path = format!("config/{}.yml", env.as_str());
161        let contents = std::fs::read_to_string(&path)?;
162        Self::from_yaml(&contents)
163    }
164
165    pub fn from_yaml(yaml: &str) -> std::io::Result<Self> {
166        serde_norway::from_str(yaml)
167            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
168    }
169}
170
171/// Load the current environment's [`CableConfig`], falling back to the default
172/// (in-memory) when the file is missing or has no `cable` section.
173pub fn load() -> CableConfig {
174    CableFileConfig::load()
175        .map(|c| c.cable.into_config())
176        .unwrap_or_default()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn parses_cable_section() {
185        let yaml = "cable:\n  type: redis\n  ping_interval: 10\n  mount_path: /ws\n  \
186                    redis:\n    url: redis://x:6379\n";
187        let cfg = CableFileConfig::from_yaml(yaml)
188            .unwrap()
189            .cable
190            .into_config();
191        assert_eq!(cfg.backend, Backend::Redis);
192        assert_eq!(cfg.ping_interval, Duration::from_secs(10));
193        assert_eq!(cfg.mount_path, "/ws");
194        assert_eq!(cfg.redis_url.as_deref(), Some("redis://x:6379"));
195    }
196
197    #[test]
198    fn absent_section_falls_back_to_memory_defaults() {
199        let cfg = CableFileConfig::from_yaml("server:\n  port: 3000\n")
200            .unwrap()
201            .cable
202            .into_config();
203        assert_eq!(cfg.backend, Backend::Memory);
204        assert_eq!(cfg.ping_interval, Duration::from_secs(3));
205        assert_eq!(cfg.mount_path, "/cable");
206    }
207}