Skip to main content

qn/
context.rs

1//! Runtime context shared by every command.
2//!
3//! Builds the `QuicknodeSdk` from `GlobalArgs` and attaches an `OutputCtx`.
4
5use std::io::IsTerminal;
6
7use quicknode_sdk::{
8    AdminConfig, CachedToken, HttpConfig, KvStoreConfig, QuicknodeSdk, RpcConfig, SdkFullConfig,
9    SqlConfig, StreamsConfig, WebhooksConfig,
10};
11
12use crate::config;
13use crate::errors::CliError;
14use crate::output::{Format, OutputCtx};
15
16/// Top-level flags inherited by every subcommand.
17#[derive(Debug, Clone, Default)]
18pub struct GlobalArgs {
19    pub api_key: Option<String>,
20    /// `--config-file`: alternate config TOML. `None` means the default path
21    /// (`~/.config/qn/config.toml`).
22    pub config_file: Option<std::path::PathBuf>,
23    /// `None` means the user didn't pass `--format`; resolve via config file
24    /// (then the TTY-aware default: `Table` on a TTY, `Json` off) when we
25    /// build the [`Ctx`].
26    pub format: Option<Format>,
27    pub wide: bool,
28    pub no_color: bool,
29    pub quiet: bool,
30    pub verbose: bool,
31    pub no_input: bool,
32    pub yes_count: u8,
33    /// Max automatic retries for read-only API calls (see `crate::retry`).
34    /// `Default` yields 0 (no retries) — the CLI default of 3 comes from clap.
35    pub retries: u32,
36    pub base_url: Option<String>,
37    /// Optional path prefix inserted between the host and each sub-client's
38    /// fixed suffix (e.g. `/console-api`). Requires `base_url`. Useful for
39    /// reverse-proxy / gateway environments and local servers that mount the
40    /// API under a prefix.
41    pub base_prefix: Option<String>,
42}
43
44impl GlobalArgs {
45    /// Resolve the output format: CLI flag > config file > TTY-aware default
46    /// (`Table` on a TTY, `Json` off).
47    /// Used by [`Ctx::from_global`] and `auth` (which doesn't build a Ctx).
48    pub fn resolve_format(&self, stdout_is_tty: bool) -> Format {
49        self.resolve_output(stdout_is_tty).0
50    }
51
52    /// Resolve `(format, wide)` together so we only read the config file once.
53    ///
54    /// For each: CLI flag > config file > built-in default. The format default
55    /// is TTY-aware: `Table` when stdout is a terminal, `Json` otherwise (so
56    /// agents / piped callers get a structured format by default). `--wide` is
57    /// purely additive — the flag sets it true; the config file can also set
58    /// it true; otherwise it's false.
59    pub fn resolve_output(&self, stdout_is_tty: bool) -> (Format, bool) {
60        let (cfg_format, cfg_wide) = self.load_output_config();
61        resolve_output_inner(self.format, self.wide, cfg_format, cfg_wide, stdout_is_tty)
62    }
63
64    /// The config file to read: `--config-file` if given, else the default path.
65    pub fn resolve_config_path(&self) -> Option<std::path::PathBuf> {
66        self.config_file.clone().or_else(config::config_path)
67    }
68
69    fn load_output_config(&self) -> (Option<Format>, bool) {
70        let Some(p) = self.resolve_config_path() else {
71            return (None, false);
72        };
73        match config::load_from(&p) {
74            Ok(Some(cfg)) => (cfg.output.format, cfg.output.wide),
75            _ => (None, false),
76        }
77    }
78}
79
80/// Pure form of [`GlobalArgs::resolve_output`] — separated so it can be
81/// exhaustively unit-tested without touching the real config file. CLI values
82/// win; otherwise we fall back to config; otherwise the TTY-aware default.
83fn resolve_output_inner(
84    flag_format: Option<Format>,
85    flag_wide: bool,
86    cfg_format: Option<Format>,
87    cfg_wide: bool,
88    stdout_is_tty: bool,
89) -> (Format, bool) {
90    let format = flag_format.or(cfg_format).unwrap_or(if stdout_is_tty {
91        Format::Table
92    } else {
93        Format::Json
94    });
95    let wide = flag_wide || cfg_wide;
96    (format, wide)
97}
98
99/// The `User-Agent` sent with every API request. Mirrors the SDK's own shape
100/// (`quicknode-sdk-<lang>/<ver> (<os>-<arch>; …)`) with the CLI as the product:
101/// `quicknode-cli/<version> (<os>-<arch>)`.
102pub fn user_agent() -> String {
103    format!(
104        "quicknode-cli/{} ({}-{})",
105        env!("CARGO_PKG_VERSION"),
106        std::env::consts::OS,
107        std::env::consts::ARCH,
108    )
109}
110
111/// Base SDK config shared by every construction site (`Ctx::from_global` and
112/// the `auth` commands): the API key plus the CLI `User-Agent`. Custom headers
113/// in `HttpConfig` override SDK-managed headers of the same name, which is the
114/// SDK's supported way to replace its auto-generated `User-Agent`.
115pub fn sdk_config(api_key: String) -> SdkFullConfig {
116    let mut full = SdkFullConfig::from_api_key(api_key);
117    apply_user_agent(&mut full);
118    full
119}
120
121/// Installs the CLI `User-Agent` header on `full`. Shared by the keyed
122/// ([`sdk_config`]) and keyless ([`Ctx::from_global_keyless_payment`])
123/// construction paths.
124fn apply_user_agent(full: &mut SdkFullConfig) {
125    let mut headers = std::collections::HashMap::new();
126    headers.insert("User-Agent".to_string(), user_agent());
127    full.http = Some(HttpConfig {
128        headers: Some(headers),
129        ..Default::default()
130    });
131}
132
133/// Points every sub-client at a custom host, suffixing each with its own base
134/// path. Shared by `Ctx::from_global` and the `auth` commands so `--base-url`
135/// applies uniformly (useful for wiremock tests and on-prem mirrors).
136fn apply_base_url(full: &mut SdkFullConfig, trimmed: &str) {
137    full.admin = Some(AdminConfig {
138        base_url: Some(format!("{trimmed}/v0/")),
139    });
140    full.streams = Some(StreamsConfig {
141        base_url: Some(format!("{trimmed}/streams/rest/v1/")),
142    });
143    full.webhooks = Some(WebhooksConfig {
144        base_url: Some(format!("{trimmed}/webhooks/rest/v1/")),
145    });
146    full.kvstore = Some(KvStoreConfig {
147        base_url: Some(format!("{trimmed}/kv/rest/v1/")),
148    });
149    full.sql = Some(SqlConfig {
150        base_url: Some(format!("{trimmed}/sql/rest/v1/")),
151    });
152}
153
154/// Like [`sdk_config`] but also honors an optional `--base-url` override.
155/// Used by the `auth` commands, which build the SDK outside [`Ctx`].
156pub fn sdk_config_with_base(
157    api_key: String,
158    base_url: Option<&str>,
159) -> Result<SdkFullConfig, CliError> {
160    let mut full = sdk_config(api_key);
161    if let Some(base) = base_url {
162        let trimmed = validate_base_url(base)?;
163        apply_base_url(&mut full, trimmed.as_str());
164    }
165    Ok(full)
166}
167
168pub struct Ctx {
169    pub sdk: QuicknodeSdk,
170    pub out: OutputCtx,
171    pub global: GlobalArgs,
172}
173
174impl Ctx {
175    /// Construct the SDK + output ctx from `global`. Resolves the API key per
176    /// the documented precedence: flag > config file (`--config-file` path if
177    /// given, else the default). If neither supplies a key we return
178    /// `CliError::NoApiKey` — regular commands do not prompt; the user is
179    /// directed to `qn auth login`.
180    pub fn from_global(global: GlobalArgs) -> Result<Self, CliError> {
181        Self::build(global, None, None).map(|(ctx, _)| ctx)
182    }
183
184    /// Like [`from_global`](Self::from_global) but for `qn rpc`: seeds the RPC
185    /// client's token cache with `seed` (a JWT loaded from disk) and sets the
186    /// client-wide custom endpoint URL from `config_endpoint_url` (the
187    /// `[rpc] endpoint_url` config default). Also returns the resolved API key
188    /// so the caller can scope and write back the token cache.
189    pub fn from_global_with_rpc_seed(
190        global: GlobalArgs,
191        seed: Option<CachedToken>,
192        config_endpoint_url: Option<String>,
193    ) -> Result<(Self, String), CliError> {
194        Self::build(global, seed, config_endpoint_url)
195    }
196
197    /// Build a keyless SDK context for paid RPC calls.
198    pub fn from_global_keyless_payment(
199        global: GlobalArgs,
200        payment: quicknode_sdk::PaymentConfig,
201    ) -> Result<Self, CliError> {
202        let stdout_is_tty = std::io::stdout().is_terminal();
203        let (format, wide) = global.resolve_output(stdout_is_tty);
204
205        let mut full = SdkFullConfig::keyless();
206        apply_user_agent(&mut full);
207        full.rpc = Some(RpcConfig {
208            payment: Some(payment),
209            ..Default::default()
210        });
211
212        let sdk = QuicknodeSdk::new(&full)?;
213        let out = OutputCtx::detect_with(
214            format,
215            global.no_color,
216            global.quiet,
217            global.verbose,
218            wide,
219            stdout_is_tty,
220            std::env::var_os("NO_COLOR"),
221            std::env::var("TERM").ok(),
222        );
223
224        Ok(Self { sdk, out, global })
225    }
226
227    /// Build a keyless SDK context for local-only commands.
228    pub fn from_global_keyless(global: GlobalArgs) -> Result<Self, CliError> {
229        let stdout_is_tty = std::io::stdout().is_terminal();
230        let (format, wide) = global.resolve_output(stdout_is_tty);
231
232        let mut full = SdkFullConfig::keyless();
233        apply_user_agent(&mut full);
234
235        let sdk = QuicknodeSdk::new(&full)?;
236        let out = OutputCtx::detect_with(
237            format,
238            global.no_color,
239            global.quiet,
240            global.verbose,
241            wide,
242            stdout_is_tty,
243            std::env::var_os("NO_COLOR"),
244            std::env::var("TERM").ok(),
245        );
246
247        Ok(Self { sdk, out, global })
248    }
249
250    fn build(
251        global: GlobalArgs,
252        rpc_seed: Option<CachedToken>,
253        rpc_endpoint_url: Option<String>,
254    ) -> Result<(Self, String), CliError> {
255        let config_path = global.resolve_config_path();
256        let stdout_is_tty = std::io::stdout().is_terminal();
257        let (format, wide) = global.resolve_output(stdout_is_tty);
258
259        let (api_key, _) = config::resolve_api_key(
260            global.api_key.as_deref(),
261            config_path.as_deref(),
262            false,
263            || unreachable!("prompt disabled for non-auth commands"),
264        )?;
265
266        let mut full = sdk_config(api_key.clone());
267
268        // Validate the config URL before building the SDK.
269        let rpc_endpoint_url = match rpc_endpoint_url {
270            Some(u) => Some(validate_endpoint_url(&u)?),
271            None => None,
272        };
273        if rpc_seed.is_some() || rpc_endpoint_url.is_some() {
274            full.rpc = Some(RpcConfig {
275                seed: rpc_seed,
276                endpoint_url: rpc_endpoint_url,
277                ..Default::default()
278            });
279        }
280
281        if global.base_prefix.is_some() && global.base_url.is_none() {
282            return Err(CliError::Arg(
283                "--base-prefix requires --base-url".to_string(),
284            ));
285        }
286
287        if let Some(base) = &global.base_url {
288            let host = validate_base_url(base)?;
289            let prefix = match &global.base_prefix {
290                Some(p) => validate_base_prefix(p)?,
291                None => String::new(),
292            };
293            let root = format!("{host}{prefix}");
294            apply_base_url(&mut full, &root);
295        }
296
297        let sdk = QuicknodeSdk::new(&full)?;
298        let out = OutputCtx::detect_with(
299            format,
300            global.no_color,
301            global.quiet,
302            global.verbose,
303            wide,
304            stdout_is_tty,
305            std::env::var_os("NO_COLOR"),
306            std::env::var("TERM").ok(),
307        );
308
309        Ok((Self { sdk, out, global }, api_key))
310    }
311}
312
313/// Validates a user-supplied `--base-url` and returns it with any trailing
314/// slash stripped. Rejects non-http(s) schemes, embedded userinfo, query/
315/// fragment, and non-root paths so we can't accidentally splice attacker-
316/// controlled segments into the SDK's hard-coded sub-client paths.
317fn validate_base_url(base: &str) -> Result<String, CliError> {
318    let parsed = url::Url::parse(base)
319        .map_err(|_| CliError::Arg(format!("--base-url '{base}' is not a valid URL")))?;
320    match parsed.scheme() {
321        "http" | "https" => {}
322        other => {
323            return Err(CliError::Arg(format!(
324                "--base-url scheme '{other}' is not allowed; use http or https"
325            )))
326        }
327    }
328    if !parsed.username().is_empty() || parsed.password().is_some() {
329        return Err(CliError::Arg(
330            "--base-url must not contain userinfo (username/password)".into(),
331        ));
332    }
333    if parsed.query().is_some() || parsed.fragment().is_some() {
334        return Err(CliError::Arg(
335            "--base-url must not contain a query string or fragment".into(),
336        ));
337    }
338    if !matches!(parsed.path(), "" | "/") {
339        return Err(CliError::Arg("--base-url must not contain a path".into()));
340    }
341    Ok(base.trim_end_matches('/').to_string())
342}
343
344/// Validates a custom RPC endpoint URL (`--endpoint-url` or `[rpc] endpoint_url`).
345/// Unlike [`validate_base_url`], a fully-formed RPC URL carries a path
346/// (`https://host/rpc`), so paths are allowed here. We only confirm it parses
347/// and uses an http(s) scheme — enough to reject garbage and non-network schemes
348/// with a clear CLI error before any call, while leaving the rest to the SDK.
349pub(crate) fn validate_endpoint_url(url: &str) -> Result<String, CliError> {
350    let parsed = url::Url::parse(url)
351        .map_err(|_| CliError::Arg(format!("--endpoint-url '{url}' is not a valid URL")))?;
352    match parsed.scheme() {
353        "http" | "https" => Ok(url.to_string()),
354        other => Err(CliError::Arg(format!(
355            "--endpoint-url scheme '{other}' is not allowed; use http or https"
356        ))),
357    }
358}
359
360/// Validates and normalizes a `--base-prefix` to a leading-slash, no-trailing-
361/// slash path fragment (e.g. `/console-api`). Rejects anything that smuggles in
362/// a host or query so it can only ever extend the path of `--base-url`: no
363/// scheme/authority (`//`), no `?`/`#`, no `.`/`..` traversal segments.
364fn validate_base_prefix(prefix: &str) -> Result<String, CliError> {
365    let trimmed = prefix.trim();
366    if trimmed.is_empty() {
367        return Ok(String::new());
368    }
369    if trimmed.contains("//") {
370        return Err(CliError::Arg(
371            "--base-prefix must be a path, not a URL (no '//')".into(),
372        ));
373    }
374    if trimmed.contains(['?', '#', '\\']) {
375        return Err(CliError::Arg(
376            "--base-prefix must not contain a query string, fragment, or backslash".into(),
377        ));
378    }
379    let inner = trimmed.trim_matches('/');
380    if inner.is_empty() {
381        return Ok(String::new());
382    }
383    let normalized = format!("/{inner}");
384    if normalized.split('/').any(|seg| matches!(seg, "." | "..")) {
385        return Err(CliError::Arg(
386            "--base-prefix must not contain '.' or '..' path segments".into(),
387        ));
388    }
389    Ok(normalized)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn flag_format_wins_over_config_and_tty_default() {
398        let (f, _) =
399            resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, true);
400        assert_eq!(f, Format::Json);
401        let (f, _) =
402            resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, false);
403        assert_eq!(f, Format::Json);
404    }
405
406    #[test]
407    fn config_format_wins_over_tty_default() {
408        let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, true);
409        assert_eq!(f, Format::Yaml);
410        let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, false);
411        assert_eq!(f, Format::Yaml);
412    }
413
414    #[test]
415    fn default_is_table_when_stdout_is_a_tty() {
416        let (f, _) = resolve_output_inner(None, false, None, false, true);
417        assert_eq!(f, Format::Table);
418    }
419
420    #[test]
421    fn default_is_json_when_stdout_is_not_a_tty() {
422        let (f, _) = resolve_output_inner(None, false, None, false, false);
423        assert_eq!(f, Format::Json);
424    }
425
426    #[test]
427    fn config_toon_overrides_non_tty_default() {
428        let (f, _) = resolve_output_inner(None, false, Some(Format::Toon), false, false);
429        assert_eq!(f, Format::Toon);
430    }
431
432    #[test]
433    fn wide_is_additive_between_flag_and_config() {
434        // Flag alone.
435        let (_, w) = resolve_output_inner(None, true, None, false, true);
436        assert!(w);
437        // Config alone.
438        let (_, w) = resolve_output_inner(None, false, None, true, true);
439        assert!(w);
440        // Both.
441        let (_, w) = resolve_output_inner(None, true, None, true, true);
442        assert!(w);
443        // Neither.
444        let (_, w) = resolve_output_inner(None, false, None, false, true);
445        assert!(!w);
446    }
447
448    #[test]
449    fn base_url_accepts_plain_http_and_https() {
450        assert_eq!(
451            validate_base_url("https://api.quicknode.com").unwrap(),
452            "https://api.quicknode.com"
453        );
454        assert_eq!(
455            validate_base_url("http://127.0.0.1:8080/").unwrap(),
456            "http://127.0.0.1:8080"
457        );
458    }
459
460    #[test]
461    fn base_url_rejects_non_http_schemes() {
462        for bad in ["file:///etc/passwd", "ftp://x", "javascript:alert(1)"] {
463            assert!(validate_base_url(bad).is_err(), "should reject {bad}");
464        }
465    }
466
467    #[test]
468    fn base_url_rejects_userinfo() {
469        assert!(validate_base_url("https://user:pass@evil/").is_err());
470        assert!(validate_base_url("https://user@evil/").is_err());
471    }
472
473    #[test]
474    fn base_url_rejects_path_query_fragment() {
475        assert!(validate_base_url("https://x/extra/path").is_err());
476        assert!(validate_base_url("https://x/?q=1").is_err());
477        assert!(validate_base_url("https://x/#frag").is_err());
478    }
479
480    #[test]
481    fn base_url_rejects_garbage() {
482        assert!(validate_base_url("not a url").is_err());
483        assert!(validate_base_url("").is_err());
484    }
485
486    #[test]
487    fn endpoint_url_allows_http_https_with_path() {
488        assert_eq!(
489            validate_endpoint_url("https://my-endpoint.example/rpc").unwrap(),
490            "https://my-endpoint.example/rpc"
491        );
492        assert_eq!(
493            validate_endpoint_url("http://127.0.0.1:8080/some/path?x=1").unwrap(),
494            "http://127.0.0.1:8080/some/path?x=1"
495        );
496    }
497
498    #[test]
499    fn endpoint_url_rejects_non_http_schemes_and_garbage() {
500        for bad in ["ftp://x/rpc", "file:///etc/passwd", "not a url", ""] {
501            assert!(validate_endpoint_url(bad).is_err(), "should reject {bad}");
502        }
503    }
504
505    #[test]
506    fn base_prefix_normalizes_slashes() {
507        assert_eq!(
508            validate_base_prefix("/console-api").unwrap(),
509            "/console-api"
510        );
511        assert_eq!(validate_base_prefix("console-api").unwrap(), "/console-api");
512        assert_eq!(
513            validate_base_prefix("/console-api/").unwrap(),
514            "/console-api"
515        );
516        assert_eq!(validate_base_prefix("/a/b").unwrap(), "/a/b");
517    }
518
519    #[test]
520    fn base_prefix_empty_is_empty() {
521        assert_eq!(validate_base_prefix("").unwrap(), "");
522        assert_eq!(validate_base_prefix("  ").unwrap(), "");
523        assert_eq!(validate_base_prefix("/").unwrap(), "");
524    }
525
526    #[test]
527    fn base_prefix_rejects_url_like_and_traversal() {
528        assert!(validate_base_prefix("//evil.com").is_err());
529        assert!(validate_base_prefix("http://evil.com").is_err());
530        assert!(validate_base_prefix("/a?b=1").is_err());
531        assert!(validate_base_prefix("/a#frag").is_err());
532        assert!(validate_base_prefix("/../etc").is_err());
533        assert!(validate_base_prefix("/a/../b").is_err());
534    }
535
536    #[test]
537    fn user_agent_identifies_the_cli() {
538        let ua = user_agent();
539        assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
540        assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
541    }
542
543    #[test]
544    fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
545        let cfg = sdk_config("k".to_string());
546        let http = cfg.http.expect("http config should be set");
547        assert_eq!(
548            http.headers.as_ref().and_then(|h| h.get("User-Agent")),
549            Some(&user_agent())
550        );
551        assert_eq!(http.timeout_secs, None);
552        assert_eq!(http.pool_max_idle_per_host, None);
553    }
554}