Visitar URL original
feat: update to quicknode-sdk 0.9.0 and expose the new Admin operations (DX-7540) by johnpmitsch · Pull Request #61 · quicknode/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(endpoint): surface 0.9.0 rate-limit, security, metrics, and usag…
…e fields (DX-7540)

- rate-limit set/method-create/method-update accept --limit-by-ip
  <true|false>; set may send the flag alone; method-list shows LIMIT_BY_IP
- security set-options accepts --response-logging; security show and
  endpoint show list hsts, cors, response_logging, and a BLOCKED_IPS section
- request-filter tables render structured matchers and a PARAMS column
- endpoint metrics / metrics endpoint accept --percentile (1-100)
- bulk result tables show each failed item's ERROR
- usage by-endpoint/by-chain/by-tag/by-method show credit and byte
  breakdowns under --wide
- HTTP 403 renders as forbidden (feature or plan access) instead of
  unauthorized
  • Loading branch information
johnpmitsch committed Oct 29, 2026
commit 8d42bf32d3c4a75dfdae069dd1d34512059e9f12
30 changes: 14 additions & 16 deletions src/commands/endpoint/bulk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
use clap::{Args as ClapArgs, Subcommand};
use comfy_table::Cell;
use quicknode_sdk::admin::{
BulkAddTagRequest, BulkRemoveTagRequest, BulkUpdateEndpointStatusRequest,
BulkAddTagRequest, BulkOperationResult, BulkRemoveTagRequest, BulkUpdateEndpointStatusRequest,
};
use serde::Serialize;

use crate::confirm::{decide_without_prompt, prompt_yes_no, ConfirmCfg, Severity};
use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, set_header_bold, write_table, OutputCtx, Render};
use crate::output::{new_table, opt_cell, set_header_bold, write_table, OutputCtx, Render};

#[derive(Debug, Subcommand)]
pub enum BulkCmd {
Expand Down Expand Up @@ -127,28 +127,26 @@ async fn tag_remove(a: RemoveTagArgs, ctx: Ctx) -> Result<(), CliError> {

// ----- renderers ----- //

/// Shared body for all three bulk views: a summary line + an ID/OK table.
fn render_bulk_summary<'a, I>(
/// Shared body for all three bulk views: a summary line + an ID/OK/ERROR table.
fn render_bulk_summary(
w: &mut dyn std::io::Write,
ctx: &OutputCtx,
total: i32,
updated_count: i32,
failed_count: i32,
results: I,
) -> std::io::Result<()>
where
I: IntoIterator<Item = (&'a str, bool)>,
{
results: &[BulkOperationResult],
) -> std::io::Result<()> {
writeln!(
w,
"total={total} updated={updated_count} failed={failed_count}"
)?;
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, vec!["ID", "OK"]);
for (id, success) in results {
set_header_bold(&mut t, ctx, vec!["ID", "OK", "ERROR"]);
for r in results {
t.add_row(vec![
Cell::new(id),
Cell::new(if success { "✓" } else { "✗" }),
Cell::new(&r.id),
Cell::new(if r.success { "✓" } else { "✗" }),
opt_cell(&r.error),
]);
}
write_table(w, &t)
Expand All @@ -168,7 +166,7 @@ impl Render for BulkStatusView {
data.total,
data.updated_count,
data.failed_count,
data.results.iter().map(|r| (r.id.as_str(), r.success)),
&data.results,
)
}
}
Expand All @@ -187,7 +185,7 @@ impl Render for BulkAddTagView {
data.total,
data.updated_count,
data.failed_count,
data.results.iter().map(|r| (r.id.as_str(), r.success)),
&data.results,
)
}
}
Expand All @@ -206,7 +204,7 @@ impl Render for BulkRemoveTagView {
data.total,
data.updated_count,
data.failed_count,
data.results.iter().map(|r| (r.id.as_str(), r.success)),
&data.results,
)
}
}
5 changes: 4 additions & 1 deletion src/commands/endpoint/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ pub struct MetricsArgs {
/// Period (`hour`, `day`, `week`, `month`).
#[arg(long, value_parser = ["hour", "day", "week", "month"])]
pub period: String,
/// Percentile (1-100); required for response-time metrics.
#[arg(long, value_parser = clap::value_parser!(i32).range(1..=100))]
pub percentile: Option<i32>,
}

pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
Expand Down Expand Up @@ -361,7 +364,7 @@ async fn metrics(a: MetricsArgs, ctx: Ctx) -> Result<(), CliError> {
let req = GetEndpointMetricsRequest {
period: a.period,
metric: a.metric,
percentile: None,
percentile: a.percentile,
};
let resp = retrying(ctx.global.retries, || {
ctx.sdk.admin.get_endpoint_metrics(&a.id, &req)
Expand Down
33 changes: 22 additions & 11 deletions src/commands/endpoint/ratelimit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use serde::Serialize;

use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{new_table, opt_cell, set_header_bold, write_table, Render};
use crate::output::{bool_cell, new_table, opt_cell, set_header_bold, write_table, Render};
use crate::retry::retrying;

#[derive(Debug, Subcommand)]
Expand All @@ -20,7 +20,7 @@ pub enum RateLimitCmd {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
},
/// Set endpoint-level rate limits (any of rps/rpm/rpd; omitted are untouched).
/// Set endpoint-level rate limits (any of rps/rpm/rpd/limit-by-ip; omitted are untouched).
Set(SetArgs),
/// Delete a user-set rate-limit override (plan defaults can't be deleted).
DeleteOverride {
Expand Down Expand Up @@ -59,6 +59,9 @@ pub struct SetArgs {
/// Requests-per-day cap.
#[arg(long)]
pub rpd: Option<i32>,
/// Apply the caps per client IP (`true`) or to the whole endpoint (`false`).
#[arg(long, value_name = "true|false")]
pub limit_by_ip: Option<bool>,
}

#[derive(Debug, ClapArgs)]
Expand All @@ -77,6 +80,9 @@ pub struct MethodCreateArgs {
/// Rate (max calls per interval).
#[arg(long)]
pub rate: i32,
/// Apply the limit per client IP (`true`) or to the whole endpoint (`false`).
#[arg(long, value_name = "true|false")]
pub limit_by_ip: Option<bool>,
}

#[derive(Debug, ClapArgs)]
Expand All @@ -92,6 +98,9 @@ pub struct MethodUpdateArgs {
pub status: Option<StatusToggle>,
#[arg(long)]
pub rate: Option<i32>,
/// Apply the limit per client IP (`true`) or to the whole endpoint (`false`).
#[arg(long, value_name = "true|false")]
pub limit_by_ip: Option<bool>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
Expand Down Expand Up @@ -155,18 +164,19 @@ async fn get(id: &str, ctx: Ctx) -> Result<(), CliError> {
}

async fn set(a: SetArgs, ctx: Ctx) -> Result<(), CliError> {
if a.rps.is_none() && a.rpm.is_none() && a.rpd.is_none() {
let caps_given = a.rps.is_some() || a.rpm.is_some() || a.rpd.is_some();
if !caps_given && a.limit_by_ip.is_none() {
return Err(CliError::Arg(
"supply at least one of --rps, --rpm, --rpd".to_string(),
"supply at least one of --rps, --rpm, --rpd, --limit-by-ip".to_string(),
));
}
let req = UpdateRateLimitsRequest {
rate_limits: Some(RateLimitSettings {
rate_limits: caps_given.then_some(RateLimitSettings {
rps: a.rps,
rpm: a.rpm,
rpd: a.rpd,
}),
limit_by_ip: None,
limit_by_ip: a.limit_by_ip,
};
ctx.sdk.admin.update_rate_limits(&a.id, &req).await?;
ctx.out.note(&format!("✓ Updated rate limits on {}", a.id));
Expand All @@ -191,7 +201,7 @@ async fn method_create(a: MethodCreateArgs, ctx: Ctx) -> Result<(), CliError> {
interval: a.interval,
methods,
rate: a.rate,
limit_by_ip: None,
limit_by_ip: a.limit_by_ip,
};
let resp = ctx.sdk.admin.create_method_rate_limit(&a.id, &req).await?;
let d = resp.data.as_ref().ok_or_else(|| {
Expand All @@ -207,9 +217,9 @@ async fn method_create(a: MethodCreateArgs, ctx: Ctx) -> Result<(), CliError> {
async fn method_update(a: MethodUpdateArgs, ctx: Ctx) -> Result<(), CliError> {
let mut methods = a.methods;
methods.extend(a.methods_csv);
if methods.is_empty() && a.status.is_none() && a.rate.is_none() {
if methods.is_empty() && a.status.is_none() && a.rate.is_none() && a.limit_by_ip.is_none() {
return Err(CliError::Arg(
"supply at least one of --method, --status, --rate".into(),
"supply at least one of --method, --status, --rate, --limit-by-ip".into(),
));
}
let req = UpdateMethodRateLimitRequest {
Expand All @@ -220,7 +230,7 @@ async fn method_update(a: MethodUpdateArgs, ctx: Ctx) -> Result<(), CliError> {
},
status: a.status.map(|s| s.as_str().to_string()),
rate: a.rate,
limit_by_ip: None,
limit_by_ip: a.limit_by_ip,
};
ctx.sdk
.admin
Expand Down Expand Up @@ -289,7 +299,7 @@ impl Render for MethodRateLimitsView {
set_header_bold(
&mut t,
ctx,
vec!["ID", "INTERVAL", "METHODS", "RATE", "STATUS"],
vec!["ID", "INTERVAL", "METHODS", "RATE", "STATUS", "LIMIT_BY_IP"],
);
for r in &data.rate_limiters {
t.add_row(vec![
Expand All @@ -298,6 +308,7 @@ impl Render for MethodRateLimitsView {
Cell::new(r.methods.join(", ")),
Cell::new(r.rate),
Cell::new(&r.status),
bool_cell(r.limit_by_ip),
]);
}
write_table(w, &t)
Expand Down
16 changes: 16 additions & 0 deletions src/commands/endpoint/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,22 @@ impl Render for SingleEndpointView {
sec.request_filters.as_deref().unwrap_or(&[]).len(),
),
]);
t.add_row(vec![
Cell::new("security.blocked_ips"),
Cell::new(sec.blocked_ips.as_deref().unwrap_or(&[]).len()),
]);
t.add_row(vec![
Cell::new("security.hsts"),
bool_cell(opts.and_then(|o| o.hsts)),
]);
t.add_row(vec![
Cell::new("security.cors"),
bool_cell(opts.and_then(|o| o.cors)),
]);
t.add_row(vec![
Cell::new("security.response_logging"),
bool_cell(opts.and_then(|o| o.response_logging)),
]);
let ip_header = opts
.and_then(|o| o.ip_custom_header.as_ref())
.and_then(|h| h.value.clone());
Expand Down
59 changes: 55 additions & 4 deletions src/commands/endpoint/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ pub struct SetOptionsArgs {
pub request_filters: Option<Toggle>,
#[arg(long, value_enum)]
pub ip_custom_header: Option<Toggle>,
#[arg(long, value_enum)]
pub response_logging: Option<Toggle>,
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -282,11 +284,12 @@ async fn set_options(a: SetOptionsArgs, ctx: Ctx) -> Result<(), CliError> {
&& a.cors.is_none()
&& a.request_filters.is_none()
&& a.ip_custom_header.is_none()
&& a.response_logging.is_none()
{
return Err(CliError::Arg(
"'endpoint security set-options' requires at least one of: \
--tokens, --referrers, --jwts, --ips, --domain-masks, --hsts, \
--cors, --request-filters, --ip-custom-header."
--cors, --request-filters, --ip-custom-header, --response-logging."
.into(),
));
}
Expand All @@ -300,7 +303,7 @@ async fn set_options(a: SetOptionsArgs, ctx: Ctx) -> Result<(), CliError> {
cors: a.cors.map(|t| t.as_str().to_string()),
request_filters: a.request_filters.map(|t| t.as_str().to_string()),
ip_custom_header: a.ip_custom_header.map(|t| t.as_str().to_string()),
response_logging: None,
response_logging: a.response_logging.map(|t| t.as_str().to_string()),
};
let req = UpdateSecurityOptionsRequest { options };
let resp = ctx.sdk.admin.update_security_options(&a.id, &req).await?;
Expand Down Expand Up @@ -596,6 +599,18 @@ impl Render for SecurityShowView {
Cell::new("request_filters"),
bool_cell(opts.and_then(|o| o.request_filters)),
]);
t.add_row(vec![
Cell::new("hsts"),
bool_cell(opts.and_then(|o| o.hsts)),
]);
t.add_row(vec![
Cell::new("cors"),
bool_cell(opts.and_then(|o| o.cors)),
]);
t.add_row(vec![
Cell::new("response_logging"),
bool_cell(opts.and_then(|o| o.response_logging)),
]);
let ip_header = opts
.and_then(|o| o.ip_custom_header.as_ref())
.and_then(|h| h.value.clone());
Expand Down Expand Up @@ -680,21 +695,40 @@ pub(crate) fn security_item_sections(
.map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)])
.collect(),
)?;
let blocked = data.blocked_ips.as_deref().unwrap_or(&[]);
section(
w,
"BLOCKED_IPS",
vec!["ID", "IP"],
blocked
.iter()
.map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)])
.collect(),
)?;
let filters = data.request_filters.as_deref().unwrap_or(&[]);
section(
w,
"REQUEST_FILTERS",
vec!["ID", "METHODS"],
vec!["ID", "METHODS", "PARAMS"],
filters
.iter()
.map(|f| vec![Cell::new(&f.id), Cell::new(matchers_text(&f.method))])
.map(|f| {
vec![
Cell::new(&f.id),
Cell::new(matchers_text(&f.method)),
Cell::new(pattern_text(&f.params)),
]
})
.collect(),
)
}

/// Renders request-filter matchers for a table cell: string matchers as-is,
/// structured matchers (e.g. `{"prefix":"eth_"}`) as compact JSON.
fn matchers_text(matchers: &[serde_json::Value]) -> String {
if matchers.is_empty() {
return "—".to_string();
}
matchers
.iter()
.map(|m| match m.as_str() {
Expand All @@ -705,6 +739,23 @@ fn matchers_text(matchers: &[serde_json::Value]) -> String {
.join(", ")
}

/// Renders a JSON pattern map as compact JSON with sorted keys so table
/// output is stable; `—` when empty.
pub(crate) fn pattern_text(
pattern: &std::collections::HashMap<String, serde_json::Value>,
) -> String {
if pattern.is_empty() {
return "—".to_string();
}
let mut keys: Vec<&String> = pattern.keys().collect();
keys.sort();
let map: serde_json::Map<String, serde_json::Value> = keys
.into_iter()
.map(|k| (k.clone(), pattern[k].clone()))
.collect();
serde_json::Value::Object(map).to_string()
}

#[derive(Serialize)]
struct SecurityOptionsView(quicknode_sdk::admin::GetSecurityOptionsResponse);

Expand Down
5 changes: 4 additions & 1 deletion src/commands/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ pub struct EndpointArgs {
/// `metered_endpoint_credits_over_time`, `credits_over_time`, `response_status_breakdown`).
#[arg(long)]
pub metric: String,
/// Percentile (1-100); required for response-time metrics.
#[arg(long, value_parser = clap::value_parser!(i32).range(1..=100))]
pub percentile: Option<i32>,
}

pub async fn run(args: Args, ctx: Ctx) -> Result<(), CliError> {
Expand All @@ -75,7 +78,7 @@ async fn endpoint(a: EndpointArgs, ctx: Ctx) -> Result<(), CliError> {
let req = GetEndpointMetricsRequest {
period: a.period,
metric: a.metric,
percentile: None,
percentile: a.percentile,
};
let resp = retrying(ctx.global.retries, || {
ctx.sdk.admin.get_endpoint_metrics(&a.id, &req)
Expand Down
Loading