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