Skip to main content

fraiseql_server/
cli.rs

1//! Clap-based CLI argument parsing for `fraiseql-server`.
2//!
3//! The [`Cli`] struct defines all command-line flags and their corresponding
4//! environment variable fallbacks.  Clap's `env` attribute provides automatic
5//! **CLI flag > env var > default** precedence.
6//!
7//! # Sharing with `fraiseql-cli`
8//!
9//! `Cli` is re-exported from `fraiseql_server` so that the `fraiseql run`
10//! subcommand can embed it via `#[command(flatten)]`, eliminating duplicated
11//! env-var handling between the two binaries.
12
13use std::net::SocketAddr;
14
15use clap::{Args, Parser, builder::BoolishValueParser};
16
17use crate::ServerConfig;
18
19/// Parse a boolean environment variable, returning `None` if unset.
20///
21/// Accepts `true`, `1`, `yes`, `on` (case-insensitive) as `Some(true)`;
22/// all other values as `Some(false)`.
23fn 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// ── Top-level CLI ────────────────────────────────────────────────────────────
30
31/// FraiseQL Server — compiled GraphQL execution engine.
32#[derive(Parser, Debug, Clone)]
33#[command(name = "fraiseql-server", version, about)]
34pub struct Cli {
35    /// Server configuration overrides (shared with `fraiseql run`).
36    #[command(flatten)]
37    pub server: ServerArgs,
38
39    /// Enable MCP (Model Context Protocol) stdio transport.
40    ///
41    /// When set (to any value), the server starts in MCP stdio mode instead of
42    /// HTTP.  Equivalent to setting `FRAISEQL_MCP_STDIO=1`.
43    #[cfg(feature = "mcp")]
44    #[arg(long, env = "FRAISEQL_MCP_STDIO", hide = true)]
45    pub mcp_stdio: Option<String>,
46}
47
48// ── Shared server arguments ──────────────────────────────────────────────────
49
50/// Server configuration flags shared between `fraiseql-server` and
51/// `fraiseql run`.
52///
53/// Every flag has a corresponding environment variable (clap's `env`
54/// attribute).  The resolution order is: **CLI flag > env var > config
55/// file > built-in default**.
56#[derive(Args, Debug, Clone, Default)]
57pub struct ServerArgs {
58    // ── Core ─────────────────────────────────────────────────────────────
59    /// Path to TOML configuration file.
60    #[arg(long, env = "FRAISEQL_CONFIG")]
61    pub config: Option<String>,
62
63    /// Database connection URL.
64    #[arg(long, env = "DATABASE_URL")]
65    pub database_url: Option<String>,
66
67    /// Server bind address (`host:port`).
68    #[arg(long, env = "FRAISEQL_BIND_ADDR")]
69    pub bind_addr: Option<SocketAddr>,
70
71    /// Path to compiled schema JSON file.
72    #[arg(long, env = "FRAISEQL_SCHEMA_PATH")]
73    pub schema_path: Option<String>,
74
75    // ── Metrics ──────────────────────────────────────────────────────────
76    /// Enable Prometheus metrics endpoint.
77    #[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    /// Bearer token for metrics endpoint authentication.
81    #[arg(long, env = "FRAISEQL_METRICS_TOKEN")]
82    pub metrics_token: Option<String>,
83
84    // ── Admin API ────────────────────────────────────────────────────────
85    /// Enable admin API endpoints.
86    #[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    /// Bearer token for admin API authentication.
90    #[arg(long, env = "FRAISEQL_ADMIN_TOKEN")]
91    pub admin_token: Option<String>,
92
93    // ── Introspection ────────────────────────────────────────────────────
94    /// Enable GraphQL introspection endpoint.
95    #[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    /// Require authentication for introspection endpoint.
99    #[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    // ── Rate limiting ────────────────────────────────────────────────────
103    /// Enable per-IP and per-user rate limiting.
104    #[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    /// Rate limit: maximum requests per second per IP.
108    #[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_IP")]
109    pub rate_limit_rps_per_ip: Option<u32>,
110
111    /// Rate limit: maximum requests per second per authenticated user.
112    #[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_USER")]
113    pub rate_limit_rps_per_user: Option<u32>,
114
115    /// Rate limit: token bucket burst capacity.
116    #[arg(long, env = "FRAISEQL_RATE_LIMIT_BURST_SIZE")]
117    pub rate_limit_burst_size: Option<u32>,
118
119    // ── Logging ──────────────────────────────────────────────────────────
120    /// Log output format: `json` for structured JSON, `text` for
121    /// human-readable (default).
122    #[arg(long, env = "FRAISEQL_LOG_FORMAT")]
123    pub log_format: Option<String>,
124}
125
126impl ServerArgs {
127    /// Construct a `ServerArgs` from environment variables only (no CLI parsing).
128    ///
129    /// This is useful for consumers that handle their own CLI args (e.g.
130    /// `fraiseql run`) but still want to pick up server-production env vars
131    /// like `FRAISEQL_METRICS_ENABLED` without duplicating the parsing logic.
132    ///
133    /// Unset env vars produce `None` fields — only explicitly set env vars
134    /// generate overrides.
135    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    /// Apply CLI/env overrides to a [`ServerConfig`] loaded from file or
164    /// defaults.
165    ///
166    /// Fields that were not provided on the command line *and* not set via
167    /// environment variables are left untouched in `config`.
168    pub fn apply_to_config(&self, config: &mut ServerConfig) {
169        // Core overrides
170        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        // Metrics
181        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        // Admin API
189        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        // Introspection
197        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        // Rate limiting — apply all four overrides atomically.
205        self.apply_rate_limit_overrides(config);
206    }
207
208    /// Apply rate-limiting CLI/env overrides to `config`.
209    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    /// Whether the log format is JSON.
237    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)] // Reason: test code, panics are acceptable
243#[allow(clippy::field_reassign_with_default)] // Reason: test readability — explicit field-by-field overrides
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::middleware::RateLimitConfig;
248
249    // ── Cli::parse_from ──────────────────────────────────────────────────
250
251    #[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        // Clear env vars that would interfere (run in isolation via temp_env
276        // in the integration tests; here we just verify the parse shape).
277        let cli = Cli::parse_from(["fraiseql-server"]);
278        // All Option fields should be None when nothing is set
279        // (env vars from the test runner may populate some, so we only check
280        // fields that are unlikely to be in the environment).
281        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        // `--metrics-enabled` with no value should default to true
299        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    // ── ServerArgs::apply_to_config ──────────────────────────────────────
325
326    #[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        // Other fields should have sensible defaults
372        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}