Skip to content

Commit 6a88ce1

Browse files
committed
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/<version>), making CLI traffic indistinguishable from direct SDK usage. All requests now carry 'quicknode-cli/<version> (<os>-<arch>)', 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').
1 parent 816125b commit 6a88ce1

4 files changed

Lines changed: 73 additions & 7 deletions

File tree

src/commands/auth.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
use std::io::{IsTerminal, Write};
1010

1111
use clap::{Args as ClapArgs, Subcommand};
12-
use quicknode_sdk::{QuicknodeSdk, SdkFullConfig};
12+
use quicknode_sdk::QuicknodeSdk;
1313

1414
use crate::config::{self, KeySource};
15-
use crate::context::GlobalArgs;
15+
use crate::context::{sdk_config, GlobalArgs};
1616
use crate::errors::CliError;
1717

1818
#[derive(Debug, ClapArgs)]
@@ -76,7 +76,7 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> {
7676
}
7777

7878
// Quick validation against the API so we don't silently save a bogus key.
79-
let sdk = QuicknodeSdk::new(&SdkFullConfig::from_api_key(key.clone()))?;
79+
let sdk = QuicknodeSdk::new(&sdk_config(key.clone()))?;
8080
crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await?;
8181

8282
config::save_api_key(&path, &key)?;
@@ -106,7 +106,7 @@ fn status(global: GlobalArgs) -> Result<(), CliError> {
106106
async fn whoami(global: GlobalArgs) -> Result<(), CliError> {
107107
let (key, source) = resolve_non_interactive(&global)?;
108108
let redacted = redact(&key);
109-
let sdk = QuicknodeSdk::new(&SdkFullConfig::from_api_key(key))?;
109+
let sdk = QuicknodeSdk::new(&sdk_config(key))?;
110110
let result = crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await;
111111
let ok = result.is_ok();
112112
print_status(&global, source, &redacted, Some(ok));

src/context.rs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
use std::io::IsTerminal;
66

77
use quicknode_sdk::{
8-
AdminConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, StreamsConfig, WebhooksConfig,
8+
AdminConfig, HttpConfig, KvStoreConfig, QuicknodeSdk, SdkFullConfig, StreamsConfig,
9+
WebhooksConfig,
910
};
1011

1112
use crate::config;
@@ -90,6 +91,33 @@ fn resolve_output_inner(
9091
(format, wide)
9192
}
9293

94+
/// The `User-Agent` sent with every API request. Mirrors the SDK's own shape
95+
/// (`quicknode-sdk-<lang>/<ver> (<os>-<arch>; …)`) with the CLI as the product:
96+
/// `quicknode-cli/<version> (<os>-<arch>)`.
97+
pub fn user_agent() -> String {
98+
format!(
99+
"quicknode-cli/{} ({}-{})",
100+
env!("CARGO_PKG_VERSION"),
101+
std::env::consts::OS,
102+
std::env::consts::ARCH,
103+
)
104+
}
105+
106+
/// Base SDK config shared by every construction site (`Ctx::from_global` and
107+
/// the `auth` commands): the API key plus the CLI `User-Agent`. Custom headers
108+
/// in `HttpConfig` override SDK-managed headers of the same name, which is the
109+
/// SDK's supported way to replace its auto-generated `User-Agent`.
110+
pub fn sdk_config(api_key: String) -> SdkFullConfig {
111+
let mut full = SdkFullConfig::from_api_key(api_key);
112+
let mut headers = std::collections::HashMap::new();
113+
headers.insert("User-Agent".to_string(), user_agent());
114+
full.http = Some(HttpConfig {
115+
headers: Some(headers),
116+
..Default::default()
117+
});
118+
full
119+
}
120+
93121
pub struct Ctx {
94122
pub sdk: QuicknodeSdk,
95123
pub out: OutputCtx,
@@ -114,7 +142,7 @@ impl Ctx {
114142
|| unreachable!("prompt disabled for non-auth commands"),
115143
)?;
116144

117-
let mut full = SdkFullConfig::from_api_key(api_key);
145+
let mut full = sdk_config(api_key);
118146

119147
// --base-url applies to every sub-client. Useful for wiremock tests and
120148
// on-prem mirrors. Each sub-client has its own base path under the host
@@ -270,4 +298,24 @@ mod tests {
270298
assert!(validate_base_url("not a url").is_err());
271299
assert!(validate_base_url("").is_err());
272300
}
301+
302+
#[test]
303+
fn user_agent_identifies_the_cli() {
304+
let ua = user_agent();
305+
assert!(ua.starts_with("quicknode-cli/"), "ua={ua}");
306+
assert!(ua.contains(env!("CARGO_PKG_VERSION")), "ua={ua}");
307+
}
308+
309+
#[test]
310+
fn sdk_config_sets_the_user_agent_header_and_nothing_else() {
311+
let cfg = sdk_config("k".to_string());
312+
let http = cfg.http.expect("http config should be set");
313+
assert_eq!(
314+
http.headers.as_ref().and_then(|h| h.get("User-Agent")),
315+
Some(&user_agent())
316+
);
317+
// SDK defaults (timeout, pooling) must stay untouched.
318+
assert_eq!(http.timeout_secs, None);
319+
assert_eq!(http.pool_max_idle_per_host, None);
320+
}
273321
}

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
//! See `tests/common/mod.rs` for the test harness.
55
66
pub mod cli;
7+
pub mod context;
78
pub mod errors;
89
pub mod output;
910

1011
pub(crate) mod commands;
1112
pub(crate) mod config;
1213
pub(crate) mod confirm;
13-
pub(crate) mod context;
1414
pub(crate) mod retry;
1515
pub(crate) mod time_arg;
1616

tests/endpoint.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,24 @@ async fn list_endpoints_happy_path() {
184184
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
185185
}
186186

187+
#[tokio::test]
188+
async fn requests_carry_the_cli_user_agent() {
189+
let server = MockServer::start().await;
190+
Mock::given(method("GET"))
191+
.and(path("/v0/endpoints"))
192+
.and(header("user-agent", qn::context::user_agent().as_str()))
193+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
194+
"data": [],
195+
"pagination": { "total": 0, "limit": 20, "offset": 0 }
196+
})))
197+
.expect(1)
198+
.mount(&server)
199+
.await;
200+
201+
let out = run_qn(&server.uri(), &["endpoint", "list"]).await;
202+
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
203+
}
204+
187205
#[tokio::test]
188206
async fn list_endpoints_404_renders_clean_error() {
189207
let server = MockServer::start().await;

0 commit comments

Comments
 (0)