feat: added flexible configuration system using figment

main
Jef Roosens 2025-03-08 22:07:57 +01:00
parent f16cdfdfff
commit f9ffc21a3f
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
11 changed files with 227 additions and 74 deletions

View File

@ -14,3 +14,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* subscriptions * subscriptions
* episode changes * episode changes
* devices * devices
* Flexible configuration via a TOML config file, environment variables or CLI
arguments

87
Cargo.lock generated
View File

@ -94,6 +94,15 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "atomic"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.4.0" version = "1.4.0"
@ -246,6 +255,12 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "bytemuck"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@ -496,6 +511,20 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -727,6 +756,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inlinable_string"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@ -899,6 +934,7 @@ dependencies = [
"cookie", "cookie",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"figment",
"libsqlite3-sys", "libsqlite3-sys",
"rand", "rand",
"serde", "serde",
@ -948,6 +984,29 @@ dependencies = [
"subtle", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@ -996,6 +1055,19 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.38" version = "1.0.38"
@ -1443,6 +1515,15 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.17" version = "1.0.17"
@ -1659,6 +1740,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.7.35" version = "0.7.35"

View File

@ -12,6 +12,7 @@ clap = { version = "4.5.30", features = ["derive", "env"] }
cookie = "0.18.1" cookie = "0.18.1"
diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] } diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = { version = "2.2.0", features = ["sqlite"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
figment = { version = "0.10.19", features = ["env", "toml"] }
libsqlite3-sys = { version = "0.31.0", features = ["bundled"] } libsqlite3-sys = { version = "0.31.0", features = ["bundled"] }
rand = "0.8.5" rand = "0.8.5"
serde = { version = "1.0.218", features = ["derive"] } serde = { version = "1.0.218", features = ["derive"] }

View File

@ -11,3 +11,24 @@ Gpodder, suitable for low-power devices.
* Devices API * Devices API
* Subscriptions API * Subscriptions API
* Episode actions 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.

3
otter.toml 100644
View File

@ -0,0 +1,3 @@
data_dir = "./data"
domain = "127.0.0.1"
port = 8080

View File

@ -16,16 +16,16 @@ pub enum AddCommand {
} }
impl DbCommand { impl DbCommand {
pub fn run(&self, cli: &super::Cli) -> u8 { pub fn run(&self, config: &crate::config::Config) -> u8 {
match self { match self {
DbCommand::Add(cmd) => cmd.run(cli), DbCommand::Add(cmd) => cmd.run(config),
} }
} }
} }
impl AddCommand { impl AddCommand {
pub fn run(&self, cli: &super::Cli) -> u8 { pub fn run(&self, config: &crate::config::Config) -> u8 {
match self.run_err(cli) { match self.run_err(config) {
Ok(()) => 0, Ok(()) => 0,
Err(err) => { Err(err) => {
eprintln!("An error occured: {}", err.stack()); eprintln!("An error occured: {}", err.stack());
@ -35,8 +35,8 @@ impl AddCommand {
} }
} }
pub fn run_err(&self, cli: &super::Cli) -> DbResult<()> { pub fn run_err(&self, config: &crate::config::Config) -> DbResult<()> {
let pool = crate::db::initialize_db(cli.data_dir.join(crate::DB_FILENAME), false)?; let pool = crate::db::initialize_db(config.data_dir.join(crate::DB_FILENAME), false)?;
match self { match self {
Self::User { username, password } => { Self::User { username, password } => {

View File

@ -3,36 +3,78 @@ mod serve;
use std::path::PathBuf; 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 /// Otter is a lightweight implementation of the Gpodder API, designed to be used for small
/// personal deployments. /// personal deployments.
#[derive(Parser)] #[derive(Parser)]
pub struct Cli { pub struct Cli {
#[arg( #[command(flatten)]
long = "data", pub config: ClapConfig,
default_value = "./data",
value_name = "DATA_DIR",
env = "OTTER_DATA_DIR"
)]
pub data_dir: PathBuf,
#[command(subcommand)] #[command(subcommand)]
pub cmd: Command, 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<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long = "data", value_name = "DATA_DIR")]
data_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long, value_name = "DOMAIN")]
domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long, value_name = "PORT")]
port: Option<u16>,
}
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
Serve(serve::ServeCommand), Serve,
#[command(subcommand)] #[command(subcommand)]
Db(db::DbCommand), Db(db::DbCommand),
} }
impl Cli { impl Cli {
pub fn run(&self) -> u8 { 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 { match &self.cmd {
Command::Serve(cmd) => cmd.run(self), Command::Serve => serve::serve(&config),
Command::Db(cmd) => cmd.run(self), Command::Db(cmd) => cmd.run(&config),
} }
} }
} }

View File

@ -1,57 +1,31 @@
use clap::Args;
use crate::{db, server}; use crate::{db, server};
/// Run the Otter web server pub fn serve(config: &crate::config::Config) -> u8 {
#[derive(Args)] tracing_subscriber::fmt::init();
pub struct ServeCommand {
#[arg(
short,
long,
default_value = "127.0.0.1",
value_name = "DOMAIN",
env = "OTTER_DOMAIN"
)]
domain: String,
#[arg( tracing::info!("Initializing database and running migrations");
short,
long, let pool = db::initialize_db(config.data_dir.join(crate::DB_FILENAME), true).unwrap();
default_value = "8080",
value_name = "PORT", let ctx = server::Context {
env = "OTTER_PORT" repo: db::SqliteRepository::from(pool),
)] };
port: u16, let app = server::app(ctx);
}
let rt = tokio::runtime::Builder::new_multi_thread()
impl ServeCommand { .enable_all()
pub fn run(&self, cli: &super::Cli) -> u8 { .build()
tracing_subscriber::fmt::init(); .unwrap();
tracing::info!("Initializing database and running migrations"); let address = format!("{}:{}", config.domain, config.port);
tracing::info!("Starting server on {address}");
let pool = db::initialize_db(cli.data_dir.join(crate::DB_FILENAME), true).unwrap();
rt.block_on(async {
let ctx = server::Context { let listener = tokio::net::TcpListener::bind(address).await.unwrap();
repo: db::SqliteRepository::from(pool), axum::serve(listener, app.into_make_service())
}; .await
let app = server::app(ctx); .unwrap()
});
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all() 0
.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
}
} }

25
src/config.rs 100644
View File

@ -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
}

View File

@ -1,4 +1,5 @@
mod cli; mod cli;
mod config;
mod db; mod db;
mod gpodder; mod gpodder;
mod server; mod server;

View File

@ -11,10 +11,7 @@ use axum::{
RequestExt, Router, RequestExt, Router,
}; };
use axum_extra::{ use axum_extra::{
extract::{ extract::{cookie::Cookie, CookieJar},
cookie::{Cookie, Expiration},
CookieJar,
},
headers::{authorization::Basic, Authorization}, headers::{authorization::Basic, Authorization},
TypedHeader, TypedHeader,
}; };