1use std::net::SocketAddr;
14
15use clap::{Args, Parser, builder::BoolishValueParser};
16
17use crate::ServerConfig;
18
19fn parse_bool_env_opt(var: &str) -> Option<bool> {
24 std::env::var(var)
25 .ok()
26 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on"))
27}
28
29#[derive(Parser, Debug, Clone)]
33#[command(name = "fraiseql-server", version, about)]
34pub struct Cli {
35 #[command(flatten)]
37 pub server: ServerArgs,
38
39 #[cfg(feature = "mcp")]
44 #[arg(long, env = "FRAISEQL_MCP_STDIO", hide = true)]
45 pub mcp_stdio: Option<String>,
46}
47
48#[derive(Args, Debug, Clone, Default)]
57pub struct ServerArgs {
58 #[arg(long, env = "FRAISEQL_CONFIG")]
61 pub config: Option<String>,
62
63 #[arg(long, env = "DATABASE_URL")]
65 pub database_url: Option<String>,
66
67 #[arg(long, env = "FRAISEQL_BIND_ADDR")]
69 pub bind_addr: Option<SocketAddr>,
70
71 #[arg(long, env = "FRAISEQL_SCHEMA_PATH")]
73 pub schema_path: Option<String>,
74
75 #[arg(long, env = "FRAISEQL_METRICS_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
78 pub metrics_enabled: Option<bool>,
79
80 #[arg(long, env = "FRAISEQL_METRICS_TOKEN")]
82 pub metrics_token: Option<String>,
83
84 #[arg(long, env = "FRAISEQL_ADMIN_API_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
87 pub admin_api_enabled: Option<bool>,
88
89 #[arg(long, env = "FRAISEQL_ADMIN_TOKEN")]
91 pub admin_token: Option<String>,
92
93 #[arg(long, env = "FRAISEQL_INTROSPECTION_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
96 pub introspection_enabled: Option<bool>,
97
98 #[arg(long, env = "FRAISEQL_INTROSPECTION_REQUIRE_AUTH", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
100 pub introspection_require_auth: Option<bool>,
101
102 #[arg(long, env = "FRAISEQL_RATE_LIMITING_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
105 pub rate_limiting_enabled: Option<bool>,
106
107 #[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_IP")]
109 pub rate_limit_rps_per_ip: Option<u32>,
110
111 #[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_USER")]
113 pub rate_limit_rps_per_user: Option<u32>,
114
115 #[arg(long, env = "FRAISEQL_RATE_LIMIT_BURST_SIZE")]
117 pub rate_limit_burst_size: Option<u32>,
118
119 #[arg(long, env = "FRAISEQL_LOG_FORMAT")]
123 pub log_format: Option<String>,
124}
125
126impl ServerArgs {
127 pub fn from_env() -> Self {
136 Self {
137 config: std::env::var("FRAISEQL_CONFIG").ok(),
138 database_url: std::env::var("DATABASE_URL").ok(),
139 bind_addr: std::env::var("FRAISEQL_BIND_ADDR")
140 .ok()
141 .and_then(|v| v.parse().ok()),
142 schema_path: std::env::var("FRAISEQL_SCHEMA_PATH").ok(),
143 metrics_enabled: parse_bool_env_opt("FRAISEQL_METRICS_ENABLED"),
144 metrics_token: std::env::var("FRAISEQL_METRICS_TOKEN").ok(),
145 admin_api_enabled: parse_bool_env_opt("FRAISEQL_ADMIN_API_ENABLED"),
146 admin_token: std::env::var("FRAISEQL_ADMIN_TOKEN").ok(),
147 introspection_enabled: parse_bool_env_opt("FRAISEQL_INTROSPECTION_ENABLED"),
148 introspection_require_auth: parse_bool_env_opt("FRAISEQL_INTROSPECTION_REQUIRE_AUTH"),
149 rate_limiting_enabled: parse_bool_env_opt("FRAISEQL_RATE_LIMITING_ENABLED"),
150 rate_limit_rps_per_ip: std::env::var("FRAISEQL_RATE_LIMIT_RPS_PER_IP")
151 .ok()
152 .and_then(|v| v.parse().ok()),
153 rate_limit_rps_per_user: std::env::var("FRAISEQL_RATE_LIMIT_RPS_PER_USER")
154 .ok()
155 .and_then(|v| v.parse().ok()),
156 rate_limit_burst_size: std::env::var("FRAISEQL_RATE_LIMIT_BURST_SIZE")
157 .ok()
158 .and_then(|v| v.parse().ok()),
159 log_format: std::env::var("FRAISEQL_LOG_FORMAT").ok(),
160 }
161 }
162
163 pub fn apply_to_config(&self, config: &mut ServerConfig) {
169 if let Some(ref db_url) = self.database_url {
171 config.database_url.clone_from(db_url);
172 }
173 if let Some(addr) = self.bind_addr {
174 config.bind_addr = addr;
175 }
176 if let Some(ref path) = self.schema_path {
177 config.schema_path = path.into();
178 }
179
180 if let Some(enabled) = self.metrics_enabled {
182 config.metrics_enabled = enabled;
183 }
184 if self.metrics_token.is_some() {
185 config.metrics_token.clone_from(&self.metrics_token);
186 }
187
188 if let Some(enabled) = self.admin_api_enabled {
190 config.admin_api_enabled = enabled;
191 }
192 if self.admin_token.is_some() {
193 config.admin_token.clone_from(&self.admin_token);
194 }
195
196 if let Some(enabled) = self.introspection_enabled {
198 config.introspection_enabled = enabled;
199 }
200 if let Some(require_auth) = self.introspection_require_auth {
201 config.introspection_require_auth = require_auth;
202 }
203
204 self.apply_rate_limit_overrides(config);
206 }
207
208 fn apply_rate_limit_overrides(&self, config: &mut ServerConfig) {
210 if self.rate_limiting_enabled.is_none()
211 && self.rate_limit_rps_per_ip.is_none()
212 && self.rate_limit_rps_per_user.is_none()
213 && self.rate_limit_burst_size.is_none()
214 {
215 return;
216 }
217
218 let mut rate_config = config.rate_limiting.take().unwrap_or_default();
219
220 if let Some(enabled) = self.rate_limiting_enabled {
221 rate_config.enabled = enabled;
222 }
223 if let Some(v) = self.rate_limit_rps_per_ip {
224 rate_config.rps_per_ip = v;
225 }
226 if let Some(v) = self.rate_limit_rps_per_user {
227 rate_config.rps_per_user = v;
228 }
229 if let Some(v) = self.rate_limit_burst_size {
230 rate_config.burst_size = v;
231 }
232
233 config.rate_limiting = Some(rate_config);
234 }
235
236 pub fn is_json_log_format(&self) -> bool {
238 self.log_format.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("json"))
239 }
240}
241
242#[allow(clippy::unwrap_used)] #[allow(clippy::field_reassign_with_default)] #[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::middleware::RateLimitConfig;
248
249 #[test]
252 fn cli_parse_config_flag() {
253 let cli = Cli::parse_from(["fraiseql-server", "--config", "/etc/fraiseql.toml"]);
254 assert_eq!(cli.server.config.as_deref(), Some("/etc/fraiseql.toml"));
255 }
256
257 #[test]
258 fn cli_parse_database_url_flag() {
259 let cli = Cli::parse_from([
260 "fraiseql-server",
261 "--database-url",
262 "postgres://localhost/db",
263 ]);
264 assert_eq!(cli.server.database_url.as_deref(), Some("postgres://localhost/db"));
265 }
266
267 #[test]
268 fn cli_parse_bind_addr_flag() {
269 let cli = Cli::parse_from(["fraiseql-server", "--bind-addr", "127.0.0.1:3000"]);
270 assert_eq!(cli.server.bind_addr, Some("127.0.0.1:3000".parse().unwrap()));
271 }
272
273 #[test]
274 fn cli_defaults_are_none_when_no_flags_or_env() {
275 let cli = Cli::parse_from(["fraiseql-server"]);
278 assert!(cli.server.config.is_none());
282 assert!(cli.server.schema_path.is_none());
283 assert!(cli.server.metrics_token.is_none());
284 assert!(cli.server.admin_token.is_none());
285 }
286
287 #[test]
288 fn cli_parse_bool_flag_with_value() {
289 let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled", "true"]);
290 assert_eq!(cli.server.metrics_enabled, Some(true));
291
292 let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled", "false"]);
293 assert_eq!(cli.server.metrics_enabled, Some(false));
294 }
295
296 #[test]
297 fn cli_parse_bool_flag_without_value() {
298 let cli = Cli::parse_from(["fraiseql-server", "--metrics-enabled"]);
300 assert_eq!(cli.server.metrics_enabled, Some(true));
301 }
302
303 #[test]
304 fn cli_parse_rate_limit_flags() {
305 let cli = Cli::parse_from([
306 "fraiseql-server",
307 "--rate-limit-rps-per-ip",
308 "200",
309 "--rate-limit-burst-size",
310 "1000",
311 ]);
312 assert_eq!(cli.server.rate_limit_rps_per_ip, Some(200));
313 assert_eq!(cli.server.rate_limit_burst_size, Some(1000));
314 assert!(cli.server.rate_limit_rps_per_user.is_none());
315 }
316
317 #[test]
318 fn cli_parse_log_format() {
319 let cli = Cli::parse_from(["fraiseql-server", "--log-format", "json"]);
320 assert_eq!(cli.server.log_format.as_deref(), Some("json"));
321 assert!(cli.server.is_json_log_format());
322 }
323
324 #[test]
327 fn apply_overrides_database_url() {
328 let args = ServerArgs {
329 database_url: Some("postgres://override/db".into()),
330 ..Default::default()
331 };
332 let mut config = ServerConfig::default();
333 args.apply_to_config(&mut config);
334 assert_eq!(config.database_url, "postgres://override/db");
335 }
336
337 #[test]
338 fn apply_leaves_config_unchanged_when_no_overrides() {
339 let args = ServerArgs::default();
340 let mut config = ServerConfig::default();
341 let original_db = config.database_url.clone();
342 let original_addr = config.bind_addr;
343 args.apply_to_config(&mut config);
344 assert_eq!(config.database_url, original_db);
345 assert_eq!(config.bind_addr, original_addr);
346 }
347
348 #[test]
349 fn apply_metrics_enabled_override() {
350 let args = ServerArgs {
351 metrics_enabled: Some(true),
352 ..Default::default()
353 };
354 let mut config = ServerConfig::default();
355 assert!(!config.metrics_enabled);
356 args.apply_to_config(&mut config);
357 assert!(config.metrics_enabled);
358 }
359
360 #[test]
361 fn apply_rate_limit_creates_config_when_absent() {
362 let args = ServerArgs {
363 rate_limit_rps_per_ip: Some(50),
364 ..Default::default()
365 };
366 let mut config = ServerConfig::default();
367 config.rate_limiting = None;
368 args.apply_to_config(&mut config);
369 let rl = config.rate_limiting.unwrap();
370 assert_eq!(rl.rps_per_ip, 50);
371 assert!(rl.enabled);
373 assert_eq!(rl.burst_size, 500);
374 }
375
376 #[test]
377 fn apply_rate_limit_preserves_existing_fields() {
378 let args = ServerArgs {
379 rate_limit_burst_size: Some(999),
380 ..Default::default()
381 };
382 let mut config = ServerConfig::default();
383 config.rate_limiting = Some(RateLimitConfig {
384 enabled: true,
385 rps_per_ip: 42,
386 rps_per_user: 420,
387 burst_size: 100,
388 cleanup_interval_secs: 60,
389 trust_proxy_headers: true,
390 trusted_proxy_cidrs: Vec::new(),
391 });
392 args.apply_to_config(&mut config);
393 let rl = config.rate_limiting.unwrap();
394 assert_eq!(rl.burst_size, 999);
395 assert_eq!(rl.rps_per_ip, 42);
396 assert_eq!(rl.rps_per_user, 420);
397 assert!(rl.trust_proxy_headers);
398 }
399
400 #[test]
401 fn apply_introspection_overrides() {
402 let args = ServerArgs {
403 introspection_enabled: Some(true),
404 introspection_require_auth: Some(false),
405 ..Default::default()
406 };
407 let mut config = ServerConfig::default();
408 args.apply_to_config(&mut config);
409 assert!(config.introspection_enabled);
410 assert!(!config.introspection_require_auth);
411 }
412
413 #[test]
414 fn is_json_log_format_case_insensitive() {
415 let args = ServerArgs {
416 log_format: Some("JSON".into()),
417 ..Default::default()
418 };
419 assert!(args.is_json_log_format());
420
421 let args = ServerArgs {
422 log_format: Some("text".into()),
423 ..Default::default()
424 };
425 assert!(!args.is_json_log_format());
426
427 let args = ServerArgs::default();
428 assert!(!args.is_json_log_format());
429 }
430}