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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/commands/agent/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `****<last4>` and confirms it works). `qn auth status` does the same without the
network call.
to `****<last4>`, 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

Expand Down Expand Up @@ -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
Expand Down
71 changes: 59 additions & 12 deletions src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
//!
//! 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};

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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand All @@ -196,11 +207,43 @@ fn redact(key: &str) -> String {
}
}

fn print_status(global: &GlobalArgs, source: KeySource, redacted: &str, validated: Option<bool>) {
/// 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<String> {
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<bool>,
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) {
Expand All @@ -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("<none>"));
}
if let Some(ok) = validated {
println!("status : {}", if ok { "valid" } else { "rejected by API" });
}
Expand Down
37 changes: 37 additions & 0 deletions src/commands/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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);

Expand All @@ -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)
}
}
52 changes: 36 additions & 16 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SdkFullConfig, CliError> {
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,
Expand Down Expand Up @@ -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)?;
Expand Down
64 changes: 64 additions & 0 deletions tests/admin_extra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading