Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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));
Expand Down
52 changes: 50 additions & 2 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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-<lang>/<ver> (<os>-<arch>; …)`) with the CLI as the product:
/// `quicknode-cli/<version> (<os>-<arch>)`.
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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions tests/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading