From f9ffc21a3f69dc17637654d918eb6683345835c7 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Sat, 8 Mar 2025 22:07:57 +0100 Subject: [PATCH] feat: added flexible configuration system using figment --- CHANGELOG.md | 2 + Cargo.lock | 87 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 21 ++++++++++ otter.toml | 3 ++ src/cli/db.rs | 12 +++--- src/cli/mod.rs | 64 +++++++++++++++++++++++----- src/cli/serve.rs | 80 ++++++++++++----------------------- src/config.rs | 25 +++++++++++ src/main.rs | 1 + src/server/gpodder/mod.rs | 5 +-- 11 files changed, 227 insertions(+), 74 deletions(-) create mode 100644 otter.toml create mode 100644 src/config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 74aaa2f..49f0d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * subscriptions * episode changes * devices +* Flexible configuration via a TOML config file, environment variables or CLI + arguments diff --git a/Cargo.lock b/Cargo.lock index d178dd0..67158dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ dependencies = [ "password-hash", ] +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -246,6 +255,12 @@ version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +[[package]] +name = "bytemuck" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" + [[package]] name = "byteorder" version = "1.5.0" @@ -496,6 +511,20 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + [[package]] name = "fnv" version = "1.0.7" @@ -727,6 +756,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -899,6 +934,7 @@ dependencies = [ "cookie", "diesel", "diesel_migrations", + "figment", "libsqlite3-sys", "rand", "serde", @@ -948,6 +984,29 @@ dependencies = [ "subtle", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -996,6 +1055,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.38" @@ -1443,6 +1515,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.17" @@ -1659,6 +1740,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index 057325c..73dc6b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ clap = { version = "4.5.30", features = ["derive", "env"] } cookie = "0.18.1" diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } +figment = { version = "0.10.19", features = ["env", "toml"] } libsqlite3-sys = { version = "0.31.0", features = ["bundled"] } rand = "0.8.5" serde = { version = "1.0.218", features = ["derive"] } diff --git a/README.md b/README.md index 34f0484..50497b6 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,24 @@ Gpodder, suitable for low-power devices. * Devices API * Subscriptions API * Episode actions API + +## Configuration + +Otter is configurable via a TOML config file, environment variables and CLI +arguments thanks to the [Figment](https://docs.rs/figment/latest/figment/) +library. Each variable in the configuration file below can be provided via an +environment variable (prefixed using `OTTER_`) or a CLI argument. + +```toml +data_dir = "./data" +domain = "127.0.0.1" +port = 8080 +``` + +Otter can be told to read a config variable using the `--config/-c` flag (see +the `help` command for other useful CLI commands). Each variable needs to be +provided either from the config file, as an environment variable (e.g. +`OTTER_DATA_DIR`) or a CLI argument (e.g. `--data-dir`). + +If a variable is provided in multiple ways, environment variables overwrite +configuration file variables, and CLI arguments overwrite both. diff --git a/otter.toml b/otter.toml new file mode 100644 index 0000000..1f93f85 --- /dev/null +++ b/otter.toml @@ -0,0 +1,3 @@ +data_dir = "./data" +domain = "127.0.0.1" +port = 8080 diff --git a/src/cli/db.rs b/src/cli/db.rs index 23c5122..bee87ec 100644 --- a/src/cli/db.rs +++ b/src/cli/db.rs @@ -16,16 +16,16 @@ pub enum AddCommand { } impl DbCommand { - pub fn run(&self, cli: &super::Cli) -> u8 { + pub fn run(&self, config: &crate::config::Config) -> u8 { match self { - DbCommand::Add(cmd) => cmd.run(cli), + DbCommand::Add(cmd) => cmd.run(config), } } } impl AddCommand { - pub fn run(&self, cli: &super::Cli) -> u8 { - match self.run_err(cli) { + pub fn run(&self, config: &crate::config::Config) -> u8 { + match self.run_err(config) { Ok(()) => 0, Err(err) => { eprintln!("An error occured: {}", err.stack()); @@ -35,8 +35,8 @@ impl AddCommand { } } - pub fn run_err(&self, cli: &super::Cli) -> DbResult<()> { - let pool = crate::db::initialize_db(cli.data_dir.join(crate::DB_FILENAME), false)?; + pub fn run_err(&self, config: &crate::config::Config) -> DbResult<()> { + let pool = crate::db::initialize_db(config.data_dir.join(crate::DB_FILENAME), false)?; match self { Self::User { username, password } => { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d2beb90..ee50121 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,36 +3,78 @@ mod serve; use std::path::PathBuf; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; +use figment::{ + providers::{Env, Format, Serialized, Toml}, + Figment, +}; +use serde::Serialize; /// Otter is a lightweight implementation of the Gpodder API, designed to be used for small /// personal deployments. #[derive(Parser)] pub struct Cli { - #[arg( - long = "data", - default_value = "./data", - value_name = "DATA_DIR", - env = "OTTER_DATA_DIR" - )] - pub data_dir: PathBuf, + #[command(flatten)] + pub config: ClapConfig, #[command(subcommand)] pub cmd: Command, } +#[derive(Serialize, Args, Clone)] +pub struct ClapConfig { + #[arg( + short, + long = "config", + env = "OTTER_CONFIG_FILE", + value_name = "CONFIG_FILE" + )] + config_file: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[arg(long = "data", value_name = "DATA_DIR")] + data_dir: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[arg(short, long, value_name = "DOMAIN")] + domain: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[arg(short, long, value_name = "PORT")] + port: Option, +} + #[derive(Subcommand)] pub enum Command { - Serve(serve::ServeCommand), + Serve, #[command(subcommand)] Db(db::DbCommand), } impl Cli { pub fn run(&self) -> u8 { + let mut figment = Figment::new(); + + if let Some(config_path) = &self.config.config_file { + figment = figment.merge(Toml::file(config_path)); + } + + let config: crate::config::Config = match figment + .merge(Env::prefixed("OTTER_")) + .merge(Serialized::defaults(self.config.clone())) + .extract() + { + Ok(config) => config, + Err(err) => { + eprintln!("{}", err); + + return 1; + } + }; + match &self.cmd { - Command::Serve(cmd) => cmd.run(self), - Command::Db(cmd) => cmd.run(self), + Command::Serve => serve::serve(&config), + Command::Db(cmd) => cmd.run(&config), } } } diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 310186d..a0ff0fe 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,57 +1,31 @@ -use clap::Args; - use crate::{db, server}; -/// Run the Otter web server -#[derive(Args)] -pub struct ServeCommand { - #[arg( - short, - long, - default_value = "127.0.0.1", - value_name = "DOMAIN", - env = "OTTER_DOMAIN" - )] - domain: String, +pub fn serve(config: &crate::config::Config) -> u8 { + tracing_subscriber::fmt::init(); - #[arg( - short, - long, - default_value = "8080", - value_name = "PORT", - env = "OTTER_PORT" - )] - port: u16, -} - -impl ServeCommand { - pub fn run(&self, cli: &super::Cli) -> u8 { - tracing_subscriber::fmt::init(); - - tracing::info!("Initializing database and running migrations"); - - let pool = db::initialize_db(cli.data_dir.join(crate::DB_FILENAME), true).unwrap(); - - let ctx = server::Context { - repo: db::SqliteRepository::from(pool), - }; - let app = server::app(ctx); - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap(); - - let address = format!("{}:{}", self.domain, self.port); - tracing::info!("Starting server on {address}"); - - rt.block_on(async { - let listener = tokio::net::TcpListener::bind(address).await.unwrap(); - axum::serve(listener, app.into_make_service()) - .await - .unwrap() - }); - - 0 - } + tracing::info!("Initializing database and running migrations"); + + let pool = db::initialize_db(config.data_dir.join(crate::DB_FILENAME), true).unwrap(); + + let ctx = server::Context { + repo: db::SqliteRepository::from(pool), + }; + let app = server::app(ctx); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + + let address = format!("{}:{}", config.domain, config.port); + tracing::info!("Starting server on {address}"); + + rt.block_on(async { + let listener = tokio::net::TcpListener::bind(address).await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap() + }); + + 0 } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0f7df52 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, + #[serde(default = "default_domain")] + pub domain: String, + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_data_dir() -> PathBuf { + PathBuf::from("./data") +} + +fn default_domain() -> String { + "127.0.0.1".to_string() +} + +fn default_port() -> u16 { + 8080 +} diff --git a/src/main.rs b/src/main.rs index b418992..2e17a7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod cli; +mod config; mod db; mod gpodder; mod server; diff --git a/src/server/gpodder/mod.rs b/src/server/gpodder/mod.rs index 7850dd3..e98b82b 100644 --- a/src/server/gpodder/mod.rs +++ b/src/server/gpodder/mod.rs @@ -11,10 +11,7 @@ use axum::{ RequestExt, Router, }; use axum_extra::{ - extract::{ - cookie::{Cookie, Expiration}, - CookieJar, - }, + extract::{cookie::Cookie, CookieJar}, headers::{authorization::Basic, Authorization}, TypedHeader, };