diff --git a/Cargo.lock b/Cargo.lock index 70c2e9b..d37bcf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,9 +1508,9 @@ dependencies = [ [[package]] name = "quicknode-sdk" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721404747d5a5ecc4d0d8c655d0ca1c776f7394cc03609db42bf921bebacafae" +checksum = "23681a7c94665c8717f3f7eafb8da9080d8d2e0eeb3ea9c108eed38e30901ea9" dependencies = [ "config", "reqwest 0.13.4", diff --git a/Cargo.toml b/Cargo.toml index a532ac6..2338d9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ name = "qn" path = "src/lib.rs" [dependencies] -quicknode-sdk = "0.5" +quicknode-sdk = "0.6" clap = { version = "4", features = ["derive", "env", "wrap_help"] } clap_complete = "4" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/README.md b/README.md index 5418107..263389e 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ qn usage summary --from 7d qn usage by-endpoint --from 30d -o yaml qn metrics account --period day --metric credits_over_time qn chain list +qn chain credits ethereum qn billing invoices qn endpoint bulk pause ep-1 ep-2 ep-3 qn endpoint tag list diff --git a/src/commands/agent/context.md b/src/commands/agent/context.md index a2cb42a..4012326 100644 --- a/src/commands/agent/context.md +++ b/src/commands/agent/context.md @@ -35,8 +35,8 @@ Config file location: - Windows: `%USERPROFILE%\.config\qn\config.toml`. Verify the resolved key against the API: `qn auth whoami` (prints the key redacted -to `****` and confirms it works). `qn auth status` does the same without the -network call. +to `****`, the account id/name and plan, and confirms it works). `qn auth +status` does the same without the network call (no account details). ## 2. Output contract @@ -108,7 +108,7 @@ Top-level nouns (plurals like `endpoints`/`streams` and `ls` are accepted aliase - `team` — list, create, show, delete, endpoints, set-endpoints; nested: `member` - `usage` — summary, by-endpoint, by-method, by-chain, by-tag - `metrics` — account, endpoint -- `chain` — list +- `chain` — list, credits - `billing` — invoices, payments - `stream` — list, show, create, update, delete, activate, pause, test-filter, enabled-count diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 08ea5c2..4c15aff 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -2,9 +2,9 @@ //! //! Login: prompts for the key (hidden input), writes `~/.config/qn/config.toml`. //! Logout: deletes that file. -//! Whoami: shows where the resolved key came from, and confirms it works by -//! calling a low-cost API (chain list). -//! Status: same as whoami minus the network round-trip. +//! Whoami: shows where the resolved key came from and the account identity +//! (id, name, plan), and confirms it works by calling the account-info API. +//! Status: same as whoami minus the network round-trip (no account details). use std::io::{IsTerminal, Write}; @@ -12,7 +12,7 @@ use clap::{Args as ClapArgs, Subcommand}; use quicknode_sdk::QuicknodeSdk; use crate::config::{self, KeySource}; -use crate::context::{sdk_config, GlobalArgs}; +use crate::context::{sdk_config_with_base, GlobalArgs}; use crate::errors::CliError; use crate::output::osc8_link; @@ -132,8 +132,11 @@ 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(&sdk_config(key.clone()))?; - crate::retry::retrying(global.retries, || sdk.admin.list_chains()).await?; + let sdk = QuicknodeSdk::new(&sdk_config_with_base( + key.clone(), + global.base_url.as_deref(), + )?)?; + crate::retry::retrying(global.retries, || sdk.admin.account_info()).await?; config::save_api_key(&path, &key)?; if !global.quiet { @@ -162,17 +165,25 @@ fn logout(global: GlobalArgs) -> Result<(), CliError> { fn status(global: GlobalArgs) -> Result<(), CliError> { let (key, source) = resolve_non_interactive(&global)?; - print_status(&global, source, &redact(&key), None); + print_status(&global, source, &redact(&key), None, None); Ok(()) } async fn whoami(global: GlobalArgs) -> Result<(), CliError> { let (key, source) = resolve_non_interactive(&global)?; let redacted = redact(&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)); + let sdk = QuicknodeSdk::new(&sdk_config_with_base(key, global.base_url.as_deref())?)?; + // account_info doubles as the liveness probe: one call validates the key + // and returns the account identity we display below. + let result = crate::retry::retrying(global.retries, || sdk.admin.account_info()).await; + let account = result.as_ref().ok().and_then(|r| r.data.clone()); + print_status( + &global, + source, + &redacted, + Some(result.is_ok()), + account.as_ref(), + ); result.map(|_| ()).map_err(Into::into) } @@ -196,11 +207,43 @@ fn redact(key: &str) -> String { } } -fn print_status(global: &GlobalArgs, source: KeySource, redacted: &str, validated: Option) { +/// Renders a subscription as a compact `plan_name (status, interval)` string, +/// skipping any absent part. Returns `None` when there is nothing to show. +fn plan_summary(sub: &quicknode_sdk::admin::AccountSubscription) -> Option { + let name = sub.plan_name.as_deref(); + let mut quals = Vec::new(); + if let Some(s) = sub.status.as_deref() { + quals.push(s); + } + if let Some(i) = sub.interval.as_deref() { + quals.push(i); + } + match (name, quals.is_empty()) { + (None, true) => None, + (Some(n), true) => Some(n.to_string()), + (None, false) => Some(quals.join(", ")), + (Some(n), false) => Some(format!("{n} ({})", quals.join(", "))), + } +} + +fn print_status( + global: &GlobalArgs, + source: KeySource, + redacted: &str, + validated: Option, + account: Option<&quicknode_sdk::admin::AccountInfo>, +) { + let sub = account.and_then(|a| a.subscription.as_ref()); + let plan = sub.and_then(plan_summary); let v = serde_json::json!({ "source": source.label(), "key": redacted, "validated": validated, + "account_id": account.map(|a| a.id), + "account_name": account.map(|a| a.name.clone()), + "plan": plan, + "plan_status": sub.and_then(|s| s.status.clone()), + "plan_interval": sub.and_then(|s| s.interval.clone()), }); let stdout_is_tty = std::io::stdout().is_terminal(); match global.resolve_format(stdout_is_tty) { @@ -216,6 +259,10 @@ fn print_status(global: &GlobalArgs, source: KeySource, redacted: &str, validate crate::output::Format::Table | crate::output::Format::Md => { println!("source : {}", source.label()); println!("key : {}", redacted); + if let Some(a) = account { + println!("account: {} ({})", a.id, a.name); + println!("plan : {}", plan.as_deref().unwrap_or("")); + } if let Some(ok) = validated { println!("status : {}", if ok { "valid" } else { "rejected by API" }); } diff --git a/src/commands/chain.rs b/src/commands/chain.rs index 08d4ad0..f95f7b4 100644 --- a/src/commands/chain.rs +++ b/src/commands/chain.rs @@ -20,11 +20,18 @@ pub enum ChainCmd { /// List supported chains and their networks. #[command(visible_alias = "ls")] List, + /// Show per-method API credit costs for a chain. + Credits { + /// Chain slug (e.g. `ethereum`; run `qn chain list` to see all). + #[arg(value_name = "CHAIN")] + chain: String, + }, } pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> { match args.cmd { ChainCmd::List => list(ctx).await, + ChainCmd::Credits { chain } => credits(chain, ctx).await, } } @@ -33,6 +40,11 @@ async fn list(ctx: Ctx) -> Result<(), CliError> { crate::output::emit(&ctx.out, &ChainsView(resp)) } +async fn credits(chain: String, ctx: Ctx) -> Result<(), CliError> { + let resp = retrying(ctx.global.retries, || ctx.sdk.admin.get_api_credits(&chain)).await?; + crate::output::emit(&ctx.out, &ApiCreditsView(resp)) +} + #[derive(Serialize)] struct ChainsView(quicknode_sdk::admin::ListChainsResponse); @@ -56,3 +68,28 @@ impl Render for ChainsView { write_table(w, &t) } } + +#[derive(Serialize)] +struct ApiCreditsView(quicknode_sdk::admin::GetApiCreditsResponse); + +impl Render for ApiCreditsView { + fn render_table( + &self, + w: &mut dyn std::io::Write, + ctx: &crate::output::OutputCtx, + ) -> std::io::Result<()> { + let rows = match &self.0.data { + Some(d) => d, + None => { + writeln!(w, "(no credits)")?; + return Ok(()); + } + }; + let mut t = new_table(ctx); + set_header_bold(&mut t, ctx, vec!["METHOD", "CREDITS"]); + for c in rows { + t.add_row(vec![Cell::new(&c.method), Cell::new(c.credits)]); + } + write_table(w, &t) + } +} diff --git a/src/context.rs b/src/context.rs index 07033b7..2974684 100644 --- a/src/context.rs +++ b/src/context.rs @@ -118,6 +118,41 @@ pub fn sdk_config(api_key: String) -> SdkFullConfig { full } +/// Points every sub-client at a custom host, suffixing each with its own base +/// path. Shared by `Ctx::from_global` and the `auth` commands so `--base-url` +/// applies uniformly (useful for wiremock tests and on-prem mirrors). +fn apply_base_url(full: &mut SdkFullConfig, trimmed: &str) { + full.admin = Some(AdminConfig { + base_url: Some(format!("{trimmed}/v0/")), + }); + full.streams = Some(StreamsConfig { + base_url: Some(format!("{trimmed}/streams/rest/v1/")), + }); + full.webhooks = Some(WebhooksConfig { + base_url: Some(format!("{trimmed}/webhooks/rest/v1/")), + }); + full.kvstore = Some(KvStoreConfig { + base_url: Some(format!("{trimmed}/kv/rest/v1/")), + }); + full.sql = Some(SqlConfig { + base_url: Some(format!("{trimmed}/sql/rest/v1/")), + }); +} + +/// Like [`sdk_config`] but also honors an optional `--base-url` override. +/// Used by the `auth` commands, which build the SDK outside [`Ctx`]. +pub fn sdk_config_with_base( + api_key: String, + base_url: Option<&str>, +) -> Result { + let mut full = sdk_config(api_key); + if let Some(base) = base_url { + let trimmed = validate_base_url(base)?; + apply_base_url(&mut full, trimmed.as_str()); + } + Ok(full) +} + pub struct Ctx { pub sdk: QuicknodeSdk, pub out: OutputCtx, @@ -149,22 +184,7 @@ impl Ctx { // so we suffix correctly. if let Some(base) = &global.base_url { let trimmed = validate_base_url(base)?; - let trimmed = trimmed.as_str(); - full.admin = Some(AdminConfig { - base_url: Some(format!("{trimmed}/v0/")), - }); - full.streams = Some(StreamsConfig { - base_url: Some(format!("{trimmed}/streams/rest/v1/")), - }); - full.webhooks = Some(WebhooksConfig { - base_url: Some(format!("{trimmed}/webhooks/rest/v1/")), - }); - full.kvstore = Some(KvStoreConfig { - base_url: Some(format!("{trimmed}/kv/rest/v1/")), - }); - full.sql = Some(SqlConfig { - base_url: Some(format!("{trimmed}/sql/rest/v1/")), - }); + apply_base_url(&mut full, trimmed.as_str()); } let sdk = QuicknodeSdk::new(&full)?; diff --git a/tests/admin_extra.rs b/tests/admin_extra.rs index ceffca0..ce5539e 100644 --- a/tests/admin_extra.rs +++ b/tests/admin_extra.rs @@ -165,6 +165,70 @@ async fn chain_list() { assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); } +#[tokio::test] +async fn chain_credits() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/api-credits/ethereum")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [ + { "method": "eth_chainId", "credits": 20 }, + { "method": "eth_call", "credits": 20 } + ] + }))) + .mount(&server) + .await; + let out = run_qn(&server.uri(), &["chain", "credits", "ethereum"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn chain_credits_unknown_slug_404() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/api-credits/not-a-chain")) + .respond_with( + ResponseTemplate::new(404).set_body_string("{\"message\":\"chain not found\"}"), + ) + .mount(&server) + .await; + let out = run_qn(&server.uri(), &["chain", "credits", "not-a-chain"]).await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); + assert!(out.stderr.contains("not found"), "stderr={}", out.stderr); +} + +#[tokio::test] +async fn auth_whoami_shows_account() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/account/info")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "id": 12345, + "name": "Acme Inc", + "created_at": "2024-01-01T00:00:00Z", + "billing_version": "v6", + "subscription": { "plan_name": "Build", "status": "active", "interval": "monthly" } + } + }))) + .mount(&server) + .await; + let out = run_qn(&server.uri(), &["auth", "whoami"]).await; + assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); +} + +#[tokio::test] +async fn auth_whoami_unauthorized_fails() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/account/info")) + .respond_with(ResponseTemplate::new(401).set_body_string("{\"message\":\"unauthorized\"}")) + .mount(&server) + .await; + let out = run_qn(&server.uri(), &["auth", "whoami"]).await; + assert_eq!(out.exit_code, 2, "stderr={}", out.stderr); +} + #[tokio::test] async fn metrics_account_with_percentile() { let server = MockServer::start().await;