use std::net::SocketAddr;
use clap::{Args, Parser, builder::BoolishValueParser};
use crate::ServerConfig;
fn parse_bool_env_opt(var: &str) -> Option<bool> {
std::env::var(var)
.ok()
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on"))
}
#[derive(Parser, Debug, Clone)]
#[command(name = "fraiseql-server", version, about)]
pub struct Cli {
#[command(flatten)]
pub server: ServerArgs,
#[cfg(feature = "mcp")]
#[arg(long, env = "FRAISEQL_MCP_STDIO", hide = true)]
pub mcp_stdio: Option<String>,
}
#[derive(Args, Debug, Clone, Default)]
pub struct ServerArgs {
#[arg(long, env = "FRAISEQL_CONFIG")]
pub config: Option<String>,
#[arg(long, env = "DATABASE_URL")]
pub database_url: Option<String>,
#[arg(long, env = "FRAISEQL_BIND_ADDR")]
pub bind_addr: Option<SocketAddr>,
#[arg(long, env = "FRAISEQL_SCHEMA_PATH")]
pub schema_path: Option<String>,
#[arg(long, env = "FRAISEQL_METRICS_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
pub metrics_enabled: Option<bool>,
#[arg(long, env = "FRAISEQL_METRICS_TOKEN")]
pub metrics_token: Option<String>,
#[arg(long, env = "FRAISEQL_ADMIN_API_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
pub admin_api_enabled: Option<bool>,
#[arg(long, env = "FRAISEQL_ADMIN_TOKEN")]
pub admin_token: Option<String>,
#[arg(long, env = "FRAISEQL_INTROSPECTION_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
pub introspection_enabled: Option<bool>,
#[arg(long, env = "FRAISEQL_INTROSPECTION_REQUIRE_AUTH", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
pub introspection_require_auth: Option<bool>,
#[arg(long, env = "FRAISEQL_RATE_LIMITING_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
pub rate_limiting_enabled: Option<bool>,
#[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_IP")]
pub rate_limit_rps_per_ip: Option<u32>,
#[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_USER")]
pub rate_limit_rps_per_user: Option<u32>,
#[arg(long, env = "FRAISEQL_RATE_LIMIT_BURST_SIZE")]
pub rate_limit_burst_size: Option<u32>,
#[arg(long, env = "FRAISEQL_LOG_FORMAT")]
pub log_format: Option<String>,
}
impl ServerArgs {
pub fn from_env() -> Self {
Self {
config: std::env::var("FRAISEQL_CONFIG").ok(),
database_url: std::env::var("DATABASE_URL").ok(),
bind_addr: std::env::var("FRAISEQL_BIND_ADDR")
.ok()
.and_then(|v| v.parse().ok()),
schema_path: std::env::var("FRAISEQL_SCHEMA_PATH").ok(),
metrics_enabled: parse_bool_env_opt("FRAISEQL_METRICS_ENABLED"),
metrics_token: std::env::var("FRAISEQL_METRICS_TOKEN").ok(),
admin_api_enabled: parse_bool_env_opt("FRAISEQL_ADMIN_API_ENABLED"),
admin_token: std::env::var("FRAISEQL_ADMIN_TOKEN").ok(),
introspection_enabled: parse_bool_env_opt("FRAISEQL_INTROSPECTION_ENABLED"),
introspection_require_auth: parse_bool_env_opt("FRAISEQL_INTROSPECTION_REQUIRE_AUTH"),
rate_limiting_enabled: parse_bool_env_opt("FRAISEQL_RATE_LIMITING_ENABLED"),
rate_limit_rps_per_ip: std::env::var("FRAISEQL_RATE_LIMIT_RPS_PER_IP")
.ok()
.and_then(|v| v.parse().ok()),
rate_limit_rps_per_user: std::env::var("FRAISEQL_RATE_LIMIT_RPS_PER_USER")
.ok()
.and_then(|v| v.parse().ok()),
rate_limit_burst_size: std::env::var("FRAISEQL_RATE_LIMIT_BURST_SIZE")
.ok()
.and_then(|v| v.parse().ok()),
log_format: std::env::var("FRAISEQL_LOG_FORMAT").ok(),
}
}
pub fn apply_to_config(&self, config: &mut ServerConfig) {
if let Some(ref db_url) = self.database_url {
config.database_url.clone_from(db_url);
}
if let Some(addr) = self.bind_addr {
config.bind_addr = addr;
}
if let Some(ref path) = self.schema_path {
config.schema_path = path.into();
}
if let Some(enabled) = self.metrics_enabled {
config.metrics_enabled = enabled;
}
if self.metrics_token.is_some() {
config.metrics_token.clone_from(&self.metrics_token);
}
if let Some(enabled) = self.admin_api_enabled {
config.admin_api_enabled = enabled;
}
if self.admin_token.is_some() {
config.admin_token.clone_from(&self.admin_token);
}
if let Some(enabled) = self.introspection_enabled {
config.introspection_enabled = enabled;
}
if let Some(require_auth) = self.introspection_require_auth {
config.introspection_require_auth = require_auth;
}
self.apply_rate_limit_overrides(config);
}
fn apply_rate_limit_overrides(&self, config: &mut ServerConfig) {
if self.rate_limiting_enabled.is_none()
&& self.rate_limit_rps_per_ip.is_none()
&& self.rate_limit_rps_per_user.is_none()
&& self.rate_limit_burst_size.is_none()
{
return;
}
let mut rate_config = config.rate_limiting.take().unwrap_or_default();
if let Some(enabled) = self.rate_limiting_enabled {
rate_config.enabled = enabled;
}
if let Some(v) = self.rate_limit_rps_per_ip {
rate_config.rps_per_ip = v;
}
if let Some(v) = self.rate_limit_rps_per_user {
rate_config.rps_per_user = v;
}
if let Some(v) = self.rate_limit_burst_size {
rate_config.burst_size = v;
}
config.rate_limiting = Some(rate_config);
}
pub fn is_json_log_format(&self) -> bool {
self.log_format.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("json"))
}
}
#[allow(clippy::unwrap_used)] #[allow(clippy::field_reassign_with_default)] #[cfg(test)]
mod tests {
use super::*;
use crate::middleware::RateLimitConfig;
#[test]
fn cli_parse_config_flag() {
let cli = Cli::parse_from(["fraiseql-server", "--config", "/etc/fraiseql.toml"]);
assert_eq!(cli.server.config.as_deref(), Some("/etc/fraiseql.toml"));
}
#[test]
fn cli_parse_database_url_flag() {
let cli = Cli::parse_from([
"fraiseql-server",
"--database-url",
"postgres://localhost/db",
]);
assert_eq!(cli.server.database_url.as_deref(), Some("postgres://localhost/db"));
}
#[test]
fn cli_parse_bind_addr_flag() {
let cli = Cli::parse_from(["fraiseql-server", "--bind-addr", "127.0.0.1:3000"]);
assert_eq!(cli.server.bind_addr, Some("127.0.0.1:3000".parse().unwrap()));
}
#[test]
fn cli_defaults_are_none_when_no_flags_or_env() {
let cli = Cli::parse_from(["fraiseql-server"]);
assert!(cli.server.config.is_none());
assert!(cli.server.schema_path.is_none());
assert!(cli.server.metrics_token.is_none());
assert!(cli.server.admin_token.is_none());
}
#[test]
fn cli_parse_bool_flag_with_value() {
let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled", "true"]);
assert_eq!(cli.server.metrics_enabled, Some(true));
let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled", "false"]);
assert_eq!(cli.server.metrics_enabled, Some(false));
}
#[test]
fn cli_parse_bool_flag_without_value() {
let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled"]);
assert_eq!(cli.server.metrics_enabled, Some(true));
}
#[test]
fn cli_parse_rate_limit_flags() {
let cli = Cli::parse_from([
"fraiseql-server",
"--rate-limit-rps-per-ip",
"200",
"--rate-limit-burst-size",
"1000",
]);
assert_eq!(cli.server.rate_limit_rps_per_ip, Some(200));
assert_eq!(cli.server.rate_limit_burst_size, Some(1000));
assert!(cli.server.rate_limit_rps_per_user.is_none());
}
#[test]
fn cli_parse_log_format() {
let cli = Cli::parse_from(["fraiseql-server", "--log-format", "json"]);
assert_eq!(cli.server.log_format.as_deref(), Some("json"));
assert!(cli.server.is_json_log_format());
}
#[test]
fn apply_overrides_database_url() {
let args = ServerArgs {
database_url: Some("postgres://override/db".into()),
..Default::default()
};
let mut config = ServerConfig::default();
args.apply_to_config(&mut config);
assert_eq!(config.database_url, "postgres://override/db");
}
#[test]
fn apply_leaves_config_unchanged_when_no_overrides() {
let args = ServerArgs::default();
let mut config = ServerConfig::default();
let original_db = config.database_url.clone();
let original_addr = config.bind_addr;
args.apply_to_config(&mut config);
assert_eq!(config.database_url, original_db);
assert_eq!(config.bind_addr, original_addr);
}
#[test]
fn apply_metrics_enabled_override() {
let args = ServerArgs {
metrics_enabled: Some(true),
..Default::default()
};
let mut config = ServerConfig::default();
assert!(!config.metrics_enabled);
args.apply_to_config(&mut config);
assert!(config.metrics_enabled);
}
#[test]
fn apply_rate_limit_creates_config_when_absent() {
let args = ServerArgs {
rate_limit_rps_per_ip: Some(50),
..Default::default()
};
let mut config = ServerConfig::default();
config.rate_limiting = None;
args.apply_to_config(&mut config);
let rl = config.rate_limiting.unwrap();
assert_eq!(rl.rps_per_ip, 50);
assert!(rl.enabled);
assert_eq!(rl.burst_size, 500);
}
#[test]
fn apply_rate_limit_preserves_existing_fields() {
let args = ServerArgs {
rate_limit_burst_size: Some(999),
..Default::default()
};
let mut config = ServerConfig::default();
config.rate_limiting = Some(RateLimitConfig {
enabled: true,
rps_per_ip: 42,
rps_per_user: 420,
burst_size: 100,
cleanup_interval_secs: 60,
trust_proxy_headers: true,
trusted_proxy_cidrs: Vec::new(),
});
args.apply_to_config(&mut config);
let rl = config.rate_limiting.unwrap();
assert_eq!(rl.burst_size, 999);
assert_eq!(rl.rps_per_ip, 42);
assert_eq!(rl.rps_per_user, 420);
assert!(rl.trust_proxy_headers);
}
#[test]
fn apply_introspection_overrides() {
let args = ServerArgs {
introspection_enabled: Some(true),
introspection_require_auth: Some(false),
..Default::default()
};
let mut config = ServerConfig::default();
args.apply_to_config(&mut config);
assert!(config.introspection_enabled);
assert!(!config.introspection_require_auth);
}
#[test]
fn is_json_log_format_case_insensitive() {
let args = ServerArgs {
log_format: Some("JSON".into()),
..Default::default()
};
assert!(args.is_json_log_format());
let args = ServerArgs {
log_format: Some("text".into()),
..Default::default()
};
assert!(!args.is_json_log_format());
let args = ServerArgs::default();
assert!(!args.is_json_log_format());
}
}