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    let mut headers = std::collections::HashMap::new();
118    headers.insert("User-Agent".to_string(), user_agent());
119    full.http = Some(HttpConfig {
120        headers: Some(headers),
121        ..Default::default()
122    });
123    full
124}
125
126/// Points every sub-client at a custom host, suffixing each with its own base
127/// path. Shared by `Ctx::from_global` and the `auth` commands so `--base-url`
128/// applies uniformly (useful for wiremock tests and on-prem mirrors).
129fn apply_base_url(full: &mut SdkFullConfig, trimmed: &str) {
130    full.admin = Some(AdminConfig {
131        base_url: Some(format!("{trimmed}/v0/")),
132    });
133    full.streams = Some(StreamsConfig {
134        base_url: Some(format!("{trimmed}/streams/rest/v1/")),
135    });
136    full.webhooks = Some(WebhooksConfig {
137        base_url: Some(format!("{trimmed}/webhooks/rest/v1/")),
138    });
139    full.kvstore = Some(KvStoreConfig {
140        base_url: Some(format!("{trimmed}/kv/rest/v1/")),
141    });
142    full.sql = Some(SqlConfig {
143        base_url: Some(format!("{trimmed}/sql/rest/v1/")),
144    });
145}
146
147/// Like [`sdk_config`] but also honors an optional `--base-url` override.
148/// Used by the `auth` commands, which build the SDK outside [`Ctx`].
149pub fn sdk_config_with_base(
150    api_key: String,
151    base_url: Option<&str>,
152) -> Result<SdkFullConfig, CliError> {
153    let mut full = sdk_config(api_key);
154    if let Some(base) = base_url {
155        let trimmed = validate_base_url(base)?;
156        apply_base_url(&mut full, trimmed.as_str());
157    }
158    Ok(full)
159}
160
161pub struct Ctx {
162    pub sdk: QuicknodeSdk,
163    pub out: OutputCtx,
164    pub global: GlobalArgs,
165}
166
167impl Ctx {
168    /// Construct the SDK + output ctx from `global`. Resolves the API key per
169    /// the documented precedence: flag > config file (`--config-file` path if
170    /// given, else the default). If neither supplies a key we return
171    /// `CliError::NoApiKey` — regular commands do not prompt; the user is
172    /// directed to `qn auth login`.
173    pub fn from_global(global: GlobalArgs) -> Result<Self, CliError> {
174        Self::build(global, None, None).map(|(ctx, _)| ctx)
175    }
176
177    /// Like [`from_global`](Self::from_global) but for `qn rpc`: seeds the RPC
178    /// client's token cache with `seed` (a JWT loaded from disk) and sets the
179    /// client-wide custom endpoint URL from `config_endpoint_url` (the
180    /// `[rpc] endpoint_url` config default). Also returns the resolved API key
181    /// so the caller can scope and write back the token cache.
182    pub fn from_global_with_rpc_seed(
183        global: GlobalArgs,
184        seed: Option<CachedToken>,
185        config_endpoint_url: Option<String>,
186    ) -> Result<(Self, String), CliError> {
187        Self::build(global, seed, config_endpoint_url)
188    }
189
190    fn build(
191        global: GlobalArgs,
192        rpc_seed: Option<CachedToken>,
193        rpc_endpoint_url: Option<String>,
194    ) -> Result<(Self, String), CliError> {
195        let config_path = global.resolve_config_path();
196        let stdout_is_tty = std::io::stdout().is_terminal();
197        let (format, wide) = global.resolve_output(stdout_is_tty);
198
199        let (api_key, _) = config::resolve_api_key(
200            global.api_key.as_deref(),
201            config_path.as_deref(),
202            false,
203            || unreachable!("prompt disabled for non-auth commands"),
204        )?;
205
206        let mut full = sdk_config(api_key.clone());
207
208        // The `[rpc] endpoint_url` config default becomes the client-wide custom
209        // URL (a per-call `--endpoint-url` overrides it in the call itself). We
210        // validate it here so a malformed config value fails with a clear error
211        // rather than at call time. `seed` and `endpoint_url` coexist harmlessly:
212        // the SDK ignores the seed when a custom URL is set.
213        let rpc_endpoint_url = match rpc_endpoint_url {
214            Some(u) => Some(validate_endpoint_url(&u)?),
215            None => None,
216        };
217        if rpc_seed.is_some() || rpc_endpoint_url.is_some() {
218            full.rpc = Some(RpcConfig {
219                seed: rpc_seed,
220                endpoint_url: rpc_endpoint_url,
221                ..Default::default()
222            });
223        }
224
225        // --base-prefix only makes sense when overriding the host. Composing it
226        // against the default prod host isn't supported, so fail loudly rather
227        // than silently ignore it.
228        if global.base_prefix.is_some() && global.base_url.is_none() {
229            return Err(CliError::Arg(
230                "--base-prefix requires --base-url".to_string(),
231            ));
232        }
233
234        // --base-url applies to every sub-client. Useful for wiremock tests and
235        // on-prem mirrors. Each sub-client has its own fixed suffix; an optional
236        // --base-prefix is inserted between the host and that suffix for
237        // reverse-proxy / gateway environments. Tooling Access / RPC minting
238        // lives on the admin `v0` base, so no separate RPC base is needed here.
239        if let Some(base) = &global.base_url {
240            let host = validate_base_url(base)?;
241            let prefix = match &global.base_prefix {
242                Some(p) => validate_base_prefix(p)?,
243                None => String::new(),
244            };
245            let root = format!("{host}{prefix}");
246            apply_base_url(&mut full, &root);
247        }
248
249        let sdk = QuicknodeSdk::new(&full)?;
250        let out = OutputCtx::detect_with(
251            format,
252            global.no_color,
253            global.quiet,
254            global.verbose,
255            wide,
256            stdout_is_tty,
257            std::env::var_os("NO_COLOR"),
258            std::env::var("TERM").ok(),
259        );
260
261        Ok((Self { sdk, out, global }, api_key))
262    }
263}
264
265/// Validates a user-supplied `--base-url` and returns it with any trailing
266/// slash stripped. Rejects non-http(s) schemes, embedded userinfo, query/
267/// fragment, and non-root paths so we can't accidentally splice attacker-
268/// controlled segments into the SDK's hard-coded sub-client paths.
269fn validate_base_url(base: &str) -> Result<String, CliError> {
270    let parsed = url::Url::parse(base)
271        .map_err(|_| CliError::Arg(format!("--base-url '{base}' is not a valid URL")))?;
272    match parsed.scheme() {
273        "http" | "https" => {}
274        other => {
275            return Err(CliError::Arg(format!(
276                "--base-url scheme '{other}' is not allowed; use http or https"
277            )))
278        }
279    }
280    if !parsed.username().is_empty() || parsed.password().is_some() {
281        return Err(CliError::Arg(
282            "--base-url must not contain userinfo (username/password)".into(),
283        ));
284    }
285    if parsed.query().is_some() || parsed.fragment().is_some() {
286        return Err(CliError::Arg(
287            "--base-url must not contain a query string or fragment".into(),
288        ));
289    }
290    if !matches!(parsed.path(), "" | "/") {
291        return Err(CliError::Arg("--base-url must not contain a path".into()));
292    }
293    Ok(base.trim_end_matches('/').to_string())
294}
295
296/// Validates a custom RPC endpoint URL (`--endpoint-url` or `[rpc] endpoint_url`).
297/// Unlike [`validate_base_url`], a fully-formed RPC URL carries a path
298/// (`https://host/rpc`), so paths are allowed here. We only confirm it parses
299/// and uses an http(s) scheme — enough to reject garbage and non-network schemes
300/// with a clear CLI error before any call, while leaving the rest to the SDK.
301pub(crate) fn validate_endpoint_url(url: &str) -> Result<String, CliError> {
302    let parsed = url::Url::parse(url)
303        .map_err(|_| CliError::Arg(format!("--endpoint-url '{url}' is not a valid URL")))?;
304    match parsed.scheme() {
305        "http" | "https" => Ok(url.to_string()),
306        other => Err(CliError::Arg(format!(
307            "--endpoint-url scheme '{other}' is not allowed; use http or https"
308        ))),
309    }
310}
311
312/// Validates and normalizes a `--base-prefix` to a leading-slash, no-trailing-
313/// slash path fragment (e.g. `/console-api`). Rejects anything that smuggles in
314/// a host or query so it can only ever extend the path of `--base-url`: no
315/// scheme/authority (`//`), no `?`/`#`, no `.`/`..` traversal segments.
316fn validate_base_prefix(prefix: &str) -> Result<String, CliError> {
317    let trimmed = prefix.trim();
318    if trimmed.is_empty() {
319        return Ok(String::new());
320    }
321    if trimmed.contains("//") {
322        return Err(CliError::Arg(
323            "--base-prefix must be a path, not a URL (no '//')".into(),
324        ));
325    }
326    if trimmed.contains(['?', '#', '\\']) {
327        return Err(CliError::Arg(
328            "--base-prefix must not contain a query string, fragment, or backslash".into(),
329        ));
330    }
331    let inner = trimmed.trim_matches('/');
332    if inner.is_empty() {
333        // Bare "/" (or "///") carries no prefix.
334        return Ok(String::new());
335    }
336    let normalized = format!("/{inner}");
337    if normalized.split('/').any(|seg| matches!(seg, "." | "..")) {
338        return Err(CliError::Arg(
339            "--base-prefix must not contain '.' or '..' path segments".into(),
340        ));
341    }
342    Ok(normalized)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn flag_format_wins_over_config_and_tty_default() {
351        let (f, _) =
352            resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, true);
353        assert_eq!(f, Format::Json);
354        let (f, _) =
355            resolve_output_inner(Some(Format::Json), false, Some(Format::Yaml), false, false);
356        assert_eq!(f, Format::Json);
357    }
358
359    #[test]
360    fn config_format_wins_over_tty_default() {
361        let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, true);
362        assert_eq!(f, Format::Yaml);
363        let (f, _) = resolve_output_inner(None, false, Some(Format::Yaml), false, false);
364        assert_eq!(f, Format::Yaml);
365    }
366
367    #[test]
368    fn default_is_table_when_stdout_is_a_tty() {
369        let (f, _) = resolve_output_inner(None, false, None, false, true);
370        assert_eq!(f, Format::Table);
371    }
372
373    #[test]
374    fn default_is_json_when_stdout_is_not_a_tty() {
375        let (f, _) = resolve_output_inner(None, false, None, false, false);
376        assert_eq!(f, Format::Json);
377    }
378
379    #[test]
380    fn config_toon_overrides_non_tty_default() {
381        let (f, _) = resolve_output_inner(None, false, Some(Format::Toon), false, false);
382        assert_eq!(f, Format::Toon);
383    }
384
385    #[test]
386    fn wide_is_additive_between_flag_and_config() {
387        // Flag alone.
388        let (_, w) = resolve_output_inner(None, true, None, false, true);
389        assert!(w);
390        // Config alone.
391        let (_, w) = resolve_output_inner(None, false, None, true, true);
392        assert!(w);
393        // Both.
394        let (_, w) = resolve_output_inner(None, true, None, true, true);
395        assert!(w);
396        // Neither.
397        let (_, w) = resolve_output_inner(None, false, None, false, true);
398        assert!(!w);
399    }
400
401    #[test]
402    fn base_url_accepts_plain_http_and_https() {
403        assert_eq!(
404            validate_base_url("https://api.quicknode.com").unwrap(),
405            "https://api.quicknode.com"
406        );
407        assert_eq!(
408            validate_base_url("http://127.0.0.1:8080/").unwrap(),
409            "http://127.0.0.1:8080"
410        );
411    }
412
413    #[test]
414    fn base_url_rejects_non_http_schemes() {
415        for bad in ["file:///etc/passwd", "ftp://x", "javascript:alert(1)"] {
416            assert!(validate_base_url(bad).is_err(), "should reject {bad}");
417        }
418    }
419
420    #[test]
421    fn base_url_rejects_userinfo() {
422        assert!(validate_base_url("https://user:pass@evil/").is_err());
423        assert!(validate_base_url("https://user@evil/").is_err());
424    }
425
426    #[test]
427    fn base_url_rejects_path_query_fragment() {
428        assert!(validate_base_url("https://x/extra/path").is_err());
429        assert!(validate_base_url("https://x/?q=1").is_err());
430        assert!(validate_base_url("https://x/#frag").is_err());
431    }
432
433    #[test]
434    fn base_url_rejects_garbage() {
435        assert!(validate_base_url("not a url").is_err());
436        assert!(validate_base_url("").is_err());
437    }
438
439    #[test]
440    fn endpoint_url_allows_http_https_with_path() {
441        assert_eq!(
442            validate_endpoint_url("https://my-endpoint.example/rpc").unwrap(),
443            "https://my-endpoint.example/rpc"
444        );
445        assert_eq!(
446            validate_endpoint_url("http://127.0.0.1:8080/some/path?x=1").unwrap(),
447            "http://127.0.0.1:8080/some/path?x=1"
448        );
449    }
450
451    #[test]
452    fn endpoint_url_rejects_non_http_schemes_and_garbage() {
453        for bad in ["ftp://x/rpc", "file:///etc/passwd", "not a url", ""] {
454            assert!(validate_endpoint_url(bad).is_err(), "should reject {bad}");
455        }
456    }
457
458    #[test]
459    fn base_prefix_normalizes_slashes() {
460        assert_eq!(
461            validate_base_prefix("/console-api").unwrap(),
462            "/console-api"
463        );
464        assert_eq!(validate_base_prefix("console-api").unwrap(), "/console-api");
465        assert_eq!(
466            validate_base_prefix("/console-api/").unwrap(),
467            "/console-api"
468        );
469        assert_eq!(validate_base_prefix("/a/b").unwrap(), "/a/b");
470    }
471
472    #[test]
473    fn base_prefix_empty_is_empty() {
474        assert_eq!(validate_base_prefix("").unwrap(), "");
475        assert_eq!(validate_base_prefix("  ").unwrap(), "");
476        assert_eq!(validate_base_prefix("/").unwrap(), "");
477    }
478
479    #[test]
480    fn base_prefix_rejects_url_like_and_traversal() {
481        assert!(validate_base_prefix("//evil.com").is_err());
482        assert!(validate_base_prefix("http://evil.com").is_err());
483        assert!(validate_base_prefix("/a?b=1").is_err());
484        assert!(validate_base_prefix("/a#frag").is_err());
485        assert!(validate_base_prefix("/../etc").is_err());
486        assert!(validate_base_prefix("/a/../b").is_err());
487    }
488
489    #[test]
490    fn user_agent_identifies_the_cli() {
491        let ua = user_agent();
492        assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
493        assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
494    }
495
496    #[test]
497    fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
498        let cfg = sdk_config("k".to_string());
499        let http = cfg.http.expect("http config should be set");
500        assert_eq!(
501            http.headers.as_ref().and_then(|h| h.get("User-Agent")),
502            Some(&user_agent())
503        );
504        // SDK defaults (timeout, pooling) must stay untouched.
505        assert_eq!(http.timeout_secs, None);
506        assert_eq!(http.pool_max_idle_per_host, None);
507    }
508}