From 6a88ce1d40ca9ad4fb5f780f3e6fe54a10fcbf88 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 10 Jun 2026 18:53:44 -0400 Subject: [PATCH] feat(http): send quicknode-cli user agent on all requests (DX-5655) Every qn request previously went out with the SDK's auto-generated User-Agent (quicknode-sdk-rust/), making CLI traffic indistinguishable from direct SDK usage. All requests now carry 'quicknode-cli/ (-)', mirroring the SDK's UA shape, set via the SDK-supported HttpConfig.headers override. A shared context::sdk_config constructor applies the header at every SDK construction site, including 'auth login' validation and 'auth whoami'. SDK HTTP defaults (timeout, pooling) are unchanged. Tests: +3 (UA shape unit test, config wiring unit test, wiremock wire-level header assertion on 'endpoint list'). --- src/commands/auth.rs | 8 +++---- src/context.rs | 52 ++++++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 2 +- tests/endpoint.rs | 18 +++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 8da4e30..ab3786f 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -9,10 +9,10 @@ use std::io::{IsTerminal, Write}; use clap::{Args as ClapArgs, Subcommand}; -use quicknode_sdk::{QuicknodeSdk, SdkFullConfig}; +use quicknode_sdk::QuicknodeSdk; use crate::config::{self, KeySource}; -use crate::context::GlobalArgs; +use crate::context::{sdk_config, GlobalArgs}; use crate::errors::CliError; #[derive(Debug, ClapArgs)] @@ -76,7 +76,7 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> { } // Quick validation against the API so we don't silently save a bogus key. - let sdk = QuicknodeSdk::new(&SdkFullConfig::from_api_key(key.clone()))?; + let sdk = QuicknodeSdk::new(&sdk_config(key.clone()))?; crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await?; config::save_api_key(&path, &key)?; @@ -106,7 +106,7 @@ fn status(global: GlobalArgs) -> Result<(), CliError> { async fn whoami(global: GlobalArgs) -> Result<(), CliError> { let (key, source) = resolve_non_interactive(&global)?; let redacted = redact(&key); - let sdk = QuicknodeSdk::new(&SdkFullConfig::from_api_key(key))?; + let sdk = QuicknodeSdk::new(&sdk_config(key))?; let result = crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await; let ok = result.is_ok(); print_status(&global, source, &redacted, Some(ok)); diff --git a/src/context.rs b/src/context.rs index 336eed3..1616c71 100644 --- a/src/context.rs +++ b/src/context.rs @@ -5,7 +5,8 @@ use std::io::IsTerminal; use quicknode_sdk::{ - AdminConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, StreamsConfig, WebhooksConfig, + AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, StreamsConfig, + WebhooksConfig, }; use crate::config; @@ -90,6 +91,33 @@ fn resolve_output_inner( (format, wide) } +/// The `User-Agent` sent with every API request. Mirrors the SDK's own shape +/// (`quicknode-sdk-/ (-; …)`) with the CLI as the product: +/// `quicknode-cli/ (-)`. +pub fn user_agent() -> String { + format!( + "quicknode-cli/{} ({}-{})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) +} + +/// Base SDK config shared by every construction site (`Ctx::from_global` and +/// the `auth` commands): the API key plus the CLI `User-Agent`. Custom headers +/// in `HttpConfig` override SDK-managed headers of the same name, which is the +/// SDK's supported way to replace its auto-generated `User-Agent`. +pub fn sdk_config(api_key: String) -> SdkFullConfig { + let mut full = SdkFullConfig::from_api_key(api_key); + let mut headers = std::collections::HashMap::new(); + headers.insert("User-Agent".to_string(), user_agent()); + full.http = Some(HttpConfig { + headers: Some(headers), + ..Default::default() + }); + full +} + pub struct Ctx { pub sdk: QuicknodeSdk, pub out: OutputCtx, @@ -114,7 +142,7 @@ impl Ctx { || unreachable!("prompt disabled for non-auth commands"), )?; - let mut full = SdkFullConfig::from_api_key(api_key); + let mut full = sdk_config(api_key); // --base-url applies to every sub-client. Useful for wiremock tests and // on-prem mirrors. Each sub-client has its own base path under the host @@ -270,4 +298,24 @@ mod tests { assert!(validate_base_url("not a url").is_err()); assert!(validate_base_url("").is_err()); } + + #[test] + fn user_agent_identifies_the_cli() { + let ua = user_agent(); + assert!(ua.starts_with("quicknode-cli/"), "ua={ua}"); + assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}"); + } + + #[test] + fn sdk_config_sets_the_user_agent_header_and_nothing_else() { + let cfg = sdk_config("k".to_string()); + let http = cfg.http.expect("http config should be set"); + assert_eq!( + http.headers.as_ref().and_then(|h| h.get("User-Agent")), + Some(&user_agent()) + ); + // SDK defaults (timeout, pooling) must stay untouched. + assert_eq!(http.timeout_secs, None); + assert_eq!(http.pool_max_idle_per_host, None); + } } diff --git a/src/lib.rs b/src/lib.rs index aade0ae..6ece18c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,13 @@ //! See `tests/common/mod.rs` for the test harness. pub mod cli; +pub mod context; pub mod errors; pub mod output; pub(crate) mod commands; pub(crate) mod config; pub(crate) mod confirm; -pub(crate) mod context; pub(crate) mod retry; pub(crate) mod time_arg; diff --git a/tests/endpoint.rs b/tests/endpoint.rs index 10a0a41..2e1e777 100644 --- a/tests/endpoint.rs +++ b/tests/endpoint.rs @@ -184,6 +184,24 @@ async fn list_endpoints_happy_path() { assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } +#[tokio::test] +async fn requests_carry_the_cli_user_agent() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/endpoints")) + .and(header("user-agent", qn::context::user_agent().as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [], + "pagination": { "total": 0, "limit": 20, "offset": 0 } + }))) + .expect(1) + .mount(&server) + .await; + + let out = run_qn(&server.uri(), &["endpoint", "list"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + #[tokio::test] async fn list_endpoints_404_renders_clean_error() { let server = MockServer::start().await;