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    /// Require authentication for schema metadata endpoint (overrides `introspection_require_auth`
103    /// for `/api/v1/schema/metadata`).
104    #[arg(long, env = "FRAISEQL_METADATA_REQUIRE_AUTH", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
105    pub metadata_require_auth: Option<bool>,
106
107    /// Require authentication for schema export endpoints (overrides `introspection_require_auth`
108    /// for `/api/v1/schema.graphql` and `/api/v1/schema.json`).
109    #[arg(long, env = "FRAISEQL_SCHEMA_EXPORT_REQUIRE_AUTH", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
110    pub schema_export_require_auth: Option<bool>,
111
112    /// Require authentication for playground endpoint (overrides `introspection_require_auth` for
113    /// the playground path).
114    #[arg(long, env = "FRAISEQL_PLAYGROUND_REQUIRE_AUTH", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
115    pub playground_require_auth: Option<bool>,
116
117    /// Require authentication for subscription endpoint (overrides `introspection_require_auth`
118    /// for the `WebSocket` subscription path).
119    #[arg(long, env = "FRAISEQL_SUBSCRIPTION_REQUIRE_AUTH", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
120    pub subscription_require_auth: Option<bool>,
121
122    // ── Rate limiting ────────────────────────────────────────────────────
123    /// Enable per-IP and per-user rate limiting.
124    #[arg(long, env = "FRAISEQL_RATE_LIMITING_ENABLED", value_parser = BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")]
125    pub rate_limiting_enabled: Option<bool>,
126
127    /// Rate limit: maximum requests per second per IP.
128    #[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_IP")]
129    pub rate_limit_rps_per_ip: Option<u32>,
130
131    /// Rate limit: maximum requests per second per authenticated user.
132    #[arg(long, env = "FRAISEQL_RATE_LIMIT_RPS_PER_USER")]
133    pub rate_limit_rps_per_user: Option<u32>,
134
135    /// Rate limit: token bucket burst capacity.
136    #[arg(long, env = "FRAISEQL_RATE_LIMIT_BURST_SIZE")]
137    pub rate_limit_burst_size: Option<u32>,
138
139    // ── Logging ──────────────────────────────────────────────────────────
140    /// Log output format: `json` for structured JSON, `text` for
141    /// human-readable (default).
142    #[arg(long, env = "FRAISEQL_LOG_FORMAT")]
143    pub log_format: Option<String>,
144}
145
146impl ServerArgs {
147    /// Construct a `ServerArgs` from environment variables only (no CLI parsing).
148    ///
149    /// This is useful for consumers that handle their own CLI args (e.g.
150    /// `fraiseql run`) but still want to pick up server-production env vars
151    /// like `FRAISEQL_METRICS_ENABLED` without duplicating the parsing logic.
152    ///
153    /// Unset env vars produce `None` fields — only explicitly set env vars
154    /// generate overrides.
155    #[must_use]
156    pub fn from_env() -> Self {
157        Self {
158            config:                     std::env::var("FRAISEQL_CONFIG").ok(),
159            database_url:               std::env::var("DATABASE_URL").ok(),
160            bind_addr:                  std::env::var("FRAISEQL_BIND_ADDR")
161                .ok()
162                .and_then(|v| v.parse().ok()),
163            schema_path:                std::env::var("FRAISEQL_SCHEMA_PATH").ok(),
164            metrics_enabled:            parse_bool_env_opt("FRAISEQL_METRICS_ENABLED"),
165            metrics_token:              std::env::var("FRAISEQL_METRICS_TOKEN").ok(),
166            admin_api_enabled:          parse_bool_env_opt("FRAISEQL_ADMIN_API_ENABLED"),
167            admin_token:                std::env::var("FRAISEQL_ADMIN_TOKEN").ok(),
168            introspection_enabled:      parse_bool_env_opt("FRAISEQL_INTROSPECTION_ENABLED"),
169            introspection_require_auth: parse_bool_env_opt("FRAISEQL_INTROSPECTION_REQUIRE_AUTH"),
170            metadata_require_auth:      parse_bool_env_opt("FRAISEQL_METADATA_REQUIRE_AUTH"),
171            schema_export_require_auth: parse_bool_env_opt("FRAISEQL_SCHEMA_EXPORT_REQUIRE_AUTH"),
172            playground_require_auth:    parse_bool_env_opt("FRAISEQL_PLAYGROUND_REQUIRE_AUTH"),
173            subscription_require_auth:  parse_bool_env_opt("FRAISEQL_SUBSCRIPTION_REQUIRE_AUTH"),
174            rate_limiting_enabled:      parse_bool_env_opt("FRAISEQL_RATE_LIMITING_ENABLED"),
175            rate_limit_rps_per_ip:      std::env::var("FRAISEQL_RATE_LIMIT_RPS_PER_IP")
176                .ok()
177                .and_then(|v| v.parse().ok()),
178            rate_limit_rps_per_user:    std::env::var("FRAISEQL_RATE_LIMIT_RPS_PER_USER")
179                .ok()
180                .and_then(|v| v.parse().ok()),
181            rate_limit_burst_size:      std::env::var("FRAISEQL_RATE_LIMIT_BURST_SIZE")
182                .ok()
183                .and_then(|v| v.parse().ok()),
184            log_format:                 std::env::var("FRAISEQL_LOG_FORMAT").ok(),
185        }
186    }
187
188    /// Apply CLI/env overrides to a [`ServerConfig`] loaded from file or
189    /// defaults.
190    ///
191    /// Fields that were not provided on the command line *and* not set via
192    /// environment variables are left untouched in `config`.
193    pub fn apply_to_config(&self, config: &mut ServerConfig) {
194        // Core overrides
195        if let Some(ref db_url) = self.database_url {
196            config.database_url.clone_from(db_url);
197        }
198        if let Some(addr) = self.bind_addr {
199            config.bind_addr = addr;
200        }
201        if let Some(ref path) = self.schema_path {
202            config.schema_path = path.into();
203        }
204
205        // Metrics
206        if let Some(enabled) = self.metrics_enabled {
207            config.metrics_enabled = enabled;
208        }
209        if self.metrics_token.is_some() {
210            config.metrics_token.clone_from(&self.metrics_token);
211        }
212
213        // Admin API
214        if let Some(enabled) = self.admin_api_enabled {
215            config.admin_api_enabled = enabled;
216        }
217        if self.admin_token.is_some() {
218            config.admin_token.clone_from(&self.admin_token);
219        }
220
221        // Introspection
222        if let Some(enabled) = self.introspection_enabled {
223            config.introspection_enabled = enabled;
224        }
225        if let Some(require_auth) = self.introspection_require_auth {
226            config.introspection_require_auth = require_auth;
227        }
228        if let Some(require_auth) = self.metadata_require_auth {
229            config.metadata_require_auth = Some(require_auth);
230        }
231        if let Some(require_auth) = self.schema_export_require_auth {
232            config.schema_export_require_auth = Some(require_auth);
233        }
234        if let Some(require_auth) = self.playground_require_auth {
235            config.playground_require_auth = Some(require_auth);
236        }
237        if let Some(require_auth) = self.subscription_require_auth {
238            config.subscription_require_auth = Some(require_auth);
239        }
240
241        // Rate limiting — apply all four overrides atomically.
242        self.apply_rate_limit_overrides(config);
243    }
244
245    /// Apply rate-limiting CLI/env overrides to `config`.
246    fn apply_rate_limit_overrides(&self, config: &mut ServerConfig) {
247        if self.rate_limiting_enabled.is_none()
248            && self.rate_limit_rps_per_ip.is_none()
249            && self.rate_limit_rps_per_user.is_none()
250            && self.rate_limit_burst_size.is_none()
251        {
252            return;
253        }
254
255        let mut rate_config = config.rate_limiting.take().unwrap_or_default();
256
257        if let Some(enabled) = self.rate_limiting_enabled {
258            rate_config.enabled = enabled;
259        }
260        if let Some(v) = self.rate_limit_rps_per_ip {
261            rate_config.rps_per_ip = v;
262        }
263        if let Some(v) = self.rate_limit_rps_per_user {
264            rate_config.rps_per_user = v;
265        }
266        if let Some(v) = self.rate_limit_burst_size {
267            rate_config.burst_size = v;
268        }
269
270        config.rate_limiting = Some(rate_config);
271    }
272
273    /// Whether the log format is JSON.
274    #[must_use]
275    pub fn is_json_log_format(&self) -> bool {
276        self.log_format.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("json"))
277    }
278}